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 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 dbt, seed a tiny throwaway DuckDB, then run dbt build and dbt test. 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. Create a couple of small seed files (real values, just a handful of rows):

dbt/seeds/sample_customers.csv
customer_id,name,region,signed_up_at
1,Acme GPU Co,us-east,2026-01-04
2,Northwind AI,eu-west,2026-02-11
3,Globex Render,us-west,2026-03-02
dbt/seeds/sample_rentals.csv
rental_id,customer_id,gpu_id,started_at,ended_at,hourly_price
1001,1,h100-01,2026-04-01T10:00:00,2026-04-01T14:00:00,2.50
1002,2,a100-07,2026-04-01T11:30:00,2026-04-01T12:30:00,1.80
1003,1,h100-02,2026-04-02T09:00:00,,2.50

Point your staging models at these seeds when running in CI. The simplest pattern is a dbt var that swaps the source — but to keep this lab focused, we just build everything seeds-first: dbt seed loads the CSVs, then dbt build runs models + tests on top of them. 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
    defaults:
      run:
        working-directory: dbt        # all steps run inside the dbt project
    env:
      # dbt reads the profile from the repo, not ~/.dbt, so CI is self-contained.
      DBT_PROFILES_DIR: .
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up uv (Python toolchain)
        uses: astral-sh/setup-uv@v5
        with:
          python-version: "3.12"

      - name: Install dbt + the DuckDB adapter
        run: uv pip install --system dbt-core dbt-duckdb

      - name: Show dbt can see the project
        run: dbt debug

      - name: Load the tiny sample (throwaway DuckDB)
        run: dbt seed --full-refresh

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

That DBT_PROFILES_DIR: . line means dbt looks for profiles.yml inside the dbt/ folder. Commit a CI-only profile that writes to a local file — the runner throws it away when the job ends:

dbt/profiles.yml
mini_griddp:
  target: ci
  outputs:
    ci:
      type: duckdb
      path: ci_warehouse.duckdb    # created fresh on the runner, discarded after
      threads: 4
✓ You should see

Push this branch and open a PR. On the PR's Checks tab, a job named dbt-build-and-test appears, runs for ~1–2 minutes, and finishes with a green check. Click into it and you'll see dbt's familiar output: PASS seed ..., PASS model ..., and a summary like Done. PASS=9 WARN=0 ERROR=0. 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 model with a not_null or unique test (your fct_rentals from Lab 06 has these on rental_id). On a new branch, introduce a duplicate:

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

# Add a duplicate rental_id to the seed so the unique test must fail.
printf '1003,2,a100-09,2026-04-02T09:00:00,,1.80\n' >> dbt/seeds/sample_rentals.csv

git add -A
git commit -m "TEMP: duplicate rental_id 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 ✗. Click it: the Build models and run ALL tests step shows FAIL 1 unique_fct_rentals_rental_id with Got 2 results, configured to fail if != 0, 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.

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

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

git add -A
git commit -m "Fix: remove duplicate rental_id"
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)
        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.

ingest/extract_postgres.py (excerpt)
from obs import get_logger, timed_run

log = get_logger("ingest.postgres")

def extract_table(con, table):
    with timed_run(log, f"extract:{table}") as m:
        rows = con.execute(f"SELECT * FROM {table}").fetchall()
        write_to_bronze(table, rows)   # your existing landing logic
        m["rows"] = len(rows)          # <-- the run metric we care about
        m["table"] = table
    return rows


if __name__ == "__main__":
    con = connect_postgres()
    for table in ("customers", "gpus", "rentals", "prices"):
        extract_table(con, table)

Run it and watch the output. Every line is parseable JSON:

terminal
python ingest/extract_postgres.py

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

JSON lines like {"ts":"2026-06-21T10:02:11","level":"INFO","logger":"ingest.postgres","msg":"extract:rentals finished","step":"extract:rentals","event":"finish","duration_s":0.214,"rows":1483}. The jq filter trims it to {"step":"extract:rentals","rows":1483,"duration_s":0.214}. 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. Apply the same timed_run wrapper to your telemetry rollup so it reports rows-rolled-up per hour.

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
import datetime as dt
from dagster import asset_check, AssetCheckResult, AssetKey


@asset_check(asset=AssetKey("fct_telemetry_hourly"))
def telemetry_is_fresh(duckdb) -> AssetCheckResult:
    """Fail if the newest telemetry hour is more than 2 hours old."""
    with duckdb.get_connection() as con:
        newest = con.execute(
            "SELECT max(hour) FROM fct_telemetry_hourly"
        ).fetchone()[0]

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

    age_hours = (dt.datetime.utcnow() - newest).total_seconds() / 3600
    return AssetCheckResult(
        passed=age_hours <= 2,
        metadata={
            "newest_hour": str(newest),
            "age_hours": round(age_hours, 2),
            "threshold_hours": 2,
        },
    )

Reload your code location and run the pipeline:

terminal
dagster dev    # open http://localhost:3000
✓ You should see

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: ...). Stop the generator, wait, re-run the check, 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 prodDagster's dbt integrationAn 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 \
        ingest/obs.py ingest/extract_postgres.py dagster_app/checks.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

  • Can you explain, step by step, what happens between git push on a branch and a green check on the PR?
  • Why does CI build against a seeded throwaway DuckDB instead of your real warehouse?
  • What two run metrics does timed_run emit, and what would each one warn you about?
  • Which signals deserve an alert, and which should merely be logged?
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 checks source-data freshness, not just tests. (B) Wrap your telemetry rollup script 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
        # Runs against the freshness rules defined on your dbt sources.
        # --no-error-on-runtime so a stale sample doesn't red the PR;
        # drop that flag once you want freshness to actually gate merges.
        run: dbt source freshness --no-error-on-runtime
solution B — ingest/rollup_telemetry.py (excerpt)
from obs import get_logger, timed_run

log = get_logger("rollup.telemetry")

def rollup_hourly(con):
    with timed_run(log, "rollup:telemetry_hourly") as m:
        con.execute(BUILD_HOURLY_SQL)          # your existing rollup SQL
        m["rows"] = con.execute(
            "SELECT count(*) FROM fct_telemetry_hourly"
        ).fetchone()[0]


if __name__ == "__main__":
    rollup_hourly(connect_duckdb())

For A, you should see a new Check source freshness step in the PR's check log, printing each source's freshness state. For B, running the rollup prints a JSON finish line with "step":"rollup:telemetry_hourly", a rows count, and duration_s — now both ends of your firehose are observable.

Next

Your platform is built, tested in CI, and observable — time to package it so anyone can run it and understand it. → Lab 10 — Ship & Document