Case Study 1: Four Hypotheses in Ninety Minutes
"The dashboard is blank" is not a problem statement. It is a symptom with at least four causes, and the cost of guessing is a day.
Executive Summary
At 09:12 on a Thursday, Kestrel's head of marketing messaged the data team: the channel attribution dashboard was showing nothing for Wednesday. Every other dashboard was fine. The DAG was green.
This case study is a worked diagnosis. It follows an engineer using the lifecycle as a decision tree — four hypotheses, one per stage, each with a specific query that eliminates it — and finds the cause in ninety minutes. It then compares that against what the same investigation looked like the previous quarter, when a different engineer solved a similar problem in a day and a half by starting from the tool they knew best.
The engineering content is deliberately modest. The transferable thing is the search order, and the discipline of eliminating a stage rather than pursuing a hunch.
Skills applied: the lifecycle as a diagnostic (§2.1, §2.9); the six source-system questions (§2.2); watermark failure modes (§2.3); conforming and identity stitching (§2.5); the Transform/Serve boundary (§2.6).
Background
The dashboard. marketing_attribution shows, per acquisition channel per day, sessions, orders,
revenue, and cost per acquisition. It reads one table, gold.fct_session_attribution, which is built
nightly.
Its dependencies, five deep:
kestrel.clickstream.v1 (Kafka)
└─▶ bronze.events (streaming consumer, 30s commits)
└─▶ silver.events (dedupe on event_id, type cast)
└─▶ silver.sessions (sessionize: 30-min inactivity gap)
└─▶ gold.fct_session_attribution (join to orders, channel logic)
What was known at 09:12:
- Wednesday's rows: zero. Tuesday's: normal.
- The Airflow DAG
kestrel_dailysucceeded at 04:38, inside its window. - The revenue dashboard, which reads
gold.fct_order_item, was correct for Wednesday. - Nobody had deployed anything since Monday.
That last pair is the interesting one. Order data was fine and session data was empty, which already rules out a large class of platform-wide causes and points at the clickstream branch. An engineer who noticed that at 09:12 has eliminated half the tree before running a query.
The Problem
Four hypotheses, one per stage, and the discipline is to check them in the order that eliminates the most possibility for the least effort — not in the order of what feels most likely.
| # | Stage | Hypothesis | Eliminating query | Cost |
|---|---|---|---|---|
| 1 | Generate | The clients stopped sending events | Count messages in the Kafka topic for Wednesday | 1 min |
| 2 | Ingest | Events were produced but not consumed into bronze | SELECT COUNT(*) FROM bronze.events WHERE event_date = '...' |
1 min |
| 3 | Transform | Events landed but a transform dropped them | Count at each of silver.events, silver.sessions, gold | 5 min |
| 4 | Serve | The data exists; the dashboard cannot see it | Query the gold table directly with the dashboard's filters | 5 min |
The whole tree costs twelve minutes. That is the argument for doing it in order rather than guessing: the cost of not guessing is twelve minutes.
The Analysis
Hypothesis 1: Generate — eliminated in one minute
kafka-consumer-groups --bootstrap-server localhost:9092 \
--describe --group bronze-events-consumer
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
bronze-events-consumer kestrel.clickstream.v1 0 412883901 412883901 0
bronze-events-consumer kestrel.clickstream.v1 1 411920447 411920447 0
...
bronze-events-consumer kestrel.clickstream.v1 11 409113772 409113772 0
Offsets advancing across all twelve partitions, lag zero. Events were produced and consumed. Generate and — as it turns out — the Kafka hop of Ingest are both fine.
Hypothesis 2: Ingest — eliminated in one minute
SELECT event_date, COUNT(*) AS events
FROM bronze.events
WHERE event_date >= CURRENT_DATE - 4
GROUP BY 1 ORDER BY 1;
event_date | events
-------------+-----------
2025-06-09 | 13,981,204
2025-06-10 | 14,113,660
2025-06-11 | 14,042,918 <- Wednesday. Normal.
2025-06-12 | 5,240,881 <- today, partial
Fourteen million events landed on Wednesday, right in line with the 14,000,000/day baseline. Ingest is fine.
Two hypotheses eliminated in two minutes, and the problem is now known to be in Transform or Serve. This is the payoff of the search order: without it, an engineer who suspected a Kafka issue could have spent an hour on consumer group configuration before establishing that the data was already in the warehouse.
Hypothesis 3: Transform — where it was
Counting at every step down the chain:
SELECT 'bronze.events' AS layer, COUNT(*) FROM bronze.events WHERE event_date = '2025-06-11'
UNION ALL SELECT 'silver.events', COUNT(*) FROM silver.events WHERE event_date = '2025-06-11'
UNION ALL SELECT 'silver.sessions', COUNT(*) FROM silver.sessions WHERE session_date = '2025-06-11'
UNION ALL SELECT 'gold.fct_session_attribution', COUNT(*)
FROM gold.fct_session_attribution WHERE session_date = '2025-06-11';
layer | count
------------------------------+------------
bronze.events | 14,042,918
silver.events | 14,038,201
silver.sessions | 1,904,337
gold.fct_session_attribution | 0 <- here
Sessions built normally — 1.9 million of them. The attribution model produced nothing.
🔎 Read the Plan — Counting at every layer is the highest-value five minutes in a data investigation
The query above is the single most useful diagnostic in this book, and it takes five minutes to write for any pipeline.
It converts "something is wrong somewhere in a five-stage chain" into "the failure is at exactly one edge," which is the difference between a search and a fix. Every layer boundary is a place where rows can silently disappear — a filter, a join, a cast that nulls, a partition predicate that does not match.
Write this query for your critical pipelines before you need it, put it in the runbook, and the next incident starts five minutes in rather than five minutes from starting. Chapter 26 §26.4 makes it a standing runbook artifact.
The attribution model, reduced to the relevant part:
SELECT s.session_id,
s.session_date,
c.channel_name,
...
FROM silver.sessions s
JOIN silver.channel_map c
ON c.utm_source = s.utm_source
AND c.utm_medium = s.utm_medium
AND s.session_date BETWEEN c.valid_from AND c.valid_to
An inner join to a channel mapping table with validity windows. If no mapping row matches, the session is dropped.
SELECT MAX(valid_to) FROM silver.channel_map;
-- 2025-06-10
Every row in channel_map had valid_to = '2025-06-10'. The table is maintained by the marketing
operations team through a quarterly upload, and the current version's validity window had expired at
the end of Tuesday. Nobody had uploaded the next one.
Elapsed: forty minutes. The remaining fifty went to the harder question, which was not "what broke."
The Decision
The immediate fix was trivial: marketing operations uploaded the new mapping, the model was re-run, Wednesday backfilled in four minutes.
The interesting decision was what to change so this class of failure announces itself.
The team considered and rejected two options before choosing a third.
Rejected: change the inner join to a left join with an 'unmapped' default. This is the
instinctive fix and it is worse than the disease. Sessions would flow through with channel
'unmapped', the dashboard would render, the numbers would be complete-looking and wrong, and
nobody would notice for a month. It converts a loud failure into a silent one, which is the
exact trade this book argues against everywhere. Blank is bad; plausibly-wrong is much worse.
Rejected: alert on zero rows in gold.fct_session_attribution. Correct, and too narrow. It
catches this table and no other, and there are forty gold tables. Writing a bespoke alert per
incident is how you end up with a hundred alerts, sixty of them stale, and a team that ignores all
of them.
Adopted: three general controls.
-
Volume monitoring on every gold table, comparing today's row count against a trailing fourteen-day window and alerting outside a band. Zero rows is the extreme case; a 40% drop is the more common and more dangerous one. Chapter 25 §25.4.
-
Expiry monitoring on every reference table with a validity window. A daily check: does
MAX(valid_to)extend at least seven days into the future? This is a generate-stage control catching a generate-stage problem — the source of the failure was a human process that stopped, not a system that broke. -
A named owner and refresh cadence for every manually maintained table, recorded in the catalog.
channel_maphad neither. Chapter 30 §30.3.
📐 Design Decision — Fail loudly or fail plausibly
This fork appears constantly and the instinct is almost always wrong.
Fail loudly (inner join drops the rows): the dashboard is blank, someone complains within hours, the problem is fixed the same day. Embarrassing, visible, cheap.
Fail plausibly (left join with a default): the dashboard renders. Attribution silently reallocates to
'unmapped'. Decisions get made. Discovered in a month, if at all — and every analysis produced in that month is now suspect.Choose loud. The cost of loud is measured in hours of embarrassment; the cost of plausible is measured in decisions.
What you give up: availability. A loud failure means the dashboard is genuinely unusable while you fix it, and there are contexts — a customer-facing status page, a real-time system — where degraded-but-available beats correct-but-down. Name which context you are in before you choose. In analytics, you are almost never in the second one.
What Happened
The three controls went in over two weeks. Volume monitoring on the forty gold tables fired eleven times in the first month; nine were true positives — three expired reference tables nobody knew about, four upstream schema changes, and two genuine data drops. Two were false positives from holiday traffic patterns, which led to the band being widened for known low-volume days.
A 75% true-positive rate on a new alert is unusually good, and the reason is that the check was general rather than targeted. The controls people build after an incident are usually too specific to the incident. Building the general version costs slightly more and catches the eight other things you did not know about.
The comparison the team drew afterwards was with a similar investigation the previous quarter, when
fct_order_item had gone stale. That engineer had started from the orchestrator — reading Airflow
logs, checking task durations, examining the scheduler — because Airflow was the tool they knew
best. It was a day and a half before anyone counted rows at each layer, and the cause turned out to
be an expired credential on the extract, visible in ten seconds from the ingest-stage count.
Same shape, ten times the cost. The difference was entirely search order.
Lessons
-
Locate the stage before choosing a tool. Four hypotheses, four queries, twelve minutes to eliminate the entire tree. Guessing costs a day.
-
Count rows at every layer boundary. The single highest-value diagnostic. Write it before you need it and keep it in the runbook.
-
Notice what is not broken. Order data was correct and session data was empty. That observation eliminated half the tree before the first query ran.
-
Prefer loud failure to plausible failure. The instinctive "fix" — a left join with a default — would have converted a one-day incident into a one-month one.
-
Build the general control, not the incident-specific one. "Alert on zero rows in this table" catches one thing. Volume monitoring across all gold tables caught nine real problems in a month.
-
Manually maintained tables need an owner and an expiry check. A human process that stops looks exactly like a system that works.
-
A data pipeline's dependencies include human processes.
channel_mapwas uploaded quarterly by a person. That is a real dependency and it was not in any diagram.
Questions for Discussion
-
The engineer eliminated Generate and Ingest in two minutes, then spent forty on Transform. Was that the right allocation? Construct the case for checking Serve first, and say what information would make that the better opening move.
-
The team rejected the left-join fix as converting a loud failure into a silent one. Name a realistic Kestrel scenario where the opposite call — degrade gracefully rather than fail — is correct. What distinguishes the two cases?
-
Volume monitoring produced eleven alerts in a month, nine true. At what true-positive rate would you start considering the alert harmful rather than useful? Does the answer depend on who receives it and at what hour?
-
channel_mapwas maintained by a quarterly human upload with no owner recorded. How many such dependencies do you think a platform Kestrel's size has? How would you find them without waiting for each one to fail? -
The previous quarter's investigation took a day and a half because the engineer started from the tool they knew best. Is that a training problem, a documentation problem, or a process problem? What single artifact would most reduce the gap?
-
The expiry check asks whether
MAX(valid_to)extends seven days into the future. Why seven? Argue for a different number and say what it optimizes for. -
Suppose the attribution model had used a left join from the start, and the incident was discovered a month later during a quarterly review. Write the first three paragraphs of that incident report. How does it differ in tone and content from the one actually written?