Four system tables — and, for expectations only, one event log — answer the narrow question monitoring is for: did it run, did it finish, did it finish clean. Getting the aggregation or the grouping key wrong here produces a dashboard that is confidently incorrect.
Job outcomes live in 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.
Why
The value is not the red cell. It is 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.
How
sql14-day health per job — the version that survives review
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 wall_secs,
MAX_BY(execution_duration_seconds, period_end_time) AS exec_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.workspace_id, -- job_id is unique PER WORKSPACE, and the
n.job_id, -- lakeflow schema is account-wide
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.wall_secs)) AS avg_wall_seconds,
ROUND(AVG(r.exec_secs)) AS avg_exec_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.workspace_id, n.job_id, n.name -- NEVER by name alone: job names are not
-- unique, in one workspace or across them
ORDER BY pct_not_clean DESC NULLS LAST, avg_wall_seconds DESC;
Two columns carry the whole comparison. wall_secs is hand-rolled from the period boundaries and includes queue and setup; execution_duration_seconds is a column the table gives you and covers only the work. Report both — the route's duration topic below explains why the gap between them is itself a metric.
result_state is a closed set, and the trap is that only one of its values means clean. Comparing against 'FAILED' counts four other non-clean outcomes as green.
result_state in job_run_timeline — everything that is not SUCCEEDED
Value
What happened
Clean?
SUCCEEDED
ran and finished without error
yes — the only one
FAILED
ran and errored
no
TIMED_OUT
killed at the timeout, so the work is unfinished
no
CANCELLED
stopped by a human or by a concurrency rule
no
SKIPPED
never ran — a condition or a max-concurrency rule
no, and it is silent
BLOCKED
held on an upstream dependency and never released
no, and it is silent
ERROR
the run could not be evaluated at all
no
NULL
in flight right now
unknown — do not count it either way
Pipelines answer the same question from a different table, and the event log is the wrong place to start — that is the next topic.
Doing it
Schedule the health query weekly and read it in the same meeting as the cost queryNot in a dashboard nobody opens.
Track pct_not_clean per job over timeThe absolute value matters less than whether it is rising.
Keep the terminal-state filter explicit and commentedOtherwise someone will delete it while simplifying, and the page will start hiding in-flight incidents.
Carry workspace_id through every grouping in every query on this pageAnd check that the lakeflow system schema is enabled on the metastore before promising a 14-day window.
Embedding it
Give the query to the team as a file in the repo and let the first person who needs a new column add itA dashboard they cannot edit is a dashboard they do not own.
Ask in the weekly which job has the worst pct_not_clean and what is being done about itWithout the question, the number is decoration.
Have someone break the query deliberately — drop the GROUP BY on run_id and watch the run count tripleThe lesson lands once and permanently.
When a job's run count drops to zero while it is still deployed, let them work out why instead of telling themFinding a silently paused schedule is a skill, and it is learned exactly once.
Defaults
Aggregate to run_id before you aggregate to anything else
Group by (workspace_id, job_id); the name is a label on the row, never the key of it
Filter to terminal runs for rates; never for detecting absence
Gotchas
job_run_timeline emits several rows for one runCOUNT(*) then scales with how long each run took, so the slowest job also looks like the busiest, and AVG over rows under-reports duration. Aggregate to run_id first.
system.lakeflow.jobs is a slowly changing dimension — one row per edit of the jobA plain join multiplies every run by the number of edits, so the team that tunes its jobs most appears to run them most often. Take the newest row per (workspace_id, job_id).
Counting only result_state = 'FAILED' as badSKIPPED, BLOCKED, ERROR and TIMED_OUT are all not-FAILED and all not clean, and the two quiet ones hurt most: a SKIPPED run never executed, a BLOCKED run never released. The night produced nothing and the dashboard says zero failures. Compare against 'SUCCEEDED'. SUCCESS_WITH_FAILURES is a Jobs API value this table never emits, so a filter naming it matches nothing while looking careful.
result_state is NULL while a run is in flightWHERE result_state = 'SUCCEEDED' as the top-level filter drops running jobs, so the dashboard looks calmest exactly during the incident.
GROUP BY the job nameNames are not unique, within a workspace or across dev, test and prod. Two job_ids collapse into one row and their counts add up, so a job that ran seven times looks like fourteen and its failures are diluted by someone else's successes.
Driving the query from job_run_timeline and inner-joining the namesThe result can 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 up to the incident. Start from the job list, LEFT JOIN the runs, and keep the terminal-state filter in the ON clause where it cannot silently re-inner the join.
Treating system tables as a live consoleThey are for trends and post-hoc analysis, not the first thirty seconds of an incident — the ingestion latency is real and you will misread a gap as an outage.
#Pipeline health, and the event log you cannot grant
What
For Lakeflow pipelines, start at system.lakeflow.pipeline_update_timeline rather than at the event log. It is an ordinary system table — one row per update period, result_state in COMPLETED / FAILED / CANCELED, plus update_id and full_refresh_selection, with 365-day retention — so any rota member with SELECT on the system schema can read it, and there is no per-pipeline publishing decision to have got right months earlier. It is Public Preview at Stand 29.07.2026: fine for a weekly report the team runs, not something to write into a signed operations concept without re-checking its status first.
Why
A pipeline's health question is the same as a job's, but the obvious source is the wrong one. The event log is where everybody looks first, because it is where the pipeline UI looks — and it is scoped to one person. The system table is scoped to whoever has SELECT on the system schema, which is the rota.
That difference decides whether the instrument survives your leaving. A query only the pipeline owner can run is not monitoring the team has; it is monitoring one person has, and it stops working the week they change role.
How
sql14 days of pipeline updates — one row per update, from the system table
SELECT
workspace_id,
pipeline_id,
update_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,
MAX_BY(full_refresh_selection, period_end_time) AS full_refresh_selection
FROM system.lakeflow.pipeline_update_timeline
WHERE period_start_time >= current_timestamp() - INTERVAL 14 DAYS
GROUP BY workspace_id, pipeline_id, update_id -- periods, exactly like job runs
ORDER BY started_at DESC;
The event log stays in the toolkit for one thing: expectation metrics, which the system table does not carry. Its access rule is narrower than "whoever can see the pipeline" — the docs scope the event log to the pipeline's run-as user, and the event_log() table-valued function can be called only by the pipeline owner. A rota member with CAN_VIEW gets nothing at all. Publishing the event log to Unity Catalog at pipeline creation is what turns it into a table you can grant on.
sqlEvent log: last twenty COMPLETED updates, deduped on update_id
-- One update emits an update_progress event PER STATE TRANSITION:
-- QUEUED -> INITIALIZING -> SETTING_UP_TABLES -> RUNNING -> COMPLETED|FAILED|CANCELED
-- so a bare "ORDER BY timestamp DESC LIMIT 20" returns two or three updates, most
-- of the rows non-terminal. Filter to terminal states AND dedupe on update_id.
SELECT
origin.update_id AS update_id,
MAX(timestamp) AS ended_at,
MAX_BY(details:update_progress.state, timestamp) AS state
FROM event_log(TABLE(dwh_prod.silver.sales_order))
WHERE event_type = 'update_progress'
AND details:update_progress.state IN ('COMPLETED', 'FAILED', 'CANCELED')
GROUP BY origin.update_id
ORDER BY ended_at 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 —
-- and then the rota can read it, which through the TVF they cannot.
Doing it
Read pipeline health from system.lakeflow.pipeline_update_timeline and grant the rota SELECT on itNo publishing decision, no owner-only TVF, one row per update.
Publish the pipeline event log to Unity Catalog at pipeline creation anywayIt carries the expectation metrics the system table does not, and retrofitting it does not backfill the history you wanted.
Dedupe on origin.update_id whenever you do read the event logAnd filter to terminal states, or the twenty rows you get back are three updates.
Embedding it
Hand the rota the pipeline system table rather than your own event-log accessAn instrument only the departing consultant can run is not an instrument the team has.
Have the team check who can actually run each monitoring query, as themselves, before handoverOwnership scoping is invisible until the wrong person needs it at 03:40.
Defaults
Pipeline health from pipeline_update_timeline; the event log for expectation metrics only
Pipeline event logs published to UC at pipeline creation, and never deleted
Aggregate to update_id before you aggregate to anything else
Gotchas
Counting event_log rows as pipeline updatesOne update emits an event per state transition, so LIMIT 20 on the raw log is two or three updates dressed as twenty, mostly QUEUED and RUNNING rows that never ended — a wall of non-terminal states and no failures, because the failures are further down.
Assuming the rota can read an unpublished event logevent_log() can be called only by the owner, so the query works for whoever built the pipeline and returns nothing for everyone else. Discovered at 03:40, by the on-call engineer who is not the owner.
Publishing the event log only once you need itPublishing is a creation-time setting and does not backfill. The history you wanted to look at is the history that was not being written down.
Writing pipeline_update_timeline into a signed operations conceptIt is Public Preview at Stand 29.07.2026. Fine for a weekly report the team runs; not a commitment to put in a document a client signs.
#Run-duration trend, the signal that arrives first
What
Nothing has failed. The nightly chain took 41 minutes in May, 52 in June, 68 last week. The 06:00 deadline is still met — by twelve minutes instead of ninety. Every capacity problem a warehouse has arrives as this slope first: growing volume, a join that lost its file pruning, small files accumulating, a cluster someone resized downward.
Why
Failure alerts are a step function: fine, fine, fine, 03:40 phone call. Duration is continuous, so it converts a future incident into a ticket you schedule. Six weeks of warning is the difference between adding a clustering key on a Tuesday afternoon and doing it under pressure with the business waiting for yesterday's revenue.
It is also the one operational metric a non-engineer follows without translation. The nightly load has slowed by 60% since April and will miss the deadline around September is a sentence that gets a change request approved. We have technical debt is not.
How
sqlDuration drift — wall clock AND execution time, 7 days against 28
WITH runs AS (
SELECT
workspace_id, -- job_id is unique PER WORKSPACE; system.lakeflow
job_id, -- is account-wide, so dev and prod would MERGE
run_id,
MAX(period_end_time) AS ended_at,
-- what the deadline cares about: queue + setup + work + cleanup
timestampdiff(SECOND, MIN(period_start_time), MAX(period_end_time)) AS wall_secs,
-- what the code cares about: the work alone, from the table's own column
MAX_BY(execution_duration_seconds, period_end_time) AS exec_secs,
-- the difference, itemised: this is the contention signal
COALESCE(MAX_BY(queue_duration_seconds, period_end_time), 0)
+ COALESCE(MAX_BY(setup_duration_seconds, period_end_time), 0) AS wait_secs
FROM system.lakeflow.job_run_timeline
WHERE period_start_time >= current_timestamp() - INTERVAL 28 DAYS
GROUP BY workspace_id, 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
workspace_id,
job_id,
median(CASE WHEN ended_at >= current_timestamp() - INTERVAL 7 DAYS
THEN wall_secs END) AS wall_7d,
median(wall_secs) AS wall_28d,
median(CASE WHEN ended_at >= current_timestamp() - INTERVAL 7 DAYS
THEN exec_secs END) AS exec_7d,
median(exec_secs) AS exec_28d,
median(CASE WHEN ended_at >= current_timestamp() - INTERVAL 7 DAYS
THEN wait_secs END) AS wait_7d,
median(wait_secs) AS wait_28d
FROM runs
GROUP BY workspace_id, job_id
)
SELECT
workspace_id,
job_id,
ROUND(wall_7d) AS wall_7d,
ROUND(wall_28d) AS wall_28d,
ROUND(wall_7d / NULLIF(wall_28d, 0), 2) AS wall_ratio, -- the deadline question
ROUND(exec_7d / NULLIF(exec_28d, 0), 2) AS exec_ratio, -- is the WORK heavier?
ROUND(wait_7d - wait_28d) AS wait_delta -- queue + setup, seconds
FROM drift
WHERE wall_7d > 1.25 * wall_28d
ORDER BY wall_ratio DESC;
Two ratios, on purpose. wall_ratio answers the only question the business asks — is the chain occupying more of the night than it did. exec_ratio answers the only question the code can fix — is the work itself heavier. When wall rises and execution does not, the extra minutes are queue and setup, and wait_delta says how many: that is contention, a cold start, or a shrunken pool, and no query plan will show it. Folding all of it into one hand-rolled timestampdiff is what sends an engineer to read an execution plan for a problem that lives in the scheduler.
The trend is a weekly report, not a page. The hard cliff is a separate instrument: a job-level health rule that fires while the run is still going, sized from the trend query rather than guessed. Size it from wall_secs — RUN_DURATION_SECONDS is wall clock too, and a threshold derived from execution time will fire late on exactly the busy nights it exists for.
yamlresources/jobs/dwh_daily.yml — the cliff, set from the observed p95
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.25The ticket, not a page, is the response.
Read wall_ratio and exec_ratio together before opening anythingBoth risen is work, wall alone is queue, and wait_delta tells you how many seconds of it.
Set RUN_DURATION_SECONDS from the observed p95 of wall clock over the last 28 days plus headroomKeep the SLA arithmetic in a comment beside it, as above.
When a job crosses 1.25, check the per-task split firstA chain that grew evenly is a volume story, one task that doubled is a code or layout story.
Embedding it
Put the trend on the team's own wall, owned by a named person who presents itA chart with no presenter is a chart with no reader.
Ask the team to predict the ratio before the query runsThe gap between prediction and result measures how well they know their own platform, and it closes fast once the guess is public.
Have them cause a regression on purpose in test — drop a clustering key, re-run, watch the number moveOwnership of a metric starts with having moved it yourself.
Make the quarterly threshold review a recurring calendar item belonging to the teamNot a line in your handover document.
Defaults
Median, not mean, and successful runs only
Wall clock for the deadline, execution_duration_seconds for the work, and the gap between them for the queue
Compare a 7-day window to a 28-day window, never a day to a day
Every grouping key is (workspace_id, job_id), because system.lakeflow is account-wide
Trend to a ticket; threshold to a pageTwo instruments, two responses.
Thresholds are derived from measurement and reviewed quarterly
Gotchas
Trending job_id without workspace_idjob_id is unique per workspace, system.lakeflow.* is account-wide, and this handbook puts dev, test and prod in separate workspaces — so the same job's dev runs and prod runs land in one median. Dev's small volumes flatten a real prod regression, and a heavy dev experiment invents one that nobody can reproduce in production.
Hand-rolling wall clock and calling it durationjob_run_timeline publishes queue_duration_seconds, setup_duration_seconds, execution_duration_seconds and cleanup_duration_seconds; timestampdiff over the period boundaries silently sums all four. The job then reads as slower with no change to the code, the investigation goes into a transform that did not move, and the actual answer — the cluster is waiting longer to start — is in a column the query never selected.
Including failed runs in the averageA 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 medianOne 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 beforeSunday 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 changeMoving 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 failuremax_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.