+1 (726) 227-3497

Monitoring and Alerting for Amazon Redshift: CloudWatch Alarms, SYS Views and Runbooks

Most Redshift incidents are not mysteries. They are a disk-full event nobody watched creep up, a queue that has been backing up since a new dashboard shipped, a Serverless workgroup that quietly doubled its RPU-hours, or a COPY that has been failing silently for three days while the dashboards show stale numbers. All of them are visible in telemetry that Redshift already emits. The gap is that nobody wired the telemetry to a pager with a runbook attached.

This is a practical monitoring build-out: which CloudWatch metrics matter, which SYS views to poll and how, what thresholds to alarm on, and what the on-call person should do when each alarm fires. It applies to both provisioned RA3 clusters and Redshift Serverless, with the differences called out.

Layer 1: CloudWatch metrics and alarms

CloudWatch is where you put anything you want to page on, because it has alarms, composite alarms and anomaly detection built in. The metric sets differ by deployment.

Provisioned (namespace AWS/Redshift), the ones worth alarming on:

MetricAlarm onWhy
PercentageDiskSpaceUsed> 80% for 15 minAbove ~90% queries start failing and vacuum/resize options narrow
HealthStatus< 1 for 5 minCluster unhealthy
MaintenanceMode= 1 unexpectedlyExplains a "the warehouse is down" ticket in ten seconds
QueryDuration (by WLM queue)anomaly detection bandCatches regressions without a fixed threshold
WLMQueueLength> 10 for 10 minQueueing, not slowness, is the usual user complaint
ConcurrencyScalingSecondsdaily sum > budgetThis is a real line item on the bill
ReadIOPS / WriteLatencyanomaly bandStorage-side weirdness

Serverless (namespace AWS/Redshift-Serverless): you care about ComputeCapacity (RPUs in use), ComputeSeconds (the cost driver), QueriesRunning, QueriesQueued and DatabaseConnections. There is no disk metric in the same sense; storage is managed, so StorageUsedBytes is a cost signal rather than an availability one.

A useful Serverless cost alarm, since RPU-seconds accumulate fast when someone points a bad BI extract at it:

aws cloudwatch put-metric-alarm \
  --alarm-name redshift-serverless-rpu-hours-spike \
  --namespace AWS/Redshift-Serverless \
  --metric-name ComputeSeconds \
  --dimensions Name=Workgroup,Value=analytics-prod \
  --statistic Sum --period 3600 --evaluation-periods 2 \
  --threshold 720000 --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:us-east-1:111111111111:data-platform-oncall

Pair it with the workgroup's max RPU limit and a usage limit action so the ceiling is enforced, not merely observed:

-- Provisioned equivalent: cap Concurrency Scaling spend
CREATE USAGE LIMIT FOR CLUSTER
  FEATURE concurrency_scaling
  LIMIT 120 MINUTES PER MONTH
  ACTION log;  -- or disable / emit-metric

Alarms with no action are noise. Route them to a single SNS topic, then to your paging tool, and use composite alarms so that "cluster unhealthy" suppresses the ten downstream alarms it causes.

Layer 2: SYS views, polled on a schedule

CloudWatch tells you the warehouse is unhappy. The SYS views tell you which query, which table and which user. On RA3 and Serverless the monitoring views to standardise on are SYS_QUERY_HISTORY, SYS_QUERY_DETAIL, SYS_LOAD_HISTORY, SYS_LOAD_ERROR_DETAIL, SYS_SERVERLESS_USAGE, SYS_CONNECTION_LOG and SYS_TRANSACTION_HISTORY. The older STL_/SVL_ tables still exist, but the SYS views are the supported, deployment-independent surface, and they already unify main-cluster and Concurrency Scaling activity.

A small set of queries covers most of what you want to know. Run them every few minutes from a Lambda on EventBridge using the Data API, and publish the results as custom CloudWatch metrics, so alarms and dashboards all live in one place.

Failed loads in the last hour — the single highest-value check, because a silent load failure is a correctness incident, not a performance one:

SELECT trim(table_name) AS table_name,
       COUNT(*) AS failures,
       MAX(start_time) AS last_failure
FROM SYS_LOAD_HISTORY
WHERE status <> 1
  AND start_time > DATEADD(hour, -1, GETDATE())
GROUP BY 1
ORDER BY failures DESC;

Queue and runtime pressure right now:

SELECT user_id, query_id, status,
       DATEDIFF(second, start_time, GETDATE()) AS elapsed_s,
       queue_time / 1000000.0 AS queue_s,
       LEFT(query_text, 120) AS sql_preview
FROM SYS_QUERY_HISTORY
WHERE status = 'running'
  AND DATEDIFF(second, start_time, GETDATE()) > 300
ORDER BY elapsed_s DESC;

Day-over-day regression by query hash — this is how you catch "someone shipped a dbt change last night":

WITH d AS (
  SELECT query_hash,
         DATE_TRUNC('day', start_time) AS day,
         COUNT(*) AS runs,
         PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY elapsed_time) / 1000000.0 AS p95_s
  FROM SYS_QUERY_HISTORY
  WHERE start_time > DATEADD(day, -8, GETDATE())
    AND query_type = 'SELECT'
  GROUP BY 1, 2
)
SELECT t.query_hash, t.runs, ROUND(t.p95_s, 1) AS p95_today,
       ROUND(y.p95_s, 1) AS p95_baseline,
       ROUND(t.p95_s / NULLIF(y.p95_s, 0), 2) AS ratio
FROM d t
JOIN d y ON y.query_hash = t.query_hash
        AND y.day = DATEADD(day, -7, t.day)
WHERE t.day = DATE_TRUNC('day', GETDATE())
  AND t.runs > 5
  AND t.p95_s > 30
  AND t.p95_s > y.p95_s * 2
ORDER BY ratio DESC;

Serverless spend by workgroup and day:

SELECT DATE_TRUNC('day', start_time) AS day,
       SUM(charged_seconds) / 3600.0 AS rpu_hours
FROM SYS_SERVERLESS_USAGE
WHERE start_time > DATEADD(day, -14, GETDATE())
GROUP BY 1 ORDER BY 1;

Table health (bloat and skew, the slow-moving killers) from SVV_TABLE_INFO, checked daily rather than per-minute:

SELECT "table", size AS mb, unsorted, stats_off, skew_rows, vacuum_sort_benefit
FROM SVV_TABLE_INFO
WHERE size > 10000
  AND (unsorted > 20 OR stats_off > 20 OR skew_rows > 5)
ORDER BY size DESC;

Layer 3: freshness and correctness checks

Availability monitoring will happily report a perfectly healthy warehouse serving yesterday's numbers. Add a freshness contract per critical table and alarm on it:

SELECT 'fct_orders' AS dataset,
       DATEDIFF(minute, MAX(loaded_at), GETDATE()) AS staleness_min
FROM analytics.fct_orders
UNION ALL
SELECT 'fct_sessions', DATEDIFF(minute, MAX(loaded_at), GETDATE())
FROM analytics.fct_sessions;

Publish staleness_min per dataset as a custom metric and set the threshold to roughly twice the expected load interval. If you run dbt, its source freshness and test results are the same signal from a different angle; export both, but keep at least one freshness check that runs independently of the pipeline it is watching, or a broken scheduler takes your monitoring down with it.

Logging and retention

SYS views keep only a rolling window (days, not months), so anything you want for capacity planning or postmortems must be persisted. Two options, both cheap:

  • Audit logging to S3 or CloudWatch Logs — enable connection, user and user-activity logs at the cluster/namespace level. Required for most compliance regimes anyway.
  • A nightly UNLOAD of SYS_QUERY_HISTORY into a partitioned S3 prefix, queried through Spectrum or an Iceberg table. This gives you a year of query telemetry for a few dollars and is the dataset that makes right-sizing arguments winnable.
UNLOAD ($$SELECT * FROM SYS_QUERY_HISTORY
         WHERE start_time >= DATEADD(day, -1, CURRENT_DATE)
           AND start_time <  CURRENT_DATE$$)
TO 's3://acme-redshift-telemetry/query_history/dt=2026-03-01/'
IAM_ROLE 'arn:aws:iam::111111111111:role/RedshiftUnload'
FORMAT PARQUET PARTITION BY (user_id) ALLOWOVERWRITE;

Runbooks: what on-call actually does

An alarm without a runbook is a 3 a.m. guessing game. Keep them short and in the alarm description.

Disk above 80% — check SVV_TABLE_INFO for the largest and most unsorted tables; drop obvious temp/staging leftovers; check for a long-running transaction pinning deleted rows (SYS_TRANSACTION_HISTORY); if genuine growth, resize or elastic-resize and schedule cold-data tiering.

Queue length sustained — identify the offending queries with the running-query view above; confirm whether Concurrency Scaling is engaged; lower the priority of the offending queue or cancel the top offender with CANCEL <pid>; file the query for tuning rather than leaving the mitigation in place.

Load failures — read SYS_LOAD_ERROR_DETAIL for the specific row and column; the cause is almost always a schema drift or an encoding/width change upstream; re-run after fixing, and make the pipeline fail loudly next time.

Serverless RPU spike — group SYS_QUERY_HISTORY by user for the spike window; it is usually an unbounded BI extract or an accidental cross join; apply a query monitoring rule to abort queries over a row/time threshold, and cap max RPUs.

Stale dataset — check the orchestrator first, then SYS_LOAD_HISTORY, then upstream. Communicate staleness to dashboard consumers before they find it themselves; a banner on the dashboard beats a Slack thread.

Putting it together

The end state is modest and achievable in a couple of days: one SNS topic, eight to twelve CloudWatch alarms with runbook links in their descriptions, one Lambda polling six SYS queries into custom metrics, one dashboard (CloudWatch or Grafana via the Redshift data source), and a nightly UNLOAD retaining telemetry. Everything above is Terraform-able, so it ships with the warehouse rather than after the first incident.

If you would rather have this installed and tuned by people who have run Redshift fleets at terabyte-a-day scale, our Redshift Administration and Redshift Performance Optimization practices do exactly this engagement — telemetry, alarms, runbooks and an on-call handover. Get in touch to scope it.