Databricks Pipeline Monitoring Framework

How We Built a Custom Monitoring Framework for Databricks Pipelines

Data pipelines fail silently. That’s the uncomfortable truth most data engineering teams learn the hard way — usually when a business stakeholder asks why last week’s report looks off, or why a dashboard hasn’t refreshed since Tuesday. By then, the damage is done.

Rather than reaching for an expensive third-party observability tool, we built something lean, purposeful, and entirely native to our Databricks environment. This post walks through what I built, why I built it that way, and the key design decisions that made it work.


The Problem With “Just Check the Logs”

When a pipeline fails, Databricks marks the job as failed. But operational failure is only half the story. A pipeline can succeed — finishing on time, writing rows, returning exit code 0 — while quietly producing garbage data. Null primary keys. Negative claim amounts. Row counts that dropped 40% with no explanation.

Traditional job monitoring doesn’t catch that. You need a second layer: data quality monitoring. And you need both layers to speak the same language so you can ask: “For every run that breached our SLO in the last 30 days, which quality checks also failed?”

That was our north star.


Two Tables, One Foreign Key

The entire framework rests on two Delta tables in Unity Catalog:

pipeline_metrics captures operational health — one row per pipeline run.
pipeline_quality_checks captures data quality — one row per check per run.

Both share a run_id column — a UUID generated at the start of every run. That single foreign key is what makes the whole system queryable.


Section 1 — Configuration

Set these values once per environment. All secrets live in Databricks Secret Scopes — never hardcoded.

# Catalog and schema for monitoring tables
MONITORING_CATALOG = "monitoring"
MONITORING_SCHEMA = "pipeline_ops"

# Full table names
METRICS_TABLE = f"{MONITORING_CATALOG}.{MONITORING_SCHEMA}.pipeline_metrics"
QUALITY_TABLE = f"{MONITORING_CATALOG}.{MONITORING_SCHEMA}.pipeline_quality_checks"

# Slack webhook — stored in Databricks Secrets
SLACK_WEBHOOK_URL = dbutils.secrets.get(scope="monitoring", key="slack_webhook_url")

Section 2 — Table Setup

Call setup_monitoring_tables() once when deploying to a new environment. It is idempotent — safe to re-run because IF NOT EXISTS prevents overwrites.

def setup_monitoring_tables():
    # pipeline_metrics — one row per run
    spark.sql(f"""
        CREATE TABLE IF NOT EXISTS {METRICS_TABLE} (
            run_id              STRING,
            pipeline_name       STRING,
            pipeline_version    STRING,
            started_at          TIMESTAMP,
            completed_at        TIMESTAMP,
            duration_minutes    DOUBLE,
            rows_processed      LONG,
            rows_quarantined    LONG,
            slo_target_minutes  INT,
            slo_met             BOOLEAN,
            status              STRING,
            error_message       STRING
        ) USING DELTA
    """)

    # pipeline_quality_checks — one row per check per run
    spark.sql(f"""
        CREATE TABLE IF NOT EXISTS {QUALITY_TABLE} (
            run_id          STRING,
            pipeline_name   STRING,
            checked_at      TIMESTAMP,
            check_name      STRING,
            column_name     STRING,
            check_type      STRING,
            rows_checked    LONG,
            rows_failed     LONG,
            failure_pct     DOUBLE,
            threshold_pct   DOUBLE,
            passed          BOOLEAN
        ) USING DELTA
    """)

Section 3 — Pipeline Metrics Writer

Call write_pipeline_metrics() at the end of every run — in both the success path and the except block. The completed_at timestamp is captured inside the function so it always reflects when the work actually finished.

def write_pipeline_metrics(
    pipeline_name, started_at, rows_processed,
    rows_quarantined, run_id, pipeline_version='1.0',
    slo_target_minutes=45, status='success', error_message=None
):
    completed_at = datetime.utcnow()
    duration_minutes = (completed_at - started_at).total_seconds() / 60
    slo_met = duration_minutes <= slo_target_minutes

    row = Row(
        run_id              = run_id,
        pipeline_name       = pipeline_name,
        pipeline_version    = pipeline_version,
        started_at          = started_at,
        completed_at        = completed_at,
        duration_minutes    = round(duration_minutes, 2),
        rows_processed      = rows_processed,
        rows_quarantined    = rows_quarantined,
        slo_target_minutes  = slo_target_minutes,
        slo_met             = slo_met,
        status              = status,
        error_message       = error_message
    )

    # Append only — this table is an audit log, never overwrite
    spark.createDataFrame([row]) \
        .write.format('delta').mode('append').saveAsTable(METRICS_TABLE)

    return duration_minutes, slo_met

Section 4 — Quality Check Runner

Call run_quality_check() once per check. Each call appends one row to pipeline_quality_checks. The condition argument is a PySpark Column expression that evaluates to True for rows that fail the check.

def run_quality_check(
    df, run_id, pipeline_name, check_name,
    column_name, check_type, condition, threshold_pct=1.0
):
    rows_checked = df.count()
    rows_failed  = df.filter(condition).count()
    failure_pct  = (rows_failed / rows_checked * 100) if rows_checked > 0 else 0.0
    passed       = failure_pct <= threshold_pct

    spark.createDataFrame([Row(
        run_id        = run_id,
        pipeline_name = pipeline_name,
        checked_at    = datetime.utcnow(),
        check_name    = check_name,
        column_name   = column_name,
        check_type    = check_type,
        rows_checked  = rows_checked,
        rows_failed   = rows_failed,
        failure_pct   = round(failure_pct, 4),
        threshold_pct = threshold_pct,
        passed        = passed
    )]).write.format('delta').mode('append').saveAsTable(QUALITY_TABLE)

    return passed

Example Checks

# Null check — zero tolerance on primary key
run_quality_check(
    df=silver_df, run_id=run_id,
    pipeline_name=PIPELINE_NAME, check_name='patient_id_not_null',
    column_name='patient_id', check_type='null_check',
    condition=col('patient_id').isNull(), threshold_pct=0.0
)

# Range check — claim amounts must be positive
run_quality_check(
    df=silver_df, run_id=run_id,
    pipeline_name=PIPELINE_NAME, check_name='claim_amount_positive',
    column_name='claim_amount', check_type='range_check',
    condition=col('claim_amount') <= 0, threshold_pct=0.5
)

# Volume check — row count must not drop vs last run
run_quality_check(
    df=silver_df, run_id=run_id,
    pipeline_name=PIPELINE_NAME, check_name='volume_vs_last_run',
    column_name='*', check_type='volume_check',
    condition=lit(rows_processed < expected_min), threshold_pct=0.0
)

Section 5 — The Pipeline Wrapper

This is the pattern used in every pipeline notebook. It ties together start time, run_id, transformation logic, quality checks, metrics writing, and alerting in a single try/except block.

start_time        = datetime.utcnow()   # capture before any work
run_id            = str(uuid.uuid4())   # shared key between both tables
rows_processed    = 0
rows_quarantined  = 0
quality_failures  = []

try:
    # STEP 1 — READ
    bronze_df      = spark.table('bronze.claims')
    rows_processed = bronze_df.count()

    # STEP 2 — TRANSFORM
    silver_df = transform_bronze_to_silver(bronze_df)

    # STEP 3 — QUALITY CHECKS (before writing)
    if not run_quality_check(...):   # null check
        quality_failures.append('patient_id_not_null')

    if not run_quality_check(...):   # range check
        quality_failures.append('claim_amount_positive')

    # STEP 4 — WRITE (split passing and failing rows)
    silver_df.filter(col('quality_flag') == 'pass') \
        .write.format('delta').mode('append').saveAsTable('silver.claims')

    silver_df.filter(col('quality_flag') == 'fail') \
        .write.format('delta').mode('append').saveAsTable('silver.claims_quarantine')

    # STEP 5 — WRITE METRICS
    duration, slo_met = write_pipeline_metrics(
        pipeline_name      = PIPELINE_NAME,
        started_at         = start_time,
        rows_processed     = rows_processed,
        rows_quarantined   = rows_quarantined,
        run_id             = run_id,
        slo_target_minutes = 45,
        status             = 'success'
    )

    # STEP 6 — ALERT if needed
    if not slo_met:
        send_alert(PIPELINE_NAME, run_id, f'SLO breached: {duration:.1f} min')
    if quality_failures:
        send_alert(PIPELINE_NAME, run_id, f'Checks failed: {quality_failures}')

except Exception as e:
    # Still write metrics so the failure is recorded
    write_pipeline_metrics(..., status='failed', error_message=str(e))
    send_alert(PIPELINE_NAME, run_id, f'Pipeline FAILED: {str(e)}')
    raise   # re-raise so Databricks marks the job as failed

Section 6 — Monitoring Queries

SLO Summary — Last 7 Days

SELECT
    pipeline_name,
    COUNT(*)                                            AS total_runs,
    SUM(CASE WHEN slo_met THEN 1 ELSE 0 END)           AS slo_met_count,
    SUM(CASE WHEN NOT slo_met THEN 1 ELSE 0 END)       AS slo_breach_count,
    ROUND(AVG(duration_minutes), 1)                    AS avg_duration_min
FROM monitoring.pipeline_ops.pipeline_metrics
WHERE started_at >= CURRENT_DATE - INTERVAL 7 DAYS
GROUP BY pipeline_name
ORDER BY slo_breach_count DESC;

Full Picture for One Run — Operational + Quality

SELECT
    m.pipeline_name,
    m.duration_minutes,
    m.slo_met,
    q.check_name,
    q.failure_pct,
    q.passed
FROM monitoring.pipeline_ops.pipeline_metrics m
JOIN monitoring.pipeline_ops.pipeline_quality_checks q
    ON m.run_id = q.run_id
WHERE m.pipeline_name = 'silver_transform_claims'
  AND DATE(m.started_at) = CURRENT_DATE
ORDER BY q.passed ASC;   -- failed checks first

Quarantine Rate Trend — Last 30 Days

SELECT
    pipeline_name,
    DATE(started_at)                                            AS run_date,
    SUM(rows_quarantined)                                       AS quarantined,
    ROUND(SUM(rows_quarantined) * 100.0
          / NULLIF(SUM(rows_processed), 0), 2)                 AS quarantine_pct
FROM monitoring.pipeline_ops.pipeline_metrics
WHERE started_at >= CURRENT_DATE - INTERVAL 30 DAYS
GROUP BY pipeline_name, DATE(started_at)
ORDER BY run_date DESC;

Key Rules to Live By

  • One shared metrics table for all pipelines — pipeline_name separates them
  • Use the same run_id in both tables — it is the foreign key linking operational and quality data
  • Always write metrics in the except block too — a failed run still needs a record
  • Always re-raise after writing failure metrics — so Databricks marks the job as failed
  • Store all secrets in Databricks Secret Scopes — never hardcode credentials
  • Bump pipeline_version on significant logic changes — enables before/after comparisons
  • Set threshold_pct = 0.0 for zero-tolerance checks like primary keys
  • Alert on SLO breach and quality failures separately — they have different on-call responses

Closing Thoughts

Data pipelines are only as trustworthy as your ability to verify them. This framework doesn’t try to replace full observability platforms — it gives Databricks teams a structured, queryable, audit-grade foundation that takes an afternoon to deploy and pays dividends every time something goes wrong.

If you’re running pipelines in Databricks today without structured monitoring, this is a very good place to start.

Write metrics on failure, not just success. An unrecorded failure is a blind spot. Always write to the metrics table in the except block.

Re-raise after logging. Your monitoring framework should record what happened. Your orchestrator should know the job failed. These are separate responsibilities.

Make thresholds explicit. A quality check with no documented tolerance is a quality check waiting to cause confusion. Store the threshold alongside the result.

Name pipelines consistently. A naming convention like silver_transform_claims makes filtering by layer trivial and keeps dashboards readable as the number of pipelines grows.


Data pipelines are only as trustworthy as your ability to verify them. A monitoring framework doesn’t have to be complex to be effective — it has to be consistent, complete, and queryable. This one is all three.

If you’re running pipelines in Databricks today without structured monitoring, this is a good place to start.