Your Spark pipeline ran successfully at 2 AM, but by noon, users are seeing stale data in their dashboards. When you checked, the incremental load never fired. Monitoring data pipelines: practical tips and trade-offs is what separates teams that know about failures hours later from those that know about them in minutes. In this post, we'll walk through the core dimensions of pipeline observability—freshness, volume, quality, and cost—and show you how to build pragmatic alerting without drowning in noise.
Freshness — Alert when tables are not updated on time
Freshness is about knowing when your data stopped flowing. A table that hasn't been updated in 12 hours might be completely normal if it's a daily load, or it might be a disaster if it's supposed to land hourly.
The simplest approach is to track the maximum timestamp in each critical table and alert if it's older than your SLA. In production, we've found that a dedicated metadata table works better than ad-hoc queries:
from pyspark.sql import SparkSession
from pyspark.sql.functions import max, current_timestamp, lit
from datetime import datetime, timedelta
spark = SparkSession.builder.appName("freshness_check").getOrCreate()
# After your main ETL job completes, log the freshness snapshot
def log_freshness(table_name, partition_col=None):
df = spark.table(table_name)
if partition_col:
max_timestamp = df.agg(max(partition_col)).collect()[0][0]
else:
# Fallback: check _metadata if available (Delta Lake)
max_timestamp = spark.sql(f"SELECT MAX(_metadata.file_modification_time) FROM {table_name}").collect()[0][0]
freshness_record = spark.createDataFrame([
{
"table_name": table_name,
"last_updated": max_timestamp,
"check_time": datetime.utcnow(),
"lag_hours": (datetime.utcnow() - max_timestamp).total_seconds() / 3600
}
])
freshness_record.write.mode("append").insertInto("metadata.table_freshness")
# Call this after each load
log_freshness("warehouse.daily_orders", "order_date")
The trade-off here is latency vs. accuracy. Querying max(timestamp_col) on a billion-row table takes time. If you're using Delta Lake or Iceberg, lean on _metadata or table stats instead. For non-partitioned tables, sample the recent data instead of scanning everything.
In your alerting layer (DataDog, New Relic, or a custom Lambda), set thresholds per table. A real-time stream might have a 5-minute SLA; a nightly batch has 30 minutes. Store these SLAs in a config table so you can adjust without redeploying code.
Volume — Detect unusual row counts
Volume checks catch obvious failures: your user load table dropped from 2M rows to 50K, or your events table is mysteriously empty. They also catch silent data loss—the job succeeded, but fewer records made it through than yesterday.
Log row counts alongside freshness:
def log_volume(table_name, threshold_pct=10):
"""Alert if row count changed by more than threshold_pct"""
spark.sql(f"""
INSERT INTO metadata.table_volume
SELECT
'{table_name}' as table_name,
COUNT(*) as row_count,
current_timestamp() as check_time
FROM {table_name}
""")
# Compare against yesterday's same table
yesterday = spark.sql(f"""
SELECT row_count FROM metadata.table_volume
WHERE table_name = '{table_name}'
AND check_time >= date_sub(current_date(), 1)
AND check_time < current_date()
ORDER BY check_time DESC LIMIT 1
""").collect()
if yesterday:
yesterday_count = yesterday[0]["row_count"]
today_count = spark.sql(f"SELECT COUNT(*) as cnt FROM {table_name}").collect()[0]["cnt"]
change_pct = abs(today_count - yesterday_count) / yesterday_count * 100
if change_pct > threshold_pct:
# Alert: send to Slack, PagerDuty, etc.
print(f"ALERT: {table_name} volume changed by {change_pct:.1f}%")
return False
return True
The catch: legitimate spikes exist. A holiday, a marketing campaign, or a data backfill can all cause volume to jump. Build in context. Store expected ranges by day-of-week or season. If Tuesday's user table is always 15% higher than Wednesday, your threshold should account for that.
We typically use a z-score approach: if today's count is more than 3 standard deviations from the 30-day rolling mean, alert. This adapts to genuine trend changes and is harder to game than static thresholds.
Quality — Track failed checks
Quality checks verify that your data meets business rules: no negative order amounts, user IDs match a whitelist, timestamps are in the past, etc. The engineering challenge is not running them—it's making failures visible and actionable.
We've seen teams build quality frameworks using Great Expectations, Soda, or custom checks. The pattern is always the same: run checks after load, log results, alert on failure.
from pyspark.sql.functions import col, when
def run_quality_checks(table_name):
df = spark.table(table_name)
results = []
# Check 1: No nulls in primary key
null_pk = df.filter(col("user_id").isNull()).count()
results.append({
"table": table_name,
"check": "null_primary_key",
"passed": null_pk == 0,
"details": f"Found {null_pk} null user_ids",
"severity": "critical"
})
# Check 2: Amount is non-negative
negative_amt = df.filter(col("amount") < 0).count()
results.append({
"table": table_name,
"check": "negative_amount",
"passed": negative_amt == 0,
"details": f"Found {negative_amt} negative amounts",
"severity": "high"
})
# Check 3: Order date is recent (within 90 days)
old_dates = df.filter(col("order_date") < date_sub(current_date(), 90)).count()
results.append({
"table": table_name,
"check": "date_freshness",
"passed": old_dates < df.count() * 0.01, # Allow 1% old dates
"details": f"Found {old_dates} orders older than 90 days",
"severity": "medium"
})
# Write results
results_df = spark.createDataFrame(results)
results_df.write.mode("append").insertInto("metadata.quality_checks")
# Alert on critical failures
failed = [r for r in results if not r["passed"] and r["severity"] == "critical"]
if failed:
raise Exception(f"Quality check failed: {failed}")
run_quality_checks("warehouse.orders")
The trade-off: thoroughness vs. performance. Running 50 quality checks on a 500M-row table takes real time. Parallelize where you can. Partition your checks: run critical checks on 100% of data, medium checks on a recent sample, and low-priority checks once a day on the full table.
Also, separate check configuration from code. Store rules in a versioned config file or database. This lets data owners update thresholds without involving engineering.
Cost — Monitor job duration and compute spend
You can't manage what you don't measure. Tracking Spark job duration reveals performance regressions before they become expensive habits. A 2-minute job that creeps to 15 minutes is costing you hundreds of dollars per month.
Log key metrics at the end of each Spark job:
import time
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("cost_tracking").getOrCreate()
def track_job_cost(job_name, executor_count, executor_memory_gb):
"""Log job duration and estimated cost"""
start_time = time.time()
# Your Spark workload here
spark.sql("SELECT * FROM big_table WHERE year = 2024").write.mode("overwrite").parquet("s3://bucket/output")
duration_seconds = time.time() - start_time
# On AWS, rough pricing: $0.44/hour per vCPU (us-east-1, on-demand)
# This is a simplified example; real calculations are more complex
vcpu_hours = (executor_count * 4) * (duration_seconds / 3600) # Assume 4 vCPU per executor
estimated_cost = vcpu_hours * 0.44
cost_record = spark.createDataFrame([
{
"job_name": job_name,
"duration_seconds": duration_seconds,
"executor_count": executor_count,
"estimated_cost_usd": estimated_cost,
"cost_per_gb_processed": estimated_cost / (spark.sparkContext._jsc.hadoopConfiguration().getFloat("fs.s3a.bytes.written", 0) / 1e9)
}
])
cost_record.write.mode("append").insertInto("metadata.job_costs")
print(f"{job_name} took {duration_seconds:.0f}s, cost ~${estimated_cost:.2f}")
track_job_cost("daily_order_aggregate", executor_count=10, executor_memory_gb=32)
Look for trends. If your daily aggregation used to run in 4 minutes with 8 executors and now needs 12 executors to finish in 6 minutes, something changed: data volume grew, a join became inefficient, or a new filter is missing. Find and fix it before the cost becomes visible on the monthly cloud bill.
Set alerts on duration outliers. If a job that typically finishes in 5 minutes takes 15 minutes, page someone. If monthly compute spend grows 20% month-over-month and volume didn't grow, investigate. Too many teams discover pipeline cost issues in retrospect.
Common mistakes
- Alert fatigue. If you alert on every volume fluctuation and quality check that technically failed, your team will ignore all alerts. Be ruthless: only alert on actionable issues with clear ownership and an SLA to fix.
- Ignoring context. A quality check passes in production but fails in staging? That's a test data issue, not a pipeline bug. Make checks context-aware: adjust thresholds and rules per environment.
- Skipping the metadata table. Storing freshness, volume, and cost data in a proper table (not just logs) lets you query trends, compare against baselines, and build dashboards. Logs are append-only noise; tables are queryable history.
- Monitoring the tool, not the outcome. Your Spark job succeeded, but did the data make it to the data warehouse? Did users' dashboards update? Monitor the whole pipeline, not just individual steps.
- No runbook. If you alert on "table X is 3 hours stale," your oncall engineer needs to know the next step. Link every alert to a runbook or Wiki page with debugging steps.
Key takeaways
- Freshness tells you when data stopped flowing; set per-table SLAs and check
max(timestamp)or table stats, not file modification times. - Volume checks catch silent data loss; use z-score comparisons against historical baselines to avoid false positives.
- Quality checks must be versioned and configurable; separate rules from code and alert only on critical failures.
- Cost metrics (duration, executor count, estimated spend) reveal performance regressions early; log them in a queryable table and set duration outlier alerts.
- Build a single metadata table to store all monitoring results; query it to find trends, not just point-in-time failures.
- Alert on actionable issues with clear ownership; alert fatigue kills observability faster than no monitoring at all.






Comments
Loading comments…