Lesson 4 - Alerting on Failure

Welcome to Alerting on Failure

Lesson 2 read a real failure’s log after the fact — useful, but it assumes someone is already looking. A real pipeline needs to say something the instant it breaks, not wait to be asked. Airflow’s answer is on_failure_callback: a function you hand to a DAG or task that Airflow calls automatically, with the real failure’s own context, the moment a task fails.

By the end of this lesson, you will be able to:

  • Write a pure, testable alert sink with no Airflow dependency, and a thin callback wrapper that feeds it real failure context
  • Wire on_failure_callback to a DAG via default_args so it applies to every task
  • Trigger a real failure and verify the resulting alert record captures that failure’s real identity

A Pure Sink, and a Thin Callback Around It

Airflow itself has no email or Slack credentials configured in this local environment — a real CityFlow deployment would point this at one of those instead (Airflow’s own SmtpNotifier, or a Slack webhook via SlackWebhookHook). What this lesson proves is the mechanism: a callback that fires exactly once, exactly on a real failure, with that failure’s real identity captured — against a sink that’s actually inspectable here, a local JSON-lines file.

"""Real, importable on_failure_callback logic for CityFlow DAGs (Module 6
Lesson 4, reused by Lesson 5's guided project).

record_failure_alert() has no Airflow import either -- it takes plain values
(dag id, task id, run id, execution date, exception text) and appends one
real JSON line to a real alert log file. Each cityflow_*_dag.py file that
wants alerting wires up a small on_failure_callback wrapper that pulls those
values out of Airflow's own `context` dict and calls this function -- the
sink itself stays pure and unit-testable without Airflow installed at all,
the same split Lesson 1 drew for validation logic."""
import json
from pathlib import Path


def record_failure_alert(alert_path: str, dag_id: str, task_id: str, run_id: str, execution_date: str, exception: str) -> dict:
    """Appends one real alert record to `alert_path` (creating its parent
    directory and the file itself if either is missing) and returns the
    record that was written, so a caller can log or assert on it directly."""
    Path(alert_path).parent.mkdir(parents=True, exist_ok=True)
    record = {
        "dag_id": dag_id, "task_id": task_id, "run_id": run_id,
        "execution_date": execution_date, "exception": exception,
    }
    with open(alert_path, "a") as f:
        f.write(json.dumps(record) + "\n")
    return record


def read_alerts(alert_path: str) -> list:
    """Reads every alert record back from `alert_path` as a list of dicts --
    an empty list if the file doesn't exist yet (no alert has ever fired)."""
    path = Path(alert_path)
    if not path.exists():
        return []
    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]

Saved as .airflow-local/dags/cityflow_alerting.py — no @dag, no Airflow import, importable and unit-testable on its own exactly the way cityflow_validation_logic.py was in Lesson 1.


Wiring It to a Real DAG

cityflow_alerting_demo_dag.py reuses Lesson 2’s exact real-failure shape — ingest_ok succeeds, process_incoming_batch genuinely fails reading a batch file that was never dropped — and adds one thing: default_args wires a thin callback wrapper as this DAG’s on_failure_callback.

# gate: skip
"""CityFlow's real on_failure_callback demo (Module 6 Lesson 4).

Reuses Lesson 2's exact real-failure shape -- ingest_ok succeeds,
process_incoming_batch genuinely fails reading a batch file that was never
dropped -- and adds one thing: default_args wires cityflow_on_failure_alert
as this DAG's on_failure_callback, so every task's real failure (there's
only ever one candidate here) fires a real call to
cityflow_alerting.record_failure_alert, which appends one real JSON line to
a real local alert file with the real failure's dag_id, task_id, run_id, and
execution date."""
import sys
from datetime import datetime

from airflow.decorators import dag, task

sys.path.insert(0, "/opt/airflow/dags")
import cityflow_alerting  # noqa: E402

ZONE_LOOKUP_PATH = "/opt/airflow/dags/data/taxi_zone_lookup.csv"
MISSING_BATCH_PATH = "/opt/airflow/dags/data/work/alerting_demo/incoming_batch.csv"
ALERT_PATH = "/opt/airflow/dags/data/work/alerting_demo/cityflow_alerts.jsonl"


def cityflow_on_failure_alert(context):
    """The real on_failure_callback Airflow invokes with a real failure's own
    context -- pulls the real identity out of it and hands off to the pure,
    already-tested sink in cityflow_alerting.py."""
    ti = context["task_instance"]
    record = cityflow_alerting.record_failure_alert(
        alert_path=ALERT_PATH,
        dag_id=ti.dag_id,
        task_id=ti.task_id,
        run_id=context["run_id"],
        execution_date=str(context["logical_date"]),
        exception=repr(context.get("exception")),
    )
    print(f"cityflow_on_failure_alert: real alert recorded -> {record}")


@dag(
    dag_id="cityflow_alerting_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    default_args={"on_failure_callback": cityflow_on_failure_alert, "retries": 0},
    tags=["cityflow", "module-6", "alerting"],
)
def cityflow_alerting_demo_dag():
    @task
    def ingest_ok() -> str:
        import pandas as pd

        zones = pd.read_csv(ZONE_LOOKUP_PATH)
        print(f"ingest_ok: read {len(zones)} real CityFlow zone rows")
        return "zones_loaded"

    @task
    def process_incoming_batch(zones_ref: str) -> None:
        import pandas as pd

        print(f"process_incoming_batch: zones_ref={zones_ref!r}, reading {MISSING_BATCH_PATH}")
        pd.read_csv(MISSING_BATCH_PATH)  # genuinely missing -> real FileNotFoundError -> real on_failure_callback fires

    process_incoming_batch(ingest_ok())


cityflow_alerting_demo_dag()

default_args={"on_failure_callback": ...} applies to every task in the DAG — but a callback only ever fires for a task that actually fails, so ingest_ok succeeding never triggers it. Only process_incoming_batch can, and in this run, does.


Triggered for Real: One Failure, One Real Alert

import json
import os
import subprocess
import time

dag_id = "cityflow_alerting_demo"
alert_host_path = ".airflow-local/dags/data/work/alerting_demo/cityflow_alerts.jsonl"

if os.path.exists(alert_host_path):
    os.remove(alert_host_path)
    print(f"removed pre-existing {alert_host_path} to start from a clean slate")

subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
                capture_output=True, text=True, check=True)

run_id = f"lesson4_m6_verify_{int(time.time())}"
trigger = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True,
)
if trigger.returncode != 0:
    subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
                    capture_output=True, text=True, check=True)
    subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
        capture_output=True, text=True, check=True,
    )
print(f"triggered {dag_id!r} as {run_id!r}")

deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(2)
    r = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(r.stdout)
    match = [x for x in runs if x["run_id"] == run_id]
    state = match[0]["state"] if match else "queued"
print(f"run {run_id!r} reached state: {state}")
assert state == "failed", f"expected failed, got {state}"

r = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
    capture_output=True, text=True, check=True,
)
print(r.stdout)

time.sleep(1)
with open(alert_host_path) as f:
    lines = [json.loads(l) for l in f if l.strip()]
print(f"real alert file now has {len(lines)} line(s):")
for l in lines:
    print(" ", l)
assert len(lines) == 1, f"expected exactly 1 alert record, got {len(lines)}"
assert lines[0]["dag_id"] == dag_id
assert lines[0]["task_id"] == "process_incoming_batch"
assert lines[0]["run_id"] == run_id
triggered 'cityflow_alerting_demo' as 'lesson4_m6_verify_1784926725'
run 'lesson4_m6_verify_1784926725' reached state: failed
dag_id                 | execution_date            | task_id                | state   | start_date                       | end_date
=======================+===========================+========================+=========+==================================+=================================
cityflow_alerting_demo | 2026-07-24T20:58:47+00:00 | process_incoming_batch | failed  | 2026-07-24T20:58:49.195129+00:00 | 2026-07-24T20:58:49.493154+00:00
cityflow_alerting_demo | 2026-07-24T20:58:47+00:00 | ingest_ok              | success | 2026-07-24T20:58:48.155321+00:00 | 2026-07-24T20:58:48.482073+00:00

real alert file now has 1 line(s):
  {'dag_id': 'cityflow_alerting_demo', 'task_id': 'process_incoming_batch', 'run_id': 'lesson4_m6_verify_1784926725', 'execution_date': '2026-07-24 20:58:47+00:00', 'exception': "FileNotFoundError(2, 'No such file or directory')"}

Exactly one real alert record — not zero, not two. ingest_ok succeeded and never called the callback at all; process_incoming_batch genuinely raised FileNotFoundError, and Airflow’s own callback machinery invoked cityflow_on_failure_alert with that failure’s real context. The record it wrote carries the real dag_id, the real task_id (specifically the task that failed, not the DAG as a whole), the real run_id this exact trigger used, and the real execution_date — four independently verifiable facts about one real failure, none of them invented after the fact.


A Real Screenshot of the Failed Run

Screenshot of the Airflow Grid view for cityflow_alerting_demo, showing ingest_ok green and process_incoming_batch red, with a Details panel showing Status failed and Run ID lesson4_m6_verify_1784926725.
The real Grid view for the run that produced the alert above: `ingest_ok` green, `process_incoming_batch` red, Status `failed`, Run ID `lesson4_m6_verify_1784926725` -- the same run id the real alert record captured.

Key Takeaways

  • on_failure_callback set in default_args applies to every task in a DAG, but only ever fires for a task that actually fails — a DAG with nine successful tasks and one failure calls it exactly once, not ten times.
  • Keeping the sink (record_failure_alert) free of any Airflow import means it can be unit-tested the same way Lesson 1 tested validation logic — the callback wrapper (cityflow_on_failure_alert) is the only part that touches Airflow’s context dict at all.
  • A real alert record is only useful if it captures the failure’s real identity — dag_id, task_id, run_id, and execution_date, pulled straight from Airflow’s own context, not reconstructed or guessed afterward.
  • A file-based sink is a stand-in for email or Slack, not a permanent design choice — the callback’s job (fire once, on real failure, with real identity) is identical no matter what record_failure_alert is swapped out for.

Exercises

Exercise 1 — Write a real unit test for record_failure_alert itself (no Airflow, no Docker) — call it twice with different task_id values against the same temp file path, then use read_alerts to confirm both records come back, in order, with the right fields.

Exercise 2 — Change MISSING_BATCH_PATH to a real, existing file and re-trigger. Confirm the DAG run reaches success and that the alert file’s line count does not increase — the same “does it stay silent on success” proof Lesson 5’s guided project makes at full pipeline scale.

Exercise 3 — Add a second on-failure sink: a function that also prints the alert record to stdout with a 🚨 marker (or any distinct prefix), and change default_args["on_failure_callback"] to a list of both callbacks (on_failure_callback accepts a list in Airflow 2.6+). Re-trigger and confirm both fire from the same real failure.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com