11Observability
This route decides which properties of the data are watched continuously, what the thresholds are, and who is told when one breaks. It also decides how much is bought from Data Quality Monitoring and how much is four SQL files in the repository. The default here: buy the profiling, write the four checks, and never let an alert exist without a named recipient.
Green jobs, wrong numbers
The route rests on one distinction. A team that cannot state it keeps buying dashboards of job status and keeps being told about bad numbers by the business.
#The failure class nothing catches
bronze.fx_rate load finds no file, writes nothing, exits zero. silver.fx_rate has nothing to insert, exits zero. The gold build finds no rate for date_sk and leaves fx_rate — and therefore amount_eur — NULL on every CHF line.| Signal | Question | Cheap check | Catches |
|---|---|---|---|
| Freshness | is it current? | MAX(_tech_loaded_at) against a 26 h SLA | an SAP extract that stopped being scheduled |
| Volume | is all of it there? | daily count against a 7-day median | a CDC gap on sap_vbap; the webshop feed silently dry |
| Schema | is it the same shape? | information_schema diff against dwh_test | a retyped SAP field truncating amount_eur |
| Distribution | are the values sane? | share of NULL, CANCELLED, UNKNOWN per day | the missing fx_rate above; a broken customer join |
Doing it
- Write the four checks as .sql files in the repo, on the same zero-rows-means-pass convention as the SQL test suite.
- Schedule them as Databricks SQL alerts on gold.fct_order_line and the silver tables that feed it — not as a notebook someone runs.
- Put a named owner and a runbook link in every alert body. An alert with neither is a notification.
- Record each incident's detection path: did we find it, or did they. That ratio is this route's only honest score.
Defaults
- Four signals on every gold table a report reads, before a fifth signal anywhere.
- Checks live in the repo and deploy with the bundle. Nothing important is configured only in the UI.
- Zero rows means pass, so one file is a CI test and a production monitor.
- Every alert names a person and a runbook. Never a shared mailbox.
Gotchas
- Treating job-status dashboards as observability. Complete, green, and silent about the fx_rate case — the pipeline did exactly what it was told, on data that was not there.
- Alerting on everything on day one. The channel is muted within a fortnight, and a muted channel is worse than none because everyone believes it works.
- Checks that live only in the monitoring UI: invisible to code review, absent from the diff when someone changes the fact grain, gone when the workspace is rebuilt.
- Monitoring gold only. The fx_rate outage was visible one step earlier in silver.fx_rate, and nobody watched it because nothing reads it directly.
The four checks
Runnable against the canonical model, as they would be committed. Each returns zero rows when the warehouse is healthy, so each is simultaneously a monitor and a test.
#Freshness, on two clocks
-- Two clocks, deliberately. A full refresh that rewrites yesterday's rows
-- keeps _tech_loaded_at current while the newest order is 40 hours old.
SELECT
'gold.fct_order_line' AS object_name,
MAX(_tech_loaded_at) AS last_write,
MAX(order_ts) AS newest_event,
timestampdiff(HOUR, MAX(_tech_loaded_at), current_timestamp()) AS write_age_h,
timestampdiff(HOUR, MAX(order_ts), current_timestamp()) AS event_age_h
FROM dwh_prod.gold.fct_order_line
HAVING timestampdiff(HOUR, MAX(_tech_loaded_at), current_timestamp()) > 26
OR timestampdiff(HOUR, MAX(order_ts), current_timestamp()) > 26;bronze.sap_kna1 is a daily full extract with no change timestamp, so the pipeline cannot distinguish 'nothing changed' from 'this is Tuesday's file again'. Only MAX(order_ts) on the fact side notices.Doing it
- Commit the check per gold table a report reads, with the SLA as a literal so the diff shows when someone loosens it.
- Derive the SLA from the schedule: start time + p95 run duration + one hour of DST.
- Pin the schedule's timezone explicitly (Europe/Berlin, not UTC) so schedule and SLA move together twice a year.
- Add the same check on silver.fx_rate — no downstream reader, largest blast radius.
Defaults
- Two clocks on every fact: load recency and event recency.
- SLA = schedule + p95 duration + 1 h, written down with that reasoning.
- Schedules and freshness checks share one explicitly stated timezone.
- Freshness on the bronze tables too — the earliest signal is the cheapest to act on.
Gotchas
- Measuring only _tech_loaded_at. A full-refresh pipeline stamps every row on every run, so the table reports itself as minutes old forever — including the morning the source stopped delivering.
- A 24-hour SLA on a daily job. It fires on ordinary jitter, the team learns the alert means nothing, and it is muted before the month it would have mattered.
- The schedule in UTC and the SLA reasoned in local time. Twice a year they drift by an hour: a week of false alarms in March, an hour of blindness in October.
- A freshness check that itself runs once a day. Worst case you learn about a stale table 24 hours late. Run it hourly — it is one aggregate over a clustered table.
- One event-age SLA on a B2B order book. Saturday and Sunday produce almost no orders, so MAX(order_ts) is past sixty hours old at 08:00 every Monday: the check fires weekly, on healthy data, until someone mutes it. Reason the event clock in business days off gold.dim_date, or evaluate it only on business days — and leave the write clock at 26 hours, because the pipeline does run at the weekend.
#Volume against a rolling median
-- The calendar is the spine, not the fact table. A day on which nothing
-- landed produces no group, so a GROUP BY over the fact alone cannot emit a
-- row for the total outage this check exists to catch.
WITH calendar AS (
SELECT dd.date AS d
FROM dwh_prod.gold.dim_date dd
WHERE dd.date >= current_date() - INTERVAL 40 DAYS
AND dd.date < current_date()
),
daily AS (
SELECT c.d, count(f.order_line_sk) AS line_count
FROM calendar c
LEFT JOIN (
SELECT date_sk, order_line_sk
FROM dwh_prod.gold.fct_order_line
WHERE date_sk >= current_date() - INTERVAL 40 DAYS
AND date_sk < current_date()
) f
ON f.date_sk = c.d
GROUP BY c.d
),
baseline AS (
SELECT t.d, t.line_count, median(h.line_count) AS median_7d
FROM daily t
JOIN daily h
ON h.d >= t.d - INTERVAL 7 DAYS
AND h.d < t.d
GROUP BY t.d, t.line_count
)
SELECT d, line_count, median_7d,
round(line_count / nullif(median_7d, 0), 2) AS ratio
FROM baseline
WHERE d = current_date() - INTERVAL 1 DAY
AND (line_count < 0.5 * median_7d OR line_count > 2.0 * median_7d);dayofweek() rule cannot express that. A per-country holiday column on gold.dim_date can.bronze.sap_vbap that resumes from the wrong offset delivers a real, well-typed day missing four hours of items. Freshness green, schema unchanged, every expectation passing — because every row that arrived was valid.Doing it
- Set the band from history: compute the observed ratio over 90 days, then pick bounds that would have fired on the incidents you remember and nothing else.
- Run the check on each bronze table as well as the fact — bronze is where a per-source outage is visible first.
- Alert on the ratio, not the raw count. A count threshold needs rewriting every time the business grows.
- Group by date_sk, never by _tech_loaded_at, so a late batch does not read as a spike.
- Bound the fact scan on order_ts as well as date_sk. fct_order_line is clustered by (customer_sk, order_ts), so a date_sk-only window reads files an order_ts range would have skipped — free today, a per-run cost once the fact is years deep.
Defaults
- Median over mean, ratio over absolute count, band derived from observed history.
- Like-for-like baselines: same weekday, business days only, one shared calendar on dim_date.
- Volume on bronze as well as gold.
- Both directions banded. A doubling is a duplicate load and it is the expensive one.
Gotchas
- A mean baseline. One promotional spike raises it for a week, so the real drop that follows sits inside the band and never fires.
- Weekend and holiday false positives left unfixed. The check becomes noise, the noise gets muted, and the muting is invisible in code review because it happened in the alert UI.
- Counting rows written instead of rows for the business day. A late batch lands two days of orders in one run; the load count spikes while every per-day count is fine.
- Only banding the low side. Duplicate loads survive longest, because everyone's instinct is that more data is not a problem.
- Grouping the fact by day and waiting for the missing day to appear. A day with no rows produces no group, so the total outage — the failure everyone assumes this check is for — returns zero rows and reads as healthy. Drive the check off gold.dim_date and LEFT JOIN the fact, so an absent day arrives as a count of 0 and trips the lower band.
#Slices and distribution
-- Same spine problem, one dimension worse: a country that goes to zero stops
-- producing rows at all. Build the country x day grid first, join the fact in.
WITH calendar AS (
SELECT dd.date AS d
FROM dwh_prod.gold.dim_date dd
WHERE dd.date >= current_date() - INTERVAL 40 DAYS
AND dd.date < current_date()
),
countries AS (
SELECT DISTINCT country
FROM dwh_prod.gold.dim_customer
WHERE _tech_is_current
),
grid AS (
SELECT k.country, c.d
FROM countries k CROSS JOIN calendar c
),
fact_lines AS (
SELECT c.country, f.date_sk AS d, f.order_line_sk
FROM dwh_prod.gold.fct_order_line f
JOIN dwh_prod.gold.dim_customer c
ON c.customer_sk = f.customer_sk
AND c._tech_is_current -- customer_sk is stable across SCD2 versions;
-- without this predicate every version fans out
WHERE f.date_sk >= current_date() - INTERVAL 40 DAYS
AND f.date_sk < current_date()
),
daily AS (
SELECT g.country, g.d, count(l.order_line_sk) AS line_count
FROM grid g
LEFT JOIN fact_lines l
ON l.country = g.country
AND l.d = g.d
GROUP BY g.country, g.d
),
baseline AS (
SELECT t.country, t.d, t.line_count, median(h.line_count) AS median_7d
FROM daily t
JOIN daily h
ON h.country = t.country
AND h.d >= t.d - INTERVAL 7 DAYS
AND h.d < t.d
GROUP BY t.country, t.d, t.line_count
)
SELECT country, d, line_count, median_7d
FROM baseline
WHERE d = current_date() - INTERVAL 1 DAY
AND (line_count < 0.5 * median_7d OR line_count > 2.0 * median_7d);gold.fct_order_line carries no source column. Source identity is the bronze table — SAP is bronze.sap_vbak / sap_vbap, the webshop is bronze.shop_order — so per-source volume is monitored there. Carrying source onto the fact is a data-model change, not a monitoring setting.-- Eight days side by side. A step change in any column is the alert;
-- pct_amount_null is the one that catches the missing fx_rate.
SELECT
date_sk,
count(*) AS line_count,
round(100.0 * count_if(amount_eur IS NULL) / count(*), 2) AS pct_amount_null,
round(100.0 * count_if(customer_sk = 'UNKNOWN') / count(*), 2) AS pct_unknown_customer,
round(100.0 * count_if(status = 'CANCELLED') / count(*), 2) AS pct_cancelled,
round(100.0 * count_if(currency = 'CHF') / count(*), 2) AS pct_chf
FROM dwh_prod.gold.fct_order_line
WHERE date_sk >= current_date() - INTERVAL 8 DAYS
AND date_sk < current_date() -- today is a partial day and always reads
-- as a step change; it is not one
GROUP BY date_sk
ORDER BY date_sk;pct_amount_null goes from 0 to the CHF share of the day, on the morning it happens rather than at month end. pct_unknown_customer catches the mirror failure — a customer load that stopped, so new orders resolve to the unknown member and revenue is attributed to nobody.Doing it
- Slice by country via dim_customer, and by status and currency on the fact itself.
- Always add _tech_is_current (or an interval predicate on _tech_valid_from / _tech_valid_to) when a monitor joins a fact to an SCD2 dimension.
- Monitor volume per bronze table so a webshop outage is visible before it reaches gold.
- Alert on a step change in a percentage, not on its level. 3% unknown customers may be normal; 3% after a month at 0% is not.
Defaults
- One slice per dimension the business steers by — here country, and status.
- Percentages compared against their own recent history, never against a fixed target.
- Per-source monitoring on bronze, because the model deliberately does not carry source into gold.
- Cap the slice count. Four countries is a check; four hundred customers is a noise generator.
Gotchas
- Joining fct_order_line to dim_customer on customer_sk alone. The surrogate key is a hash of customer_id and is stable across SCD2 versions, so the join fans out one row per version — and the monitor reports a volume spike that is a defect in the monitor.
- Slicing by a high-cardinality column. Per-customer checks fire somewhere every day, which teaches the team that this channel is always red.
- Assuming an aggregate inside its band is healthy. Any slice smaller than the band width is invisible to the total: NL can go to zero without moving the number you are watching.
- Alerting on an absolute percentage. It is right for one quarter, drifts with the business, and is then rewritten under pressure during an incident.
- Expecting a fact-driven GROUP BY to report a slice that vanished. NL at zero emits no NL rows, so the country disappears from the result instead of appearing at zero, and the alert built for exactly this case never fires. The countries x days grid has to exist before the fact is joined onto it.
#Schema drift, diffed against test
dwh_prod against dwh_test, and zero rows means production is the shape the suite ran against.WITH prod AS (
SELECT table_schema, table_name, column_name, ordinal_position,
full_data_type, is_nullable
FROM dwh_prod.information_schema.columns
WHERE table_schema IN ('bronze', 'silver', 'gold')
),
test AS (
SELECT table_schema, table_name, column_name, ordinal_position,
full_data_type, is_nullable
FROM dwh_test.information_schema.columns
WHERE table_schema IN ('bronze', 'silver', 'gold')
)
SELECT 'only_in_prod' AS side, * FROM (SELECT * FROM prod EXCEPT SELECT * FROM test)
UNION ALL
SELECT 'only_in_test' AS side, * FROM (SELECT * FROM test EXCEPT SELECT * FROM prod);full_data_type, not data_type: the latter reports DECIMAL for both DECIMAL(18,2) and DECIMAL(12,2), so a narrowing of amount_eur passes the diff untouched. ordinal_position is in the list to catch a positional insert about to write currency into status.bronze.sap_vbap and nothing complains. On bronze.shop_order, Autoloader's default addNewColumns evolution stops the stream with an UnknownFieldException and picks the column up on the next start — which under a job retry reads as a transient failure that fixed itself, and is the only notification anyone gets. Either way silver ignores the new column, because the transform names its columns. Six months later someone wants that field and finds it has been arriving unmapped since spring.amount_eur is wrong for exactly the largest orders, the ones a spot check is least likely to include.Doing it
- Run the diff after every deployment and nightly, and treat a non-empty result as a deployment defect until proven otherwise.
- Include ordinal_position and full_data_type; exclude nothing you cannot name a reason for.
- Use INSERT INTO … BY NAME wherever a target is written from a SELECT, so a reordered column fails loudly instead of shifting values.
- Add the comment column to the diff — grain and ownership live in comments, so a dropped comment is a dropped contract.
Defaults
- Diff prod against test, not against a snapshot file somebody has to maintain.
- Symmetric EXCEPT, always. One direction reports additions and is blind to removals.
- full_data_type over data_type; ordinal_position included.
- Writes by name, never by position.
Gotchas
- information_schema shows only objects the caller has privileges on. A monitor running as a service principal without SELECT on a schema returns zero rows — indistinguishable from healthy. Assert the row count of both CTEs, not just the diff.
- Diffing data_type instead of full_data_type. DECIMAL(18,2) narrowed to DECIMAL(12,2) is invisible, and the symptom is wrong totals confined to the largest orders.
- Ignoring ordinal_position because 'nothing depends on column order'. Positional INSERT … SELECT * does, and the failure is currency landing in status: same type, plausible values, no error.
- Autoloader schema evolution treated as a solution rather than an event. It keeps the pipeline running, which is right, and it means new columns arrive with nobody deciding whether they belong in silver.
What the platform does for you
Data Quality Monitoring covers the profiling and the statistical half. It does not replace the four checks — it makes them the exception rather than the workload.
#Data Quality Monitoring
| Profile | For | Here |
|---|---|---|
| Snapshot | current state; metrics over the whole table each run | gold.dim_customer, gold.dim_product |
| Time series | a timestamp column defines windows; metrics per window | gold.fct_order_line on order_ts |
| Inference | model input/output logs, prediction drift | not used by this warehouse |
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.catalog import MonitorTimeSeries
w = WorkspaceClient()
w.quality_monitors.create(
table_name="dwh_prod.gold.fct_order_line",
output_schema_name="dwh_prod.gold",
assets_dir="/Workspace/Shared/dwh/monitors/fct_order_line",
time_series=MonitorTimeSeries(
timestamp_col="order_ts",
granularities=["1 day"],
),
# slicing is evaluated on the monitored table, so only its own columns
slicing_exprs=["status", "currency", "region_sk"],
)amount_eur must equal ROUND(quantity * unit_price * fx_rate, 2). That is an assertion, and it belongs in the SQL test suite.Doing it
- Enable anomaly detection at schema level on gold; add table-level profiling only to the fact and the dimensions a report reads.
- Time series on gold.fct_order_line keyed on order_ts; snapshot on the dimensions.
- Route the platform's alerts into the same channel as the four checks, so on-call has one place to look.
- Check the SDK surface against your installed version before copying the snippet — the API names predate the rename and lag it.
Defaults
- Anomaly detection schema-wide; profiling on the tables you can name a consumer for.
- Metrics tables treated as first-class data — queried, joined, alerted on in SQL.
- Platform detects drift; the repo asserts rules. Never expect the first to do the second.
- One alerting channel for platform and hand-written checks alike.
Gotchas
- Expecting anomaly detection to catch a wrong business rule. It learns what this table normally looks like, so a conversion that has been wrong since go-live is normal and will never be flagged.
- Profiling every table because it is one checkbox. The metrics tables and the compute are real cost, and alert volume from tables nobody reads is what teaches people to ignore the channel.
- Monitoring a table whose grain is about to change. Your own deployment invalidates the learned baseline, the monitor alarms on a correct change, and the reflex is to disable it rather than reset it.
- Assuming lineage completeness during root cause. Records are emitted only where lineage can be inferred, so a missing edge is not evidence that nothing reads the table.
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.
- Data quality monitoring — the post-rename overview — read it to see which half of the old product you are actually enabling
- INFORMATION_SCHEMA.COLUMNS — the full column list, and the privilege rule that makes an empty result ambiguous
- Databricks SQL alerts — how a zero-rows-means-pass query becomes a scheduled alert with a destination
- External lineage — what it takes for root cause to reach past SAP and Power BI instead of stopping at the platform edge
- Use liquid clustering for tables — fct_order_line is clustered by (customer_sk, order_ts) — which is why a monitor windowed on date_sk alone prunes less than it looks like it should