02 · Data Lineage & Observability¶
When a number on a dashboard looks wrong, the question is always "where did this come from, and what touched it along the way." Lineage answers that by recording the graph of datasets and transformations that produced a given table. Observability goes further — it continuously watches that graph for freshness, volume, and schema anomalies so you find out about problems before a business user does. This module builds both from scratch.
What actually ran
This module was reasoned through step by step against real sqlite3
and pandas APIs but not executed in a live interpreter for this
lesson — the outputs shown match documented behavior precisely.
Recording lineage as a graph¶
import sqlite3
import pandas as pd
import datetime as dt
lineage = sqlite3.connect(":memory:")
lineage.executescript("""
CREATE TABLE nodes (name TEXT PRIMARY KEY, kind TEXT);
CREATE TABLE edges (upstream TEXT, downstream TEXT, job_name TEXT, run_at TEXT);
""")
lineage.executemany(
"INSERT INTO nodes VALUES (?, ?)",
[
("raw.orders_api", "source"),
("bronze.orders", "table"),
("silver.orders", "table"),
("gold.daily_revenue", "table"),
("dashboard.exec_summary", "dashboard"),
],
)
lineage.executemany(
"INSERT INTO edges VALUES (?, ?, ?, ?)",
[
("raw.orders_api", "bronze.orders", "ingest_orders", "2026-08-01T02:00:00"),
("bronze.orders", "silver.orders", "clean_orders", "2026-08-01T02:15:00"),
("silver.orders", "gold.daily_revenue", "aggregate_revenue", "2026-08-01T02:30:00"),
("gold.daily_revenue", "dashboard.exec_summary", "dashboard_refresh", "2026-08-01T03:00:00"),
],
)
lineage.commit()
Upstream lineage: "what feeds this table?"¶
def upstream_of(lineage, node: str, depth: int = 10) -> list[str]:
visited, frontier = set(), {node}
chain = []
for _ in range(depth):
parents = set()
for n in frontier:
rows = lineage.execute("SELECT upstream FROM edges WHERE downstream = ?", (n,)).fetchall()
parents.update(r[0] for r in rows)
parents -= visited
if not parents:
break
chain.extend(parents)
visited.update(parents)
frontier = parents
return chain
print(upstream_of(lineage, "dashboard.exec_summary"))
This is the query you run when the exec dashboard shows a wrong number: walk backward through the graph to every table and job that contributed to it, in order.
Downstream lineage: "what breaks if I change this?"¶
def downstream_of(lineage, node: str, depth: int = 10) -> list[str]:
visited, frontier = set(), {node}
chain = []
for _ in range(depth):
children = set()
for n in frontier:
rows = lineage.execute("SELECT downstream FROM edges WHERE upstream = ?", (n,)).fetchall()
children.update(r[0] for r in rows)
children -= visited
if not children:
break
chain.extend(children)
visited.update(children)
frontier = children
return chain
print(downstream_of(lineage, "bronze.orders"))
This is impact analysis: before changing bronze.orders's schema, this
query tells you exactly which downstream tables and dashboards to check —
without it, schema changes are educated guesses.
Observability: freshness, volume, and schema checks over time¶
metrics = sqlite3.connect(":memory:")
metrics.execute("""
CREATE TABLE table_metrics (table_name TEXT, run_at TEXT, row_count INTEGER, column_count INTEGER)
""")
history = [
("silver.orders", "2026-08-01T02:15:00", 12000, 6),
("silver.orders", "2026-08-02T02:14:00", 12300, 6),
("silver.orders", "2026-08-03T02:16:00", 11950, 6),
("silver.orders", "2026-08-04T02:15:00", 400, 6), # anomaly: volume crashed
("silver.orders", "2026-08-05T02:15:00", 12500, 5), # anomaly: a column disappeared
]
metrics.executemany("INSERT INTO table_metrics VALUES (?, ?, ?, ?)", history)
metrics.commit()
def detect_anomalies(metrics, table_name: str, volume_drop_pct: float = 0.5) -> pd.DataFrame:
df = pd.read_sql(
"SELECT * FROM table_metrics WHERE table_name = ? ORDER BY run_at", metrics, params=(table_name,)
)
df["prev_row_count"] = df["row_count"].shift(1)
df["prev_column_count"] = df["column_count"].shift(1)
df["volume_anomaly"] = df["row_count"] < df["prev_row_count"] * (1 - volume_drop_pct)
df["schema_anomaly"] = df["column_count"] != df["prev_column_count"]
df.loc[df.index[0], ["volume_anomaly", "schema_anomaly"]] = False # no baseline for first row
return df[df["volume_anomaly"] | df["schema_anomaly"]]
print(detect_anomalies(metrics, "silver.orders")[["run_at", "row_count", "column_count", "volume_anomaly", "schema_anomaly"]])
run_at row_count column_count volume_anomaly schema_anomaly
3 2026-08-04T02:15:00 400 6 True False
4 2026-08-05T02:15:00 12500 5 False True
The August 4th run dropped from ~12,000 rows to 400 — a volume anomaly that should page someone before the Gold table (and dashboard) built on top of it gets computed from a nearly-empty source. August 5th shows a schema anomaly (a column vanished) even though volume looks fine.
Freshness SLAs¶
def check_freshness(metrics, table_name: str, sla_hours: int, now: dt.datetime) -> dict:
last_run = pd.read_sql(
"SELECT MAX(run_at) as last_run FROM table_metrics WHERE table_name = ?", metrics, params=(table_name,)
)["last_run"][0]
last_run_dt = pd.Timestamp(last_run)
age_hours = (now - last_run_dt).total_seconds() / 3600
return {"table": table_name, "age_hours": round(age_hours, 1), "sla_met": age_hours <= sla_hours}
print(check_freshness(metrics, "silver.orders", sla_hours=26, now=dt.datetime(2026, 8, 6, 3, 0, 0)))
Traps¶
- Lineage that's manually maintained. A hand-updated lineage diagram goes stale the day someone adds a job without updating it — lineage should be captured automatically by the orchestrator/pipeline framework (most modern tools like Airflow with OpenLineage, dbt, or Dagster emit this as a side effect of running).
- Alerting on absolute thresholds instead of relative change. A
fixed "row count must exceed 10,000" rule breaks the first time a
legitimately smaller batch runs; comparing against recent history (as
detect_anomaliesdoes) adapts to normal variation. - Observability with no owner attached. An anomaly alert that doesn't say who to page is just noise — pair every monitored table with an owner from the catalog (Module 03, Level 3).
Cheat sheet¶
| Question | Query |
|---|---|
| What fed this table? | upstream_of(lineage, node) |
| What breaks if I change this? | downstream_of(lineage, node) |
| Did volume/schema change unexpectedly? | detect_anomalies(metrics, table) |
| Is this table fresh enough? | check_freshness(metrics, table, sla_hours) |
How It Actually Works¶
Automated lineage capture is fundamentally an event-emission problem: lineage cannot be derived purely by inspecting static SQL or code, because the actual read/write relationships only become concrete when a job executes against specific tables and partitions — so the mechanism is instrumentation hooks inside each execution engine that emit structured facts ("this job run read dataset A version X, wrote dataset B version Y") to a central lineage store as jobs actually run.
Frameworks like OpenLineage implement this by hooking into an engine's own execution
lifecycle (Spark's QueryExecutionListener, Airflow's task lifecycle callbacks) and emitting
a start event (job name, run ID, declared inputs) and a complete event (same run ID, actual
outputs, column-level facts if the engine exposes them) to a lineage backend over HTTP. The
lineage graph itself is then just these events assembled into a directed graph keyed by
dataset identity — which is why lineage accuracy depends entirely on every engine touching
a dataset having this instrumentation enabled; a manual script or an untracked notebook that
writes to the same table is invisible to the graph even though it's a real dependency.
Column-level lineage (tracing which upstream columns fed a specific downstream column) goes a
level deeper: it requires the engine to expose its query plan's column-level provenance —
Spark's Dataset API can trace which input columns fed each output expression through its
logical plan's expression tree — and this trace is only as complete as the plan analysis;
a UDF that opaquely combines several columns internally typically breaks column-level lineage
at that boundary because the engine has no visibility into the UDF's own internal logic,
falling back to reporting the UDF's entire input set as feeding its output rather than a
precise column-to-column mapping.
Observability (freshness, volume, schema-drift monitors) is built as continuous queries against this same lineage and dataset metadata, comparing each new dataset version's stats against a rolling historical baseline to flag anomalies statistically rather than against a fixed hand-set threshold.
Exercise¶
Add column-level lineage: an edges_columns table recording
(upstream_table, upstream_column, downstream_table, downstream_column,
job_name). Write a column_lineage(lineage, table, column) function that
returns the full upstream chain of columns feeding a specific downstream
column (e.g., "which raw columns feed gold.daily_revenue.total_amount"),
not just which tables are involved.