09 · Data Pipeline Monitoring & Alerting¶
A pipeline that fails silently is worse than one that fails loudly — stale data that looks fine in a dashboard erodes trust in the whole platform once someone finally notices. This module covers the three layers of pipeline observability: operational metrics, data quality checks, and alerting that doesn't train people to ignore it.
What actually ran
The Python metric-collection and anomaly-detection code was run locally against synthetic pandas data. The Prometheus/Grafana and PagerDuty integration snippets are written correctly against each tool's documented API/config format but weren't executed against live instances here.
Three layers of pipeline observability¶
1. Operational metrics — did the job run, how long did it take, did it
succeed or fail. Answers "is the pipeline working."
2. Data quality metrics — row counts, null rates, schema drift, value
distributions. Answers "is the data the pipeline produced correct."
3. Business/freshness metrics — how stale is the data a dashboard is
showing right now. Answers "can someone trust what they're looking at."
Most teams instrument layer 1 first (it's the easiest — orchestrators provide it for free) and stop there — which is exactly how a pipeline can show "SUCCESS" in Airflow while silently writing zero rows or duplicated rows. All three layers matter; layer 2 and 3 are what actually protect data consumers.
Layer 1: operational metrics from Airflow¶
from airflow.models import DagRun
from datetime import timedelta
def check_recent_dag_health(dag_id: str, lookback_days: int = 7) -> dict:
runs = DagRun.find(dag_id=dag_id)
recent = [r for r in runs if r.execution_date >
runs[-1].execution_date - timedelta(days=lookback_days)] if runs else []
total = len(recent)
failed = sum(1 for r in recent if r.state == "failed")
durations = [
(r.end_date - r.start_date).total_seconds()
for r in recent if r.end_date and r.start_date
]
return {
"dag_id": dag_id,
"runs": total,
"failure_rate": failed / total if total else 0.0,
"p95_duration_seconds": sorted(durations)[int(len(durations) * 0.95)] if durations else None,
}
Tracking p95_duration_seconds over time (not just the latest run) catches
gradual performance regression — a pipeline that grows from 5 minutes to 40
minutes over three months rarely trips a hard timeout on any single run,
but a trend line makes it obvious.
Layer 2: data quality metrics as a first-class pipeline output¶
import pandas as pd
def compute_quality_metrics(df: pd.DataFrame, run_id: str) -> dict:
return {
"run_id": run_id,
"row_count": len(df),
"null_rate_by_column": df.isna().mean().to_dict(),
"duplicate_rate": 1 - (len(df.drop_duplicates()) / len(df)) if len(df) else 0.0,
"distinct_customer_ids": df["customer_id"].nunique() if "customer_id" in df else None,
}
df = pd.DataFrame({
"customer_id": [1, 2, 2, 3, None],
"amount": [10.0, 20.0, 20.0, None, 5.0],
})
metrics = compute_quality_metrics(df, run_id="run-2024-01-15")
print(metrics)
Write these metrics to a durable store (a metrics table, a time-series DB) on every run, success or failure — a single run's numbers are only useful in context of the trend, and you can't build that trend retroactively once a problem is already discovered.
Anomaly detection on row counts: simple and effective¶
import numpy as np
def is_row_count_anomalous(history: list[int], current: int, z_threshold: float = 3.0) -> bool:
"""Flags current count if it deviates more than z_threshold standard
deviations from the recent historical mean — catches both a sudden
drop (upstream outage) and a sudden spike (duplicate ingestion)."""
if len(history) < 5:
return False # not enough history to judge
mean = np.mean(history)
std = np.std(history)
if std == 0:
return current != mean
z_score = abs(current - mean) / std
return z_score > z_threshold
history = [98_000, 101_000, 99_500, 100_200, 99_800, 100_500]
print(is_row_count_anomalous(history, current=45_000)) # True — big drop
print(is_row_count_anomalous(history, current=100_100)) # False — normal
A fixed threshold ("alert if row count < 50,000") breaks the first time normal growth pushes past it or a seasonal dip is genuinely expected; a z-score against recent history adapts automatically and is simple enough to compute without a dedicated anomaly-detection library — genuinely sufficient for most row-count and null-rate monitoring.
Freshness: the metric consumers actually care about¶
from datetime import datetime, timezone
def compute_freshness_minutes(last_successful_load: datetime) -> float:
return (datetime.now(timezone.utc) - last_successful_load).total_seconds() / 60
def freshness_sla_breached(last_successful_load: datetime, sla_minutes: int) -> bool:
return compute_freshness_minutes(last_successful_load) > sla_minutes
# A dashboard consumer cares about "how old is this data", not
# "did the job that produced it technically succeed" — a job that
# succeeds but runs 6 hours late still breaches freshness.
Expose freshness directly, ideally right on the dashboard itself ("data as of 14:32 UTC, 12 minutes ago") — this single UI addition prevents most "why does this number look wrong" support tickets, because the answer is visible without anyone having to ask.
Alerting: routing and avoiding fatigue¶
def route_alert(severity: str, metric: str, message: str) -> str:
"""Different severities go to different channels — a hard pipeline
failure pages someone; a soft data-quality warning goes to a Slack
channel for review during business hours."""
routes = {
"critical": "pagerduty", # job failed, or freshness SLA badly breached
"warning": "slack", # anomalous but not catastrophic (e.g. z-score 3-5)
"info": "log_only", # expected variance, logged for trend analysis
}
channel = routes.get(severity, "log_only")
return f"[{channel}] {metric}: {message}"
print(route_alert("critical", "freshness_minutes", "Orders table 4 hours stale, SLA is 1 hour"))
print(route_alert("warning", "row_count", "Row count 15% below 7-day average"))
The routing matters as much as the detection: paging someone at 2am for a
15%-below-average row count (routine variance, arguably not even worth a
Slack message) is how a team learns to ignore pages entirely — reserve
pagerduty-tier alerts for things that genuinely need immediate action, and
route everything else somewhere lower-urgency.
Prometheus + Grafana wiring (config shape)¶
# prometheus.yml (excerpt) — scrapes a metrics endpoint your pipeline
# process exposes via a library like prometheus_client
scrape_configs:
- job_name: 'data_pipeline'
scrape_interval: 60s
static_configs:
- targets: ['pipeline-host:9100']
from prometheus_client import Gauge, start_http_server
row_count_gauge = Gauge("pipeline_row_count", "Rows processed", ["dag_id"])
freshness_gauge = Gauge("pipeline_freshness_minutes", "Data freshness in minutes", ["table"])
start_http_server(9100) # Prometheus scrapes this endpoint
row_count_gauge.labels(dag_id="orders_etl").set(100_234)
freshness_gauge.labels(table="analytics.orders").set(12.5)
Exposing pipeline metrics this way puts them in the same monitoring stack as application/infra metrics — one Grafana dashboard, one alerting system, rather than a separate bespoke tool just for data pipelines.
Traps¶
- Only monitoring job success/failure. A "SUCCESS" status tells you nothing about whether the output data is actually correct or complete.
- Fixed thresholds that never adapt. Hardcoded row-count thresholds become stale as the business grows or has known seasonal patterns; prefer statistical or rolling-baseline comparisons.
- Alert fatigue from unrouted severity. Sending every anomaly to a pager trains the on-call rotation to ignore pages, which defeats the point of paging at all.
- No freshness metric visible to consumers. Without it, a stale dashboard looks identical to a fresh one, and the first sign of trouble is a confused business user, not the platform team.
Cheat sheet¶
| Layer | Example metric | Answers |
|---|---|---|
| Operational | Success rate, p95 duration | Is the pipeline running |
| Data quality | Row count, null rate, duplicate rate | Is the data correct |
| Freshness | Minutes since last successful load | Can this be trusted right now |
| Alert severity | critical/warning/info routed differently | Does this need action now |
How It Actually Works¶
Operational metrics (task duration, success/failure counts, queue depth) come directly out of Airflow's metadata database — every task instance's start time, end time, and final state is already persisted there for scheduling purposes, so a metrics exporter is largely just querying that database and re-emitting it in a format Prometheus can scrape, rather than instrumenting anything new. Data quality metrics work differently: they have to be computed by the pipeline itself as a deliberate output (row counts, null rates, distribution stats written to a metrics table or emitted alongside the data), because the orchestrator has no visibility into whether the data a task produced is any good — only whether the task exited zero.
Row-count anomaly detection is typically a simple statistical test — comparing today's count against a rolling mean and standard deviation of recent days, and alerting if today's value falls outside some number of standard deviations — which is cheap to compute and catches the common failure modes (an upstream source going empty, a duplicate load doubling counts) without needing a trained model. Freshness (time since the last successful load, or the max event timestamp present in the table) matters more to consumers than uptime metrics because a pipeline can be "running successfully" on schedule while silently processing zero new rows — freshness is computed from the data's own timestamps, not from the job's exit code, which is exactly why it catches failures job-status metrics miss.
Exercise¶
Extend is_row_count_anomalous to also account for a known weekly
seasonality (e.g. Sundays always have ~40% lower volume) by comparing
current only against history from the same day-of-week, and explain why a
naive rolling mean across all days would produce false positives every
Sunday.