from pyspark.sql import functions as F
catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"USE SCHEMA {catalog}.dbx_course")
spark.sql("""
CREATE TABLE IF NOT EXISTS ops_run_log (
run_ts TIMESTAMP, job STRING, step STRING, status STRING,
rows_in BIGINT, rows_out BIGINT, rows_bad BIGINT, message STRING)
""")
Module 16: Production Monitoring & Operations
Monitor, alert, troubleshoot and operate pipelines in production.
Topics in this module
- Production Pipeline Architecture
- Job Monitoring and Alerts
- System Tables
- Pipeline Observability
- Logging and Error Classification
- Data Quality Monitoring
- SLA, SLO, and Freshness Monitoring
- Troubleshooting Failed Jobs
- Incident Management
- Retry and Recovery Strategies
- Backfill and Reprocessing
- Audit Logging
- Operational Runbooks
- Cost Monitoring and Budget Alerts
Know that production runs are healthy, and fix them fast when they aren't. You build a small run log, freshness checks and a runbook, the things every pipeline needs.
- Free Edition has no account console, so budgets and account-level system tables may be unavailable. Where that matters, topics say so
- Practice needs
dbx_course.orders(Module 4) and a place for logs:
Production Pipeline Architecture
By the end: draw the layers of a production pipeline and where monitoring plugs in.
| Layer | What to watch |
|---|---|
| Orchestration (jobs) | Run status, duration, retries |
| Data | Row counts, quality rules, freshness |
| Compute | Failures, memory, queueing |
| Cost | Usage per job and team |
| Access | Who read or changed what |
- A production pipeline = code + schedule + alerts + logs + runbook
- Design for failure: every step is idempotent, restartable, and reports what it did
- Separate data problems (bad input) from system problems (cluster, network, bug)
- Decide who gets paged for each
Remember
A pipeline nobody monitors is a pipeline that is broken and nobody knows yet.
Job Monitoring and Alerts
By the end: alert on job failures, slow runs and missing runs.
On the job (Module 10.9): add email notifications for failure, and a duration warning with an expected duration.
| Alert on | Why |
|---|---|
| Failure | Something is broken |
| Duration over expected | Slower: data growth, skew, contention |
| No run happened | A missing run fails silently (check freshness, topic 7) |
| Success with 0 rows | Technically fine, actually broken |
- Alert on the last retry, not every retry ("mute until last retry")
- A good alert says what, where, how bad, and a link to the run
- Route to a channel or group, not a person
- Too many alerts = ignored alerts. Tune them
Alerts on data with SQL alerts: a query on a schedule + a condition:
%sql
SELECT count(*) AS stale_hours
FROM (SELECT timestampdiff(HOUR, max(order_ts), current_timestamp()) AS h FROM orders)
WHERE h > 24
- A SQL alert runs the query on a schedule and notifies when the condition is met. It can also run as a job task, so later tasks can branch on the result
Free Edition
Email notifications work. Slack, Teams and webhook destinations need an admin-created destination.
System Tables
By the end: use system tables to monitor jobs, usage and access across the account.
| Table | Tells you |
|---|---|
system.lakeflow.jobs, job_tasks |
Job and task definitions (history) |
system.lakeflow.job_run_timeline |
Every run: start, end, result_state, termination_code |
system.lakeflow.job_task_run_timeline |
Same, per task |
system.billing.usage |
Usage and cost by job, product and tag |
system.access.audit |
Who did what |
system.query.history |
SQL queries |
%sql
-- failed runs in the last 7 days
SELECT job_id, run_id, result_state, termination_code, period_start_time
FROM system.lakeflow.job_run_timeline
WHERE period_start_time >= current_date() - INTERVAL 7 DAYS
AND result_state IN ('FAILED', 'ERROR', 'TIMED_OUT')
ORDER BY period_start_time DESC
- Read-only, free to query (you pay for the compute that runs the query)
- Retention is typically 365 days per table
- You need
USEandSELECTon thesystemschemas. By default, only account and metastore admins - Timeline tables hold one row per period of a run: take the latest per
run_id
Free Edition
Whether system.* is available to you isn't confirmed. Try SHOW SCHEMAS IN system. If it fails, topic 4 gives you the same view from your own tables.
Pipeline Observability
By the end: write a run log and read pipeline metrics.
def log_run(job, step, status, rows_in=None, rows_out=None, rows_bad=None, message=""):
(spark.createDataFrame(
[(job, step, status, rows_in, rows_out, rows_bad, message)],
"job STRING, step STRING, status STRING, rows_in LONG, rows_out LONG, rows_bad LONG, message STRING")
.withColumn("run_ts", F.current_timestamp())
.select("run_ts", "job", "step", "status", "rows_in", "rows_out", "rows_bad", "message")
.write.mode("append").saveAsTable("ops_run_log"))
src = spark.table("orders")
n_in = src.count()
good = src.filter("customer_id IS NOT NULL AND quantity > 0")
n_out = good.count()
log_run("orders_daily", "clean", "SUCCESS", n_in, n_out, n_in - n_out)
%sql
SELECT * FROM ops_run_log ORDER BY run_ts DESC LIMIT 10
| Log | Records |
|---|---|
| Run log (this table) | Row counts, status, one row per step |
| Event log (pipelines) | Flow progress and quality (Module 9.12) |
| Streaming progress | lastProgress per micro-batch (Module 11) |
| System tables | Platform-wide runs, cost, access |
- Record in, out and rejected rows per step, plus a run id (
{{job.run_id}}) - Build a dashboard on the run log: counts over time reveal drift
- Pipeline observability = counts + quality + freshness + lineage (lineage: Module 6.8)
Logging and Error Classification
By the end: log properly and classify errors.
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("orders_daily")
class DataQualityError(Exception): ...
class SourceUnavailableError(Exception): ...
def step_clean():
try:
df = spark.table("orders")
bad = df.filter("quantity <= 0").count()
if bad:
raise DataQualityError(f"{bad} rows with non-positive quantity")
log.info("clean ok: %d rows", df.count())
except DataQualityError as e:
log.error("data problem: %s", e)
log_run("orders_daily", "clean", "DATA_ERROR", message=str(e))
raise
| Class | Example | Action |
|---|---|---|
| Transient | Network blip, quota, busy compute | Retry |
| Data | Bad values, schema change | Quarantine or stop, notify the data owner |
| Code | Bug, wrong column | Fix, redeploy |
| Config / access | Missing permission, bad secret | Ops fixes |
- Use log levels: INFO for progress, WARNING for oddities, ERROR for failures
- Include identifiers (job, run id, table, date): not passwords or personal data
- Raise a specific error and let the job fail. Never swallow exceptions
- Job logs and notebook output are on the run page;
printandloggingboth appear
Data Quality Monitoring
By the end: monitor data quality over time, not only at load time.
checks = spark.sql("""
SELECT current_timestamp() AS checked_at, 'orders' AS table_name,
count(*) AS row_count,
count(*) FILTER (WHERE customer_id IS NULL) AS null_customer,
count(*) FILTER (WHERE quantity IS NULL) AS null_quantity,
count(DISTINCT order_id) AS distinct_orders
FROM orders
""")
checks.write.mode("append").saveAsTable("ops_dq_history")
display(spark.table("ops_dq_history").orderBy("checked_at"))
| Metric | Spot |
|---|---|
| Row count per load | Sudden drop or spike |
| Null rate per column | A source stopped sending a field |
| Distinct keys / duplicates | Double loads |
| Value distribution | A code that changed |
- Compare today with the recent normal, not only with a fixed number
- Anomaly detection (Data quality monitoring) can learn each table's usual freshness and completeness and flag outliers; data profiling keeps statistics and drift over time
- Those services run on serverless compute and are billed: check availability and cost before enabling
Alert on change, not only on limits
A 40% drop in row count is a problem even if 10,000 rows is still "over the minimum".
SLA, SLO, and Freshness Monitoring
By the end: define SLAs, SLOs and freshness checks.
| Term | Meaning | Example |
|---|---|---|
| SLI | What you measure | Hours since the last load |
| SLO | Your target | Revenue table fresher than 6 hours, 99% of days |
| SLA | A promise to consumers | Dashboard data ready by 08:00 |
%sql
SELECT 'orders' AS table_name,
max(order_ts) AS last_event,
timestampdiff(HOUR, max(order_ts), current_timestamp()) AS hours_stale,
timestampdiff(HOUR, max(order_ts), current_timestamp()) <= 24 AS within_slo
FROM orders
- Freshness:
max(event_time)or the table's last commit (DESCRIBE HISTORY) - Completeness: expected row count or expected partitions
- Set an error budget: the number of misses you accept per month. Over budget = fix reliability before adding features
- Put the SLO in the job description and in the alert
- Our practice data is static, so
within_slois false. Live data would pass
Troubleshooting Failed Jobs
By the end: troubleshoot a failed job step by step.
| Check | Where |
|---|---|
| Which task failed? | Run graph / matrix view |
| The actual error | Task output, stack trace (read from the bottom) |
| What changed? | Recent commits, schema or data changes, new volume |
| Is it data? | Query the input rows for the failing key |
| Is it compute? | Queue time, memory messages, quota |
| Rerun safely | Repair run (Module 10.12) |
- Reproduce in a notebook with the same parameters
- Read the last lines of a Python traceback first, then the line in your code
- Common causes: a schema change, null in a new place, a permission change, a changed file name, a dependency version change
- Write down the cause and fix. Add a test or a check so it can't recur (Module 14)
Fix the class of problem
The fix isn't done until a test or check exists that would have caught it.
Incident Management
By the end: handle an incident from detection to review.
| Step | Do |
|---|---|
| Detect | An alert, or a consumer complaint (then fix the missing alert) |
| Triage | Who is affected? How bad? Name an incident owner |
| Mitigate | Pause downstream jobs, mark data as delayed, restore the last good version |
| Fix | Correct the cause, rerun, verify counts |
| Communicate | Tell consumers early: what, impact, next update |
| Review | A blameless write-up: timeline, cause, what will change |
- Use Delta time travel and
RESTOREto recover a bad write (Module 5.10) - Keep a list of who owns what (on-call, data owners)
- Reviews ask "why did our system allow this?" not "who did it?"
Retry and Recovery Strategies
By the end: choose retry and recovery strategies by failure type.
| Failure | Strategy |
|---|---|
| Transient (network, quota) | Retry with a delay and a limit |
| Bad record | Quarantine the row, continue |
| Bad batch | Stop, fix, repair run |
| Stream failure | Restart from checkpoint (Module 11.14) |
| Downstream outage | Retry later, keep upstream results |
| Wrong data written | Restore a version, rerun |
import time
def with_retries(fn, attempts=3, delay=10):
for i in range(1, attempts + 1):
try:
return fn()
except SourceUnavailableError as e:
log.warning("attempt %d/%d failed: %s", i, attempts, e)
if i == attempts:
raise
time.sleep(delay * i) # increasing wait
- Retry only transient errors. Retrying a data error just fails slower
- Set retries in one layer (job or code), not both (Module 28)
- Retries need idempotent steps:
MERGE, overwrite by range
Backfill and Reprocessing
By the end: reprocess data safely.
- Backfill: loading a range that was missed or is new. Reprocess: redoing a range with fixed logic
- Same code path as daily runs, with a date range parameter (Module 8.13, Module 10.13)
| Step | Check |
|---|---|
| 1. Decide the range | Which dates, which tables, what depends on them |
| 2. Try in a copy | A test schema or a DEEP CLONE of the table |
| 3. Run in chunks | Small ranges, easy to restart |
| 4. Reconcile | Counts and sums against the source (Module 8.14) |
| 5. Switch over | Deploy the result, tell consumers |
%sql
CREATE OR REPLACE TABLE orders_backfill_copy DEEP CLONE orders
- Pause downstream jobs while you reprocess, or consumers see half-done data
- Record the backfill in the run log: what range, why, who
- Keep the original data until the new result is verified
Backfill and streaming
A stream with a checkpoint won't re-read old data. Reprocess in batch from bronze, or use a one-time append flow in a pipeline.
Audit Logging
By the end: use audit logs to answer who did what.
| Question | Source |
|---|---|
| Who read or changed a table? | system.access.audit (and table history with DESCRIBE HISTORY) |
| Who ran this job? | Job run details, system.lakeflow |
| Who changed permissions? | Audit events from Unity Catalog |
| What data did a query touch? | Lineage, system.query.history |
%sql
-- recent Unity Catalog actions by a user
SELECT event_time, action_name, user_identity.email, request_params
FROM system.access.audit
WHERE event_date >= current_date() - INTERVAL 7 DAYS
AND service_name = 'unityCatalog'
ORDER BY event_time DESC
LIMIT 50
- Filter on
event_date, not onlyevent_time: much faster - Audit data is for investigation and compliance: keep who may query it small
- On your own tables,
DESCRIBE HISTORYshows every write with user and operation
%sql
DESCRIBE HISTORY orders_delta
Free Edition
System tables may not be available. DESCRIBE HISTORY always works for Delta tables you own.
Operational Runbooks
By the end: write a runbook that someone at 3 a.m. can follow.
Runbook: orders_daily
Purpose Loads and cleans orders. Feeds the revenue dashboard.
Owner data-eng (channel #data-eng-alerts)
Schedule Daily 05:00 UTC. SLA: tables ready by 08:00.
Depends on Landing files in /Volumes/.../orders. Table products.
Alerts
FAILED -> check the failed task, see "Common failures"
Duration > 20m -> check input volume, query profile
Common failures
Schema changed -> Auto Loader stopped: rerun (new column added) or full refresh
Quarantine > 1% -> notify the source owner, do NOT backfill yet
Permission denied -> check the service principal grants
Recovery
1. Repair run (reruns failed task and downstream)
2. Bad data written -> RESTORE TABLE ... TO VERSION AS OF n, then rerun
3. Backfill: run the job with run_date range; reconcile counts
Escalation On-call, then team lead after 30 minutes.
- One page per pipeline, kept next to the code
- Written for someone who didn't build it
- Update it after every incident
- Link it from the alert and the job description
A runbook is tested by use
If following it didn't work last time, fix the runbook now.
Cost Monitoring and Budget Alerts
By the end: monitor cost and set budget alerts.
| Tool | Shows |
|---|---|
system.billing.usage |
Usage by job, product, tag (source of truth) |
| Budgets (account console) | Monthly spend thresholds with email alerts |
| Budget policies / usage policies | Attribute serverless usage to teams or projects |
| Query history, job runs | Which runs and queries cost the most |
%sql
-- usage per job (sum handles correction records)
SELECT usage_metadata.job_id, usage_date, sum(usage_quantity) AS dbus
FROM system.billing.usage
WHERE usage_metadata.job_id IS NOT NULL
GROUP BY ALL
HAVING dbus != 0
ORDER BY usage_date DESC
- Usage appears within about 12 hours, and budget emails can lag up to 24 hours
- Budgets use list prices: no discounts or credits
- Tag jobs and pipelines (
team,project,env) so cost can be split (Module 18) - Review the top 10 jobs by cost every month
Free Edition
There is no account console, so budgets aren't available, and billing system tables may not exist. You have a daily compute quota instead.
What you can do now
- Monitoring layers, alerts, system tables and run logs
- Logging, error classes, quality and freshness monitoring
- Troubleshooting, incidents, retries and backfills
- Audit logs, runbooks and cost monitoring