Crosshire
← Handbook
RunJobs · trends · cost · alert routing

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.

Stand 29.07.2026

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

What
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.
sql
14-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 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;
For Lakeflow pipelines the equivalent is the pipeline event log, which is also where expectation metrics live. Left unpublished it is reachable only through the 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.
sql
Last twenty pipeline updates and how each one ended
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.
Why here
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.
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.
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 it. A 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 it. Without the question, the number is decoration.
  • Have someone break the query deliberately — drop the GROUP BY on run_id and watch the run count triple. The 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 them. Finding a silently paused schedule is a skill, and it is learned exactly once.
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

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.
sql
Duration drift — median of the last 7 days against the last 28
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;
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.
yaml
resources/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.
Why here
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.
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.
Embedding it
  • Put the trend on the team's own wall, owned by a named person who presents it. A chart with no presenter is a chart with no reader.
  • Ask the team to predict the ratio before the query runs. The 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 move. Ownership of a metric starts with having moved it yourself.
  • Make the quarterly threshold review a recurring calendar item belonging to the team, not a line in your handover document.
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

What
The nightly job is one promise: gold is correct and current by 06:00. Tasks are how the promise is kept, not what is promised. So notifications are declared once, at job level, and no task carries its own.
yaml
resources/jobs/dwh_daily.yml — one notification block for the whole chain
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.py
Failed means something ran and errored: the runbook decides re-run or intervene. Late means the deadline passed and the data is not there — which includes the case where the job never started, and therefore never failed, and therefore never alerted.
So the late condition is derived from the data and scheduled independently of the job it watches. The freshness SQL itself belongs to the observability route; what monitoring adds is that it must run on its own clock and route to the same rota under a different runbook.
sql
The deadline check — scheduled 06:05, independent of dwh_daily
SELECT
  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.
Why here
Per-task notifications look thorough and are the fastest route to a muted channel. A twelve-task chain fails once and sends twelve messages, four of them for tasks merely cancelled when their upstream died. After the second incident someone writes a mail rule, and from then on the alerts arrive perfectly and are read by nobody.
Separating late from failed matters because the responses differ. Failed is a technical problem with a runbook. Late is often a business communication — the data will be there at 09:00, tell the controller before they open the report. Sending both down the same pipe in the same words guarantees one of them is handled wrongly.
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.
Embedding it
  • Ask the team what the 06:00 promise is, in one sentence, before touching any YAML. Three answers from three people means the alerting was never the problem.
  • Have them pause a schedule in test and wait for an alert that never comes. Ten silent minutes teaches the never-started failure mode better than any paragraph.
  • Let the team write the late runbook, including who they call in the business. Naming that person is where operations stops being an engineering-only activity.
  • Review muted channels together every quarter and ask what was muted and why. Every mute is a design defect somewhere upstream of 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

What
Notification destinations are configured once by a workspace admin — email, Slack, Teams, PagerDuty or a webhook — and referenced from job configuration by id. Jobs then never contain an address, which means changing who is paged is one edit in one place instead of a sweep through every job definition in the repository.
The routing table — agreed once, written down, reviewed quarterly
ConditionGoes toExpected responseRunbook
Chain FAILEDon-call rota, paging destination30 minutesper-pipeline re-run runbook
Chain LATE at 06:05on-call rota, paging destination30 minutes, plus tell the business contactlate runbook
Duration ratio > 1.25team channelnext sprintnone — it becomes a ticket
Data-quality anomalydata steward for the domainsame working dayobservability route
Cost anomaly > 25% week on weekplatform ownerweekly reviewstorage & cost route
The rota is two names per week: the person on call, and the person who covers when the first one is at the dentist. It is published where the team looks, not in the consultant's handover deck.
yaml
databricks.yml — destinations are per-target variables, never literals
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.
Why here
The common failure mode is precise: alerts go to 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.
The second is the consultant's own address in the configuration. It works beautifully for four months. Then the engagement ends, the address bounces, and the alert nobody is receiving is by definition the alert nobody knows they are not receiving. The alerting system fails silently, which is the one thing an alerting system may never do.
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.
Embedding it
  • Put a team member's name in the rota from week one and sit beside them for the first page rather than taking it yourself.
  • Hand the monthly alert test to the team and let them run it without you present. The first time the canary fires and nobody notices is the finding you want them to own.
  • Ask in every weekly: who was paged last, and what did they do? An unanswerable question means alerts are landing where nobody reads them.
  • Agree at kick-off the date on which you come off the rota, and put it in the plan. An unstated end date becomes a permanent dependency.
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

What
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.
sql
Where the warehouse hours went last week
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;
Read 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.
Why here
Teams reliably optimise the wrong statement. The 20-minute quarterly report everyone complains about costs a fraction of the 3-second query a dashboard fires forty thousand times a week, and only one of the two appears at the top of this list. Ranking by total consumption moves the work to where the money is.
It also tells you who your consumers actually are. 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.
Embedding it
  • Let the team run the query in the review and pick the target themselves. The choice between slowest and most expensive is the lesson, and it only lands if they make it.
  • Ask the owner of the top consumer to explain what the query is for. Roughly a third of the time the honest answer retires the query.
  • Have them measure before and after any optimisation from this table rather than a stopwatch, so improvement is a number and not a feeling.
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

What
The tag taxonomy and the attribution query belong to the storage & cost route. Monitoring asks a narrower question on a schedule: which job got more expensive this week, and by how much.
sql
Week-on-week cost movement per job
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;
Predictive optimization runs OPTIMIZE, VACUUM and ANALYZE for you on Unity Catalog managed tables, and it bills under its own 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.
Why here
A monthly cost figure tells you spend rose. A weekly per-job delta tells you which change caused it, while the person who made that change still remembers making it. Two weeks later the same finding is archaeology, and the cluster configuration behind it has been copied into three more jobs.
It also keeps cost inside engineering. Once cost arrives only as a finance escalation, the conversation is about budget rather than about the join that stopped pruning — and engineers cannot fix a budget.
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.
Embedding it
  • Hand over the query, not a dashboard. A team that can edit the SQL owns the number; a team with a picture owns a picture.
  • Let the team explain the first fired row to the platform owner themselves, with you in the room and silent.
  • Ask what they would delete if the budget were cut by a fifth. The answer is usually known, unwritten, and worth writing 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 referencethe 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 referencewhich compute types are covered and what each of the duration columns actually measures
  • Add notifications to a jobthe health rules, the four notification events and the notification_settings keys that stop skipped runs paging you
  • Manage notification destinationshow to create the destination once so no job definition ever contains an address
  • Databricks SQL alertsconditions, schedules and ownership — including what happens to an alert whose owner is deactivated
Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.