Course 6 · Capstone

Lab 09 — CI/CD & Observability

A platform that only you can run, only on your laptop, only when you remember to, isn't production-grade. In this lab you make mini-GridDP defensible: every pull request gets its tests run automatically, and every pipeline run leaves a trail you can read. This is the difference between "it works on my machine" and "it works, and I can prove it, and I'll know the second it stops."

Setup — do this first

You need your mini-griddp repo pushed to GitHub (you've been committing since Lab 01 — if it's still local-only, create a repo with gh repo create mini-griddp --source=. --private --push). You also need the dbt project from Labs 03/06 building cleanly with uv run dbt build locally, and the ingest/rollup scripts from Labs 02/04. Have your local stack runnable too, since you'll exercise the Dagster UI at the end. Everything in this lab is done from the repo root.

The goal

So far mini-GridDP is correct when you run it. Two gaps separate it from a real platform:

  • Nobody checks your changes before they land. You could merge a broken model at midnight and not find out until the dashboard is wrong. We fix this with CI — Continuous Integration — that runs your dbt tests automatically on every pull request and blocks the merge if anything fails.
  • You can't see what your pipelines did. When a run is slow or empty, you have no record of how many rows moved or how long it took. We fix this with observability — structured logs, run metrics, and freshness checks surfaced in the Dagster UI.

This lab adds the last two boxes from the architecture diagram — the QUALITY and OBSERVABILITY rows at the bottom:

YOU GITHUB YOUR LAPTOP ┌──────────┐ ┌──────────────────────────┐ ┌─────────────────────┐ │ git push │───────▶│ Pull Request │ │ Dagster UI │ │ a branch │ │ └─▶ CI (Actions) │ │ ├─ run timeline │ └──────────┘ │ dbt build + test │ │ ├─ asset checks │ │ on throwaway DuckDB│ │ ├─ freshness check │ │ RED ✗ → merge blocked │ │ └─ structured logs │ │ GREEN ✓ → safe to merge │ │ rows + duration │ └──────────────────────────┘ └─────────────────────┘
CI/CD, in one breath

CI = on every change, automatically build it and run the tests. CD = automatically deploy what passes. For a data platform, "deploy" is usually "run dbt against production," which Dagster already does on a schedule (Lab 05). So we focus on the high-value half: a CI gate that stops bad data models from ever reaching main.

A CI workflow on every PR

GitHub Actions runs a workflow — a YAML file describing steps to execute on an event. Our event is pull_request. The steps: check out the code, set up Python with uv (the same tool from Course 5 ch.05), install the project from its lockfile, seed a tiny throwaway DuckDB, then run dbt build, which runs the tests too. If any test fails, the step exits non-zero and the whole check goes red.

The key idea: CI runs against a throwaway database seeded with a tiny sample, not your real warehouse. That makes the run fast, deterministic, and safe — it never touches production data. dbt's seeds are perfect for this: CSVs that dbt loads as tables.

First, give CI a sample to chew on. The seeds have to look exactly like bronze — same table names, same columns, loaded_at included — because the staging models will read them through the same source('bronze', …) declarations. Five small files, a handful of rows each; note the H100 price change on 2 April, so the SCD2 join gets exercised too:

dbt/seeds/customers.csv
customer_id,name,tier,created_at,loaded_at
1,Acme GPU Co,pro,2026-01-04 09:00:00+00,2026-04-03 06:00:00+00
2,Northwind AI,free,2026-02-11 09:00:00+00,2026-04-03 06:00:00+00
3,Globex Render,enterprise,2026-03-02 09:00:00+00,2026-04-03 06:00:00+00
dbt/seeds/gpus.csv
gpu_id,model,vram_gb,loaded_at
1,NVIDIA H100 80GB,80,2026-04-03 06:00:00+00
2,NVIDIA A100 80GB,80,2026-04-03 06:00:00+00
dbt/seeds/gpu_prices.csv
gpu_id,price_per_hour,valid_from,loaded_at
1,4.20,2026-01-01 00:00:00+00,2026-04-03 06:00:00+00
1,4.50,2026-04-02 00:00:00+00,2026-04-03 06:00:00+00
2,2.40,2026-01-01 00:00:00+00,2026-04-03 06:00:00+00
dbt/seeds/rentals.csv
rental_id,customer_id,gpu_id,hours,started_at,loaded_at
1001,1,1,4.00,2026-04-01 10:00:00+00,2026-04-03 06:00:00+00
1002,2,2,1.00,2026-04-01 11:30:00+00,2026-04-03 06:00:00+00
1003,1,1,2.50,2026-04-02 09:00:00+00,2026-04-03 06:00:00+00
dbt/seeds/telemetry.csv
gpu_id,ts,util_pct,temp_c,power_w,loaded_at
1,2026-04-02 09:00:05+00,81.0,82.5,330.0,2026-04-03 06:00:00+00
1,2026-04-02 09:00:10+00,79.0,81.9,325.0,2026-04-03 06:00:00+00
2,2026-04-02 09:00:05+00,55.0,70.1,260.0,2026-04-03 06:00:00+00

Now make dbt load them as bronze, and only in CI. Two lines of config in dbt_project.yml do it: the seeds land in the bronze schema (the Lab 03 macro keeps that name as-is), and they are enabled only when the target is ci, so a local dbt build can never overwrite your real bronze tables with three rows. Column types are pinned so timestamps stay timestamps:

dbt/dbt_project.yml (add at the bottom)
seed-paths: ["seeds"]

# CI fixtures: tiny CSVs that stand in for bronze. Enabled ONLY for the ci
# target, so a local `dbt build` can never overwrite your real bronze tables.
seeds:
  mini_griddp:
    +enabled: "{{ target.name == 'ci' }}"
    +schema: bronze
    customers:
      +column_types: {created_at: timestamptz, loaded_at: timestamptz}
    gpus:
      +column_types: {loaded_at: timestamptz}
    gpu_prices:
      +column_types: {price_per_hour: "decimal(6,2)", valid_from: timestamptz, loaded_at: timestamptz}
    rentals:
      +column_types: {hours: "decimal(8,2)", started_at: timestamptz, loaded_at: timestamptz}
    telemetry:
      +column_types: {ts: timestamptz, loaded_at: timestamptz}

One subtlety decides the order of the CI steps: dbt does not know that a seed stands in for a source, so on an empty database dbt build would start creating views before the seeds they read exist. Seed first, then build. Now the workflow:

.github/workflows/ci.yml
name: CI

# Run on every PR targeting main, and on pushes to main itself.
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  dbt-build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up uv (Python toolchain)
        uses: astral-sh/setup-uv@v5

      - name: Install the project from the lockfile (same versions as your laptop)
        run: uv sync --frozen

      - name: Show dbt can see the project
        run: uv run dbt debug --target ci
        working-directory: dbt

      - name: Load the tiny sample (throwaway DuckDB)
        run: uv run dbt seed --full-refresh --target ci
        working-directory: dbt

      - name: Build models and run ALL tests
        # dbt build runs seeds, models AND tests in dependency order.
        # If any test fails, dbt exits non-zero and this step (and the PR) goes red.
        run: uv run dbt build --target ci
        working-directory: dbt

Every dbt step runs inside dbt/, where dbt finds profiles.yml on its own, and names the ci target. Add that target to the Lab 03 profile next to dev, not instead of it; it writes a throwaway file that the runner discards when the job ends:

dbt/profiles.yml
mini_griddp:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: ../warehouse.duckdb   # the file from Lab 02, relative to dbt/
      schema: main
      threads: 4
    ci:
      type: duckdb
      path: ci_warehouse.duckdb   # created fresh on the CI runner, discarded after
      schema: main
      threads: 4

You don't need GitHub to see what CI will see. The same two commands run on your laptop against the same throwaway file (*.duckdb is already gitignored):

terminal
cd dbt
uv run dbt seed --full-refresh --target ci
uv run dbt build --target ci
cd ..
✓ You should see

Locally: Done. PASS=33 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=33, which is 5 seeds, 13 models and 15 tests, in about five seconds. Then push this branch and open a PR. On the PR's Checks tab, a job named dbt-build-and-test appears, runs for a minute or two, and finishes with a green check. Click into it and you'll see dbt's familiar output ending in the same summary line. That green check is the promise: nothing broken got merged.

Workflow pieceWhat it doesWhy it matters
on: pull_requestTriggers the run when a PR opens or updatesEvery change is checked before it can merge
runs-on: ubuntu-latestA fresh, disposable Linux VMClean room — no "works on my machine" state
dbt seedLoads the tiny sample CSVsFast, deterministic data; never touches prod
dbt buildRuns models + tests in DAG orderA failing test exits non-zero → red check
Make it a real gate

A red check is only advisory until you enforce it. In your repo on GitHub: Settings → Branches → Add branch protection rule for main, tick Require status checks to pass before merging, and select dbt-build-and-test. Now GitHub physically refuses to merge a PR whose CI is red.

Watch CI go red, then green

Tests you never see fail are tests you don't trust. Let's break something on purpose and watch the gate catch it. Pick a test to break: fct_rentals.customer_id carries a relationships test to dim_customer (Lab 06). On a new branch, add a rental for a customer that does not exist:

terminal
git checkout -b break-ci-on-purpose

# Add a rental for customer 9, who is not in customers.csv, so the relationships test must fail.
printf '1004,9,1,1.00,2026-04-02 12:00:00+00,2026-04-03 06:00:00+00\n' >> dbt/seeds/rentals.csv

git add -A
git commit -m "TEMP: orphan rental to prove CI catches it"
git push -u origin break-ci-on-purpose
gh pr create --fill        # opens the PR
✓ You should see — RED

Within a minute the PR's check turns into a red ✗ (locally, the same dbt build --target ci shows it at once). Click it: the Build models and run ALL tests step shows FAIL 1 relationships_fct_rentals_customer_id__customer_id__ref_dim_customer_ with Got 1 result, configured to fail if != 0, a summary of PASS=32 WARN=0 ERROR=1, and dbt build exits non-zero. The PR banner reads "Merging is blocked — Required statuses must pass." The bad data cannot reach main. This is the whole point. (A duplicate rental_id would not have done it: staging deduplicates, so unique stays green and only assert_rental_count_reconciles notices, as Lab 06's fourth question predicted.)

Now fix it. Remove the orphan row, commit, and push to the same branch — CI re-runs automatically:

terminal
# Drop the last (orphan) line we appended.
sed -i.bak '$ d' dbt/seeds/rentals.csv && rm dbt/seeds/rentals.csv.bak

git add -A
git commit -m "Fix: remove the orphan rental"
git push                   # same branch → CI runs again on the PR
✓ You should see — GREEN

The check flips to a green ✓, the "Merging is blocked" banner disappears, and the PR is now mergeable. You just experienced the full loop a teammate's PR goes through: propose → CI judges → fix → CI approves → merge. Since this PR only existed to demonstrate the gate, close it without merging (gh pr close break-ci-on-purpose) and delete the branch.

Structured logs & run metrics

CI guards your code. Logging tells you what your runs actually did. Right now your ingest and rollup scripts probably print() a few things, which is invisible the moment you're not watching. Replace prints with structured logging: one JSON object per event, so a human can read it and a machine can parse it.

Create a tiny shared helper so every script logs the same way:

ingest/obs.py
"""Shared structured-logging helper for mini-GridDP scripts."""
import json
import logging
import sys
import time
from contextlib import contextmanager


class JsonFormatter(logging.Formatter):
    """Render each log record as a single line of JSON."""
    def format(self, record):
        payload = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        # Anything passed via logger.info("...", extra={"fields": {...}})
        if hasattr(record, "fields"):
            payload.update(record.fields)
        if record.exc_info:                 # keep the traceback when logger.exception() is used
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload)


def get_logger(name):
    logger = logging.getLogger(name)
    if not logger.handlers:            # avoid duplicate handlers on re-import
        handler = logging.StreamHandler(sys.stdout)
        handler.setFormatter(JsonFormatter())
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)
    return logger


@contextmanager
def timed_run(logger, step):
    """Log start/finish of a step and emit duration + whatever metrics you set."""
    metrics = {}                       # mutate this inside the `with` block
    start = time.perf_counter()
    logger.info(f"{step} started", extra={"fields": {"step": step, "event": "start"}})
    try:
        yield metrics
    except Exception:
        logger.exception(f"{step} FAILED", extra={"fields": {"step": step, "event": "error"}})
        raise
    else:
        metrics["duration_s"] = round(time.perf_counter() - start, 3)
        metrics["step"] = step
        metrics["event"] = "finish"
        logger.info(f"{step} finished", extra={"fields": metrics})

Now wire it into the Postgres extractor from Lab 02. The pattern: open a timed_run, do the work, and record rows into the metrics dict — duration is captured for you. Three small edits: full_refresh ends with return n, incremental_rentals ends with return new_rows, and main() wraps each step:

ingest/extract_postgres.py (the import, and the new main)
from obs import get_logger, timed_run

log = get_logger("ingest.postgres")

# ... connect(), full_refresh() (now ends with `return n`),
#     incremental_rentals() (now ends with `return new_rows`) unchanged otherwise ...


def main():
    con = connect()
    ensure_watermark_store(con)
    print(f"Landing bronze into {WAREHOUSE} ...")
    for t in FULL_REFRESH:
        with timed_run(log, f"extract:{t}") as m:
            m["rows"] = full_refresh(con, t)        # <-- the run metric we care about
    with timed_run(log, "extract:rentals") as m:
        m["rows"] = incremental_rentals(con)
    con.close()
    print("Done.")

Run it and watch the output. The JSON lines sit between the script's own print lines, so tell jq to skip anything that isn't JSON:

terminal
uv run python ingest/extract_postgres.py

# Want only the finished-step metrics? Pipe through jq (-R + fromjson? skips the plain lines):
uv run python ingest/extract_postgres.py | jq -R 'fromjson? | select(.event == "finish") | {step, rows, duration_s}'
✓ You should see

JSON lines like {"ts":"2026-11-05T16:29:40","level":"INFO","logger":"ingest.postgres","msg":"extract:customers finished","step":"extract:customers","event":"finish","duration_s":0.023,"rows":50}. The jq filter trims it to {"step":"extract:customers","rows":50,"duration_s":0.023}, then gpus 10, gpu_prices 23 and rentals 0 — zero is right for the incremental step when nothing new has landed in Postgres, and exactly the number you would question on a full-refresh step. You now have rows processed and duration for every step — the two numbers you'll watch to catch an empty source or a sudden slowdown. The exercise applies the same wrapper to the telemetry consumer.

Why JSON, not plain text

Plain logs are fine until you have thousands of lines. JSON logs are queryable: jq, a log platform, or Dagster's event log can filter on rows == 0 or duration_s > 60 instantly. Structured-from-the-start is a habit that pays off the first time you have to debug a 2 a.m. run.

The observability surface

Logs are the raw material; you still need a surface to see them on. For mini-GridDP that surface is the Dagster UI you stood up in Lab 05 (and the orchestration ideas from Course 5 ch.07). Every asset materialization shows up as a run with a timeline, the captured logs, and — once we add them — checks.

Dagster asset checks let an asset assert things about itself after it materializes. Add a freshness check that flags stale telemetry — if the newest row in fct_telemetry_hourly is older than two hours, the data has gone stale and the check fails loudly in the UI:

dagster_app/checks.py
from datetime import datetime, timezone

import duckdb
from dagster import asset_check, AssetCheckResult, AssetKey

from dagster_app.assets import WAREHOUSE


@asset_check(asset=AssetKey("fct_telemetry_hourly"))
def telemetry_is_fresh() -> AssetCheckResult:
    """Fail if the newest telemetry hour is more than 2 hours old."""
    con = duckdb.connect(str(WAREHOUSE), read_only=True)
    newest = con.execute("select max(hour) from gold.fct_telemetry_hourly").fetchone()[0]
    con.close()

    if newest is None:
        return AssetCheckResult(passed=False, metadata={"reason": "table is empty"})

    # `hour` is TIMESTAMPTZ (Lab 04), so DuckDB hands back an aware datetime; compare like with like.
    age_hours = (datetime.now(timezone.utc) - newest).total_seconds() / 3600
    return AssetCheckResult(
        passed=age_hours <= 2,
        metadata={
            "newest_hour": str(newest),
            "age_hours": round(age_hours, 2),
            "threshold_hours": 2,
        },
    )

Checks live in their own module, so tell definitions.py to load them from both files, then run the pipeline:

dagster_app/definitions.py (two lines change)
from dagster_app import assets, checks

all_assets = load_assets_from_modules([assets])
all_checks = load_asset_checks_from_modules([assets, checks])
terminal
uv run dagster job execute -j full_pipeline -m dagster_app.definitions   # headless, or:
uv run dagster dev    # open http://localhost:3000 (stop the Lab 01 placeholder first)
✓ You should see

In the headless log, ASSET_CHECK_EVALUATION - Asset check 'telemetry_is_fresh' on 'fct_telemetry_hourly' passed right after the rollup step. In the Dagster UI, open the fct_telemetry_hourly asset. Under Checks you'll see telemetry_is_fresh with a green ✓ right after a fresh run, and its metadata (age_hours: 0.3, newest_hour: ...). Leave the producer off for two hours (or lower the threshold), re-run, and it flips to a red ✗ with age_hours above the threshold — the platform is now telling you the data is stale instead of you finding out from a confused dashboard.

What you'd actually alert on, in priority order:

SignalWhere it comes fromWhy you'd page someone
A scheduled run failedDagster run statusThe pipeline is down; data is not updating at all
Freshness check redThe asset check aboveData is stale — dashboards silently show old numbers
rows == 0 on an ingest stepYour structured log metricA source went empty; a downstream model will be wrong
Duration 3× the normYour duration_s metricSomething's degrading before it fully breaks
A dbt test failed in prodThe dbt_build asset going red (Lab 06)An assumption about the data broke (nulls, dupes)
Alert on symptoms, not noise

Wire failures to a Dagster sensor that posts to Slack or email. The rule of thumb: alert on things a human must act on (run failed, data stale), and merely log the rest. An alert that fires for nothing trains everyone to ignore it — the worst possible outcome for observability.

Commit Lab 09

You've added the CI workflow, sample seeds, a CI profile, the logging helper, and asset checks. Land it all on main through a clean PR — and this time, let your own green check approve the merge:

terminal
git checkout -b lab-09-cicd-observability
git add .github/workflows/ci.yml dbt/seeds dbt/profiles.yml dbt/dbt_project.yml \
        ingest/obs.py ingest/extract_postgres.py dagster_app/checks.py dagster_app/definitions.py
git commit -m "Lab 09: CI on every PR + structured logs + freshness checks"
git push -u origin lab-09-cicd-observability
gh pr create --fill
# wait for the green check, then:
gh pr merge --squash --delete-branch
✓ You should see

The PR opens, CI runs dbt build on the sample, the check goes green, and the merge succeeds. Your commit history now shows mini-GridDP gaining a safety net — exactly the kind of progression a hiring manager loves to scroll through.

✓ Check yourself

Answer from memory before opening each one.

  1. CI is red on a PR, yet the merge button still works and a teammate uses it. What was never set up?

    Show answer

    Branch protection. A failing check is only advisory until main has a rule requiring the dbt-build-and-test status check to pass; with that rule, GitHub itself refuses the merge. See Make it a real gate.

  2. A teammate proposes running CI against the real warehouse.duckdb "so the tests see real data". What stands in the way, and what do you concede?

    Show answer

    The runner does not have it — the warehouse is a gitignored build artifact — and even if it did, the run would be slow, non-deterministic (green today, red tomorrow with no code change) and touching real data. Seeds give a small fixed sample, so a red check means the code broke. The concession: a three-row sample never contains the odd rows production does. CI guards code; the same tests running inside the scheduled pipeline (Lab 06) guard data, and you need both. See A CI workflow on every PR.

  3. Last night's log contains extract:customers finished with "rows": 0, and the run is green. Why did nothing fail, and what should have happened?

    Show answer

    Selecting from an empty table is a perfectly successful query — no exception, so timed_run logs a normal finish. A green status says the code ran, not that data arrived. That is why rows is emitted as a metric: rows == 0 on an ingest step is one of the signals worth alerting on, before an empty dimension makes every downstream model wrong. See Structured logs and what you'd alert on.

  4. An exception is raised inside a with timed_run(log, "extract:gpus") as m: block, after m["rows"] was set. Which log lines exist for that step, and what does the jq 'select(.event == "finish")' filter show for it?

    Show answer

    A start line and an ERROR line with "event": "error"; then the exception is re-raised. There is no finish line, so no duration_s, and the rows you set is never emitted. The jq filter shows nothing at all for the step — a view of finished steps hides failures, so watch for error events, or a start with no matching finish. See timed_run.

  5. The telemetry producer died yesterday afternoon. This morning's scheduled run is green from end to end. Which signal tells you something is wrong, and why is run status silent?

    Show answer

    The telemetry_is_fresh asset check: the newest hour in fct_telemetry_hourly is more than two hours old, so it fails and reports age_hours. The run succeeded honestly — rolling up yesterday's readings again works fine. Staleness is missing data, not failing code, so only a check that compares the data's own timestamp with the clock can see it. See The observability surface.

  6. A teammate routes every signal in the alerting table — including "duration 3× the norm" — to a page at any hour. What would you change, and on what principle?

    Show answer

    Page for what a human must act on now: a failed scheduled run, a red freshness check (dashboards quietly showing old numbers). A slow but successful run is an early warning — log it and review it in daylight, escalating only if it starts to threaten freshness. Alerts that fire with nothing to do teach the team to ignore alerts, and that costs you the one that matters. See Alert on symptoms, not noise.

Exercise — Add a freshness step to CI, or one more instrumented script

Pick one. (A) Add a CI step that also runs dbt source freshness so the PR reports source-data freshness, not just tests. (B) Wrap the telemetry consumer's drain in timed_run so it emits a rows metric like the extractor does.

solution A — .github/workflows/ci.yml (add a step)
      - name: Check source freshness
        # The seeds' loaded_at stamps are months old, so this reports ERROR STALE
        # and exits 1. continue-on-error keeps the step informational; drop that
        # line once you want freshness to gate merges (with fixtures that are fresh).
        run: uv run dbt source freshness --target ci
        working-directory: dbt
        continue-on-error: true
solution B — ingest/consume_stream.py (excerpt)
from obs import get_logger, timed_run

log = get_logger("ingest.telemetry")


def main():
    # ... connect, subscribe as before ...
    landed = 0
    with timed_run(log, "land:telemetry") as m:
        # ... the poll loop as before, but count what flush() inserts:
        #     landed += len(buffer) just before each flush(con, buffer)
        m["rows"] = landed


if __name__ == "__main__":
    main()

For A, you should see a new Check source freshness step in the PR's check log, printing each seed's freshness state in red without failing the job. For B, running uv run python ingest/consume_stream.py --drain prints a JSON finish line with "step":"land:telemetry", a rows count, and duration_s — now both ends of your firehose are observable.

Takeaway

  • CI runs dbt build on every pull request against a throwaway DuckDB file seeded with a tiny sample, so it is fast, repeatable and cannot touch production.
  • The seeds must look exactly like bronze and be enabled only for the ci target, and CI must run dbt seed before dbt build because dbt does not know a seed stands in for a source.
  • A red check is advisory until branch protection requires dbt-build-and-test, and then GitHub itself blocks the merge.
  • Prove the gate by breaking the relationships test on a branch: the check goes red, then green once you fix it.
  • Structured JSON logs with the step, rows and duration are queryable with jq, while a print is invisible the moment you stop watching.
  • A Dagster asset check can flag telemetry older than two hours, and you alert only on what a human must act on, such as a failed run or stale data.

The platform is built, tested in CI and observable, so the last lab packages it so anyone can run and understand it. → Lab 10 — Ship & Document

Further reading