12Monitoring
This route decides what is watched, what raises an alarm, and who that alarm reaches. The decisions that matter are not the thresholds: they are that the chain is alerted rather than each task, that late and failed are separate conditions with separate responses, and that run duration is tracked as a slope long before anything fails.
Job and pipeline health
Three system tables and one event log answer the narrow question monitoring is for: did it run, did it finish, did it finish clean. Getting the aggregation wrong here produces a dashboard that is confidently incorrect.
#The health query, and the two joins that ruin it
system.lakeflow.job_run_timeline, names in system.lakeflow.jobs, task detail in system.lakeflow.job_task_run_timeline. Neither of the first two is shaped the way people assume, which is why the naive version of this query is wrong in both directions at once.WITH job_name AS ( -- system.lakeflow.jobs is SCD2: one row per EDIT
SELECT workspace_id, job_id, name, delete_time
FROM system.lakeflow.jobs
QUALIFY ROW_NUMBER() OVER (
PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
),
runs AS ( -- job_run_timeline emits SEVERAL rows per run
SELECT
workspace_id,
job_id,
run_id,
MIN(period_start_time) AS started_at,
MAX(period_end_time) AS ended_at,
MAX_BY(result_state, period_end_time) AS result_state,
timestampdiff(SECOND, MIN(period_start_time), MAX(period_end_time)) AS secs
FROM system.lakeflow.job_run_timeline
WHERE period_start_time >= current_timestamp() - INTERVAL 14 DAYS
GROUP BY workspace_id, job_id, run_id
)
SELECT
n.name,
COUNT(r.run_id) AS runs,
SUM(IF(r.result_state <> 'SUCCEEDED', 1, 0)) AS not_clean,
ROUND(100 * AVG(IF(r.result_state <> 'SUCCEEDED', 1, 0)), 1) AS pct_not_clean,
ROUND(AVG(r.secs)) AS avg_seconds,
MAX(r.ended_at) AS last_finished
FROM job_name n -- jobs on the LEFT, deliberately: a job that
LEFT JOIN runs r -- stopped running must show as runs = 0,
ON r.workspace_id = n.workspace_id -- not vanish from the result
AND r.job_id = n.job_id
AND r.result_state IS NOT NULL -- terminal runs only; in-flight rows carry NULL.
-- Kept in the JOIN: as a WHERE clause it turns
-- the LEFT JOIN back into an inner one.
WHERE n.delete_time IS NULL -- deleted jobs keep their run history; skip them
GROUP BY n.name
ORDER BY pct_not_clean DESC NULLS LAST, avg_seconds DESC;event_log() table-valued function, and only by someone who can already see the pipeline. Publishing it to Unity Catalog at pipeline creation turns it into an ordinary table you can grant on and join to — the same query, readable by the same rota that reads the job health above.SELECT
timestamp,
details:update_progress.state AS state
FROM event_log(TABLE(dwh_prod.silver.sales_order))
WHERE event_type = 'update_progress'
ORDER BY timestamp DESC
LIMIT 20;
-- Once the event log is published to UC, the FROM clause is the only line that
-- changes: swap the TVF for the catalog.schema.name configured on the pipeline.pct_not_clean as a number that can be quoted in a steering meeting. The pipeline is flaky is an opinion; the silver job failed on 6 of 14 nights and was retried into green on 4 of them ends the argument in one sentence.runs and last_finished earn their place separately, and only because the join starts from the job list rather than from the run history. A job that has not run at all in the window still appears, with runs = 0 and an empty last_finished — which is the only shape on this page that catches a job that stopped running altogether: a paused schedule, a deleted trigger, an expired service principal secret. Nothing failed, so nothing alerted, and an inner join would have deleted the evidence along with the rows.Doing it
- Publish the pipeline event log to Unity Catalog at pipeline creation — retrofitting it does not backfill the history you wanted.
- Schedule the health query weekly and read it in the same meeting as the cost query, not in a dashboard nobody opens.
- Track pct_not_clean per job over time; the absolute value matters less than whether it is rising.
- Keep the terminal-state filter explicit and commented, or someone will delete it while simplifying and the page will start hiding in-flight incidents.
Defaults
- Aggregate to run_id before you aggregate to anything else.
- Take the newest row per job_id from system.lakeflow.jobs, always.
- Filter to terminal runs for rates; never for detecting absence.
- Pipeline event logs published to UC at pipeline creation, and never deleted.
Gotchas
- job_run_timeline emits several rows for one run. COUNT(*) then reports run counts that scale with how long each run took, so the slowest job also looks like the busiest one, and AVG over rows under-reports duration. Aggregate to run_id first or every number on the page is wrong in a way that looks plausible.
- system.lakeflow.jobs is a slowly changing dimension — one row per edit of the job. A plain join multiplies every run by the number of times the job was ever edited, so the team that tunes its jobs most appears to run them most often.
- result_state = 'SUCCESS_WITH_FAILURES'. Counting only 'FAILED' as bad reports green for runs where a task failed and the chain carried on regardless. Compare against 'SUCCEEDED', never against 'FAILED'.
- result_state is NULL while a run is in flight. Writing WHERE result_state = 'SUCCEEDED' as the top-level filter drops running jobs entirely, which means the dashboard looks calmest exactly during the incident.
- Driving the query from job_run_timeline and inner-joining the names. The result can then only contain jobs that ran, so the job whose schedule was paused three weeks ago is not a zero row — it is no row, and the page reports green all the way to the incident. Start from the job list, LEFT JOIN the runs, and keep the terminal-state filter inside the ON clause where it cannot silently re-inner the join.
- Treating system tables as a live console. They are for trends and post-hoc analysis, not for the first thirty seconds of an incident — the ingestion latency is real and you will misread a gap as an outage.
#Run-duration trend, the signal that arrives first
WITH runs AS (
SELECT
job_id,
run_id,
MAX(period_end_time) AS ended_at,
timestampdiff(SECOND, MIN(period_start_time), MAX(period_end_time)) AS secs
FROM system.lakeflow.job_run_timeline
WHERE period_start_time >= current_timestamp() - INTERVAL 28 DAYS
GROUP BY job_id, run_id
-- healthy runs only: a fast failure would drag the trend DOWN
HAVING MAX_BY(result_state, period_end_time) = 'SUCCEEDED'
),
drift AS (
SELECT
job_id,
median(CASE WHEN ended_at >= current_timestamp() - INTERVAL 7 DAYS
THEN secs END) AS p50_7d,
median(secs) AS p50_28d
FROM runs
GROUP BY job_id
)
SELECT
job_id,
ROUND(p50_7d) AS p50_7d,
ROUND(p50_28d) AS p50_28d,
ROUND(p50_7d / NULLIF(p50_28d, 0), 2) AS ratio
FROM drift
WHERE p50_7d > 1.25 * p50_28d
ORDER BY ratio DESC; health:
rules:
- metric: RUN_DURATION_SECONDS
op: GREATER_THAN
value: 5400 # 90 min. Chain starts 03:00, gold is due 06:00.Doing it
- Run the drift query weekly and open a ticket for anything above 1.25 — the ticket, not a page, is the response.
- Set RUN_DURATION_SECONDS from the observed p95 of the last 28 days plus headroom, with the SLA arithmetic in a comment beside it as above.
- When a job crosses 1.25, check the per-task split first: a chain that grew evenly is a volume story, one task that doubled is a code or layout story.
Defaults
- Median, not mean, and successful runs only.
- Compare a 7-day window to a 28-day window, never a day to a day.
- Trend to a ticket; threshold to a page. Two instruments, two responses.
- Thresholds are derived from measurement and reviewed quarterly.
Gotchas
- Including failed runs in the average. A job that starts failing after four minutes instead of succeeding after forty makes the duration trend improve, so the metric reports good news on the day it breaks.
- Mean instead of median. One Saturday full refresh sits in a 28-day mean for a month, producing a phantom regression and then a phantom recovery when it ages out.
- Comparing yesterday to the day before. Sunday volume in a B2B distributor is a fraction of Tuesday volume, so a day-over-day comparison alarms every Monday and is muted by the second week. Weekend and holiday effects are handled properly in the observability route.
- Comparing durations across a compute change. Moving a job from classic to serverless changes what the clock includes; the step is not a regression but looks exactly like one, and someone spends two days optimising a transform that did not change.
- A threshold set once at 'double what it does now'. By the time the runtime doubles, doubling is normal — so the first time it fires is also the day it starts firing nightly, and it is muted within a week.
- Retries hiding chronic failure. max_retries turns a job that fails twice nightly into a green job whose only symptom is a duration that includes the failed attempts. Green plus a rising trend is the signature.
Alerts that reach a person
An alert has done its job when a named human acknowledges it inside the agreed time. Everything before that — the condition, the destination, the routing table — exists only to make that outcome likely.
#Alert the chain; late and failed are different conditions
resources:
jobs:
dwh_daily:
name: dwh_daily
schedule:
quartz_cron_expression: "0 0 3 * * ?"
timezone_id: Europe/Berlin
# ONE block for the chain. No task below declares notifications.
webhook_notifications:
on_failure:
- id: ${var.oncall_destination}
on_duration_warning_threshold_exceeded:
- id: ${var.oncall_destination}
health:
rules:
- metric: RUN_DURATION_SECONDS
op: GREATER_THAN
value: 5400
notification_settings:
no_alert_for_skipped_runs: true
no_alert_for_canceled_runs: true
tasks:
- task_key: bronze_ingest
pipeline_task:
pipeline_id: ${resources.pipelines.bronze_ingest.id}
- task_key: silver
depends_on: [{ task_key: bronze_ingest }]
pipeline_task:
pipeline_id: ${resources.pipelines.silver.id}
- task_key: gold
depends_on: [{ task_key: silver }]
spark_python_task:
python_file: ../src/dwh/jobs/gold_fct_order_line.py
- task_key: assert_gold
depends_on: [{ task_key: gold }]
spark_python_task:
python_file: ../src/dwh/jobs/run_sql_assertions.pySELECT
MAX(_tech_loaded_at) AS last_load,
timestampdiff(MINUTE, MAX(_tech_loaded_at), current_timestamp()) AS minutes_stale
FROM dwh_prod.gold.fct_order_line;
-- Alert condition: minutes_stale > 185 -> LATE, not FAILED.
--
-- 185 is arithmetic, not taste: this query runs at 06:05 and the chain starts at
-- 03:00, so anything older than those 3h05 was written by LAST night's run.
-- Comparing two timestamps keeps the session time zone out of it. Re-derive the
-- number whenever the schedule moves.Doing it
- Declare notifications at job level in the bundle and delete every task-level block you inherit.
- Set no_alert_for_skipped_runs and no_alert_for_canceled_runs to true on any chain with conditional tasks.
- Schedule the deadline check as its own job, at deadline plus five minutes, so it is not a task of the thing it watches.
- Give late its own one-page runbook with the business contact named in it.
Defaults
- One notification block per chain, at job level.
- Failed and late are two conditions, two runbooks, one rota.
- The late check is data-driven and independently scheduled.
- Alert configuration lives in the bundle and is reviewed like code.
Gotchas
- Per-task notifications on a long chain. One failure, a dozen messages, and within two incidents the whole prefix is filtered into a folder — the alerting keeps working and stops being received, which is worse than having none because everyone believes it is covered.
- Failure-only alerting. The run that never started emits nothing, so the outage with the longest detection time is precisely the one nobody is watching for.
- Leaving no_alert_for_skipped_runs at its default on a chain with conditional tasks. Routine skips page the rota, skips are the most common non-event in a warehouse chain, and the rota learns to ignore the source.
- Configuring the duration health rule but leaving on_duration_warning_threshold_exceeded without a destination. The rule evaluates, nothing is sent, and the team reports that late alerting is covered because the YAML says so.
- Deriving the late condition from the scheduler. 'The job succeeded' is not 'the data is current' — a chain can succeed against an empty source file and be perfectly on time with nothing in it.
- A staleness threshold picked as a round number. The chain starts 03:00 and normally lands gold around 04:15; a check that runs at 06:05 and alerts on 'older than 60 minutes' is true every single night. It fires on day one, is muted on day two, and the night the data really is missing looks identical to the forty before it. The threshold is the gap between the chain's start and the check's schedule, not a number that sounds strict.
- Forgetting the one night the arithmetic breaks. The schedule is pinned to Europe/Berlin; when the clocks go back in October the real gap between a 03:00 start and a 06:05 check is 4h05, not 3h05, and a correctly configured check false-fires once a year. Suppress that date, or acknowledge it in the runbook — do not widen the threshold for the other 364 nights.
#Routing: a rota, not a mailbox
| Condition | Goes to | Expected response | Runbook |
|---|---|---|---|
| Chain FAILED | on-call rota, paging destination | 30 minutes | per-pipeline re-run runbook |
| Chain LATE at 06:05 | on-call rota, paging destination | 30 minutes, plus tell the business contact | late runbook |
| Duration ratio > 1.25 | team channel | next sprint | none — it becomes a ticket |
| Data-quality anomaly | data steward for the domain | same working day | observability route |
| Cost anomaly > 25% week on week | platform owner | weekly review | storage & cost route |
variables:
oncall_destination:
description: Notification destination id for the DWH on-call rota.
dev_null_destination:
description: Dead-letter destination. Dev and test page nobody, on purpose.
default: 11112222-3333-4444-5555-666677778888
targets:
dev:
variables:
oncall_destination: ${var.dev_null_destination}
test:
variables:
oncall_destination: ${var.dev_null_destination}
prod:
variables:
oncall_destination: 0a1b2c3d-4e5f-6789-abcd-ef0123456789
# Both variables are DECLARED above. A ${var.x} that was never declared is not a
# runtime surprise — bundle validate rejects it, which is why validate is the
# first step in CI.dwh-team@client.example. Everyone receives it, so nobody is responsible for it. The first three are read, the tenth becomes an Outlook rule, and the alerting system is now a machine that converts incidents into unread mail. Diffusion of responsibility has a name and it is a distribution list.Doing it
- Create the destinations at workspace level and reference them by id through a bundle variable per target; dev and test point at a dead-letter channel so a broken branch cannot page the rota.
- Transfer ownership of every scheduled query and alert to a service principal, not a person.
- Grep the bundle for external mail domains before handover. One line to check, and never empty the first time.
- Test the alert path monthly: fail a canary job in test on purpose and confirm a human acknowledged it inside the agreed response time.
Defaults
- Destinations by id, resolved per bundle target. Never an address in a job definition.
- Alerts and scheduled queries owned by a service principal.
- Two names on the rota every week, published where the team looks.
- A monthly canary that proves a human is at the other end.
Gotchas
- A shared mailbox as the destination. Everyone is notified, nobody is accountable, and the mail rule that hides it is written after the second incident rather than the twentieth.
- The consultant's address surviving handover. The bounce goes to a mail server, not to the client, so the failure of the alerting system is itself unalerted — and it is discovered by the incident it was supposed to catch.
- One destination id across dev, test and prod. Developers page the rota with broken branches, the rota mutes the channel within a month, and the production alert that follows is muted along with it.
- Alerts and scheduled queries owned by an individual account. When that person is deactivated the schedule stops evaluating and nothing errors anywhere — the same reason production objects are owned by groups, applied to the alerting layer.
- A routing table agreed in a workshop and never rehearsed. The first real page is then also the first time anyone discovers the paging destination was never configured.
Load and cost
Two weekly queries. One asks which statements are consuming the warehouse; the other asks which jobs became more expensive. Read together they usually answer each other.
#Query history: expensive is not the same as slow
system.query.history records statements run on SQL warehouses and on serverless compute. Which compute types it covers has widened more than once, so it is not a fact to quote from memory — confirm it on the client's account against the reference below (Stand 29.07.2026). The useful weekly question is not what is the slowest query — it is what consumed the most warehouse time in total, which is a different query and usually a different culprit.SELECT
LEFT(regexp_replace(statement_text, '\\s+', ' '), 120) AS shape,
client_application,
COUNT(*) AS runs,
ROUND(SUM(total_duration_ms) / 3.6e6, 2) AS wall_hours,
ROUND(SUM(execution_duration_ms) / 3.6e6, 2) AS exec_hours,
ROUND(percentile(total_duration_ms, 0.95) / 1000, 1) AS p95_seconds,
ROUND(SUM(read_bytes) / POW(1024, 4), 3) AS read_tib,
SUM(IF(from_result_cache, 1, 0)) AS cache_hits,
SUM(IF(spilled_local_bytes > 0, 1, 0)) AS spilling_runs
FROM system.query.history
WHERE start_time >= current_timestamp() - INTERVAL 7 DAYS
AND statement_type = 'SELECT'
AND execution_status = 'FINISHED'
GROUP BY ALL
ORDER BY wall_hours DESC
LIMIT 20;wall_hours against exec_hours before you read anything else. A large gap is time spent waiting for compute, not time spent computing — a warehouse sizing or auto-stop question, and no amount of SQL rewriting will touch it.read_tib against the rows the statement returns is the file-pruning signal: a query over gold.fct_order_line filtered to one customer that reads terabytes is telling you the clustering keys do not match how the table is queried.client_application routinely reveals a BI tool nobody mentioned in the requirements workshop, connected by someone who needed a number in a hurry.Doing it
- Run this weekly, take the top three by wall_hours, and check the wall-versus-exec gap before opening a query plan.
- Cross-check the top consumers against gold.mv_sales: repeated hand-written aggregation is a measure that should exist in the metric view.
- Check spilling_runs before resizing anything — spill is a memory signal, and fixing the query is cheaper than buying a bigger warehouse for it.
- Restrict SELECT on system.query.history deliberately: statement_text can contain literal values, including personal data.
Defaults
- Rank by total consumption, not by p95.
- Wall versus execution time first, query plan second.
- Repeated hand-written aggregation is a metric-view gap, not a tuning task.
- Treat query history as sensitive data and grant it like sensitive data.
Gotchas
- Optimising the slowest statement. It is almost never the expensive one, the work takes a week, the bill does not move, and the exercise is discredited internally before it reaches the query that mattered.
- Reading total_duration_ms as query performance. It includes waiting for compute, so a cold warehouse with aggressive auto-stop presents as slow SQL and someone rewrites a perfectly good transform before checking the split.
- Result-cache hits flattening the picture. Rows with from_result_cache = true take almost no time, so a dashboard refreshed twice looks half as expensive as it is. Keep the cache-hit count in the output.
- Assuming the table has everything. Statements run inside a job on classic compute are not SQL warehouse statements, so 'we have no expensive queries' can mean the expensive work happens where this table does not look. Confirm coverage on the client's account first.
- Grouping by raw statement_text. Every literal makes a new group, so the dashboard query that runs forty thousand times splits into forty thousand rows of one and never reaches the top of the list. The normalised 120-character prefix above is the cheap fix and it is only a heuristic — it splits the moment a literal lands inside the first 120 characters, and you notice because a query you know is heavy is missing from a list it obviously belongs on.
#Cost per domain, watched weekly
WITH priced AS (
SELECT
u.usage_metadata.job_id AS job_id,
u.custom_tags['domain'] AS domain,
u.usage_date,
u.usage_quantity * p.pricing.effective_list.default AS list_cost
FROM system.billing.usage u
JOIN system.billing.list_prices p
ON u.cloud = p.cloud
AND u.sku_name = p.sku_name
AND u.usage_start_time >= p.price_start_time
AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
WHERE u.usage_date >= current_date() - INTERVAL 36 DAYS
AND u.usage_date < current_date() - INTERVAL 1 DAYS -- closed days only
AND u.usage_metadata.job_id IS NOT NULL
),
weekly AS (
SELECT
domain,
job_id,
SUM(IF(usage_date >= current_date() - INTERVAL 8 DAYS, list_cost, 0)) AS last_week,
SUM(IF(usage_date < current_date() - INTERVAL 8 DAYS, list_cost, 0)) / 4 AS prior_avg
FROM priced
GROUP BY ALL
)
SELECT
domain,
job_id,
ROUND(last_week, 2) AS last_week,
ROUND(prior_avg, 2) AS prior_weekly_avg,
ROUND(100 * (last_week / NULLIF(prior_avg, 0) - 1), 1) AS pct_change
FROM weekly
WHERE prior_avg > 0 AND last_week > 1.25 * prior_avg
ORDER BY pct_change DESC;billing_origin_product with no job_id and no domain tag. It is on by default for accounts created on or after 11.11.2024, and the rollout to older accounts is still completing — see the predictive-optimization entry in the currency register. On an older account it can therefore switch itself on mid-quarter, which presents as unexplained spend appearing in the untagged bucket. The response is not to hunt for the runaway job: it is to find the scheduled OPTIMIZE that is now doing the same work twice, and delete it.Doing it
- Schedule the query weekly into the same review as duration drift and query history. One meeting, three numbers.
- Compare closed days only, as the WHERE clause above does.
- When pct_change fires, check the duration trend for the same job before opening the cost data — the answer is usually already there.
- Keep a one-line note per accepted increase. A rise everyone agreed to is not an incident, but only if someone wrote it down.
Defaults
- Weekly cadence, closed days only, list cost clearly labelled as list cost.
- Cost and duration reviewed side by side, in one session.
- A threshold of 25% week on week, tuned once after four weeks of observation.
- Every accepted increase carries a one-line reason.
Gotchas
- Including today. Billing data arrives with a lag of hours, so any window ending today is systematically low, and the first thing someone reports is a saving that does not exist.
- Reading a job-level view as the whole bill. usage_metadata.job_id is NULL for SQL warehouse usage, so a dashboard costing more than the entire nightly chain is simply absent from this result. Run the warehouse split separately.
- Attributing predictive optimization spend to a runaway job. It carries no job_id and no domain tag, so it lands in the untagged bucket and reads as an attribution failure rather than a feature that switched itself on.
- Reporting cost per job by job owner. The moment a cost number appears next to a person's name in a management deck, the tags start being wrong, and the attribution you spent a month building is gone.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Jobs system tables reference — the exact schema of system.lakeflow.jobs, job_run_timeline and job_task_run_timeline — read the notes on multiple rows per run before writing any query against them
- Query history system table reference — which compute types are covered and what each of the duration columns actually measures
- Add notifications to a job — the health rules, the four notification events and the notification_settings keys that stop skipped runs paging you
- Manage notification destinations — how to create the destination once so no job definition ever contains an address
- Databricks SQL alerts — conditions, schedules and ownership — including what happens to an alert whose owner is deactivated