learn-dataops-with-phoebe / Builder session 7 of 8
Learn DataOps with Phoebe · Builder track · Session 7 of 8

Observability, secrets and IaC

RetailPulse now cleans, orchestrates, tests, migrates, trains, and serves. But you still cannot answer the one question an on-call teammate asks at 7am: is it healthy right now? Tonight you make the pipeline observable - structured logs, freshness and volume checks, alerts that route to the owner - then lock down secrets and stand the whole stack up with one command. This is hands-on and gets your platform trustworthy.

🟠 Builder track · hands-on Practitioners: DA · DE · DS · ML Python · Docker Compose · Postgres · Airflow · MLflow RetailPulse v0.6 -> v0.7
0-3 · Recap 3-20 · Signals, alerts, secrets 20-42 · Build-along: logging + Compose 42-45 · Q&A
Part 0

Where RetailPulse stands

Six sessions in, RetailPulse is a real data product: a clean_sales pipeline writing clean_sales.parquet, an Airflow DAG scheduling it, a Great Expectations suite plus a data contract gating quality, a versioned Postgres sales table, a demand-forecast model tracked and registered in MLflow, and a FastAPI /predict endpoint in Docker with Evidently drift monitoring and a retrain trigger. CI runs on every PR. It works. What it does not yet do is tell you when it stops working.

Live - presented in session Self-study - read after class ★ Try it now command Open-source stack + AWS notes
★ What you walk out with today Structured logs on the pipeline so every run is searchable, freshness and volume checks that know when the sales table is stale or the row count is wrong, an alert that fires to a human when something users would feel, secrets pulled out of the code and into a gitignored .env, and a docker-compose.yml that stands the entire RetailPulse stack - Postgres, Airflow, MLflow - up with one command. RetailPulse v0.7.
Part 1 · covers the three signals + the five pillars

The three signals of data observability 6 min live

Observability is the ability to answer "what is happening and why" without shipping new code to find out. It rests on three kinds of signal: logs (what happened, event by event), metrics (how much and how fast, as numbers over time), and lineage (what depends on what, so a break upstream explains a break downstream). App observability watches these for services. Data observability watches them for the data itself.

Logs what happened, per event Metrics how much · how fast Lineage · what depends on what Is RetailPulse healthy right now? answerable in seconds Three signals, one question. Without them, "is it healthy?" is a guess.
🔍 Click to zoom - logs, metrics, and lineage combine to answer one operational question
LiveApp observability vs DATA observability3 min

Application observability asks: is the service up, fast, and error-free? Data observability asks a harder question: is the data flowing through it correct, complete, and current? A FastAPI endpoint can return HTTP 200 in 40ms all day while quietly serving predictions off a sales table that stopped updating on Tuesday. The service is healthy; the data is rotten. The classic frame is the five pillars of data observability:

  • Freshness: how recently did the data update? A stale sales table is the most common silent failure.
  • Volume: did the expected number of rows arrive? A load that drops from 50k rows to 3k is broken even if nothing errored.
  • Schema: did the columns or types change out from under you? The b3 contract guards this; observability watches it in prod.
  • Distribution: are the values in range? Negative prices, dates in 1970, a category that vanished - these pass row counts but poison models.
  • Lineage: when something breaks, what upstream source caused it and what downstream depends on it? This turns a 2am mystery into a 2-minute trace.
★ Try it now (your terminal) - is the parquet fresh?python -c "import os, time; \ age = (time.time() - os.path.getmtime('data/clean_sales.parquet'))/3600; \ print(f'clean_sales.parquet is {age:.1f}h old')" # If that number is bigger than your SLA, the data is stale - a freshness signal you can act on.
Real world

The dashboard that was green and wrong. A retail team's exec dashboard showed flat sales for nine days. Every service was up, every check was HTTP 200, no page fired. The upstream export job had silently stopped writing; the warehouse kept serving the last good snapshot. A single freshness metric - "hours since sales last updated" - would have caught it on day one. App observability said healthy. Data observability was the missing lens.

Self-studyMetrics, logs, traces - the app-observability triad2 min read

In service land the triad is metrics, logs, and traces. Data land renames the third to lineage, but the discipline is the same: emit numbers you can chart, events you can search, and dependency links you can walk. For RetailPulse tonight you build the first two by hand - structured logs and a couple of metrics - because feeling them is how you learn what a paid observability platform is actually doing for you later.

SignalService questionData question for RetailPulse
LogsWhat did the code do?Did the cleaning step drop rows, and how many?
MetricsLatency, error rateFreshness hours, row count, null rate
Lineage / tracesWhich call failed?Which source table caused the bad forecast?
Part 2 · covers alerts worth having + alert fatigue

Alerting that people trust 6 min live

Signals are only useful if someone acts on them. An alert is a promise: when this fires, a real problem exists and it is worth interrupting a human. Break that promise a few times - alert on noise - and the whole team learns to ignore the channel. Then the one alert that mattered gets muted with the rest. The craft is picking a small set of alerts that map to something a user would actually feel.

AlertPillarCondition for RetailPulseWhy it is worth a page
Pipeline failed-The Airflow DAG task errored or did not runNo fresh data at all downstream
Data late / staleFreshnesssales last loaded > 26h ago (SLA 24h + buffer)Forecasts and dashboards go quietly out of date
Row count out of bandVolumeDaily rows outside 30k-70k historical bandA partial or double load skews every metric
Schema changedSchemaA column dropped or a type flipped vs the contractDownstream code breaks or corrupts silently
Drift highDistributionEvidently drift score over threshold (from b6)Model predicts on data it was not trained for
LiveAlert on symptoms users feel, route to the owner3 min

Two rules save you from the alert-fatigue trap:

  • Alert on symptoms, not causes. "Sales data is 30 hours stale" is a symptom a user feels. "CPU on the worker hit 80%" is a cause that may or may not matter. Page on the first; put the second on a dashboard you look at when investigating.
  • Route to the owner. An alert with no clear owner is noise by design. Each RetailPulse alert names the person or team who can fix it and links straight to the runbook. If nobody owns it, do not alert on it - delete the check or assign the owner first.
  • Make every alert actionable. If the honest response to an alert is "yeah, that happens sometimes," it is not an alert, it is a log line. Tune the threshold or drop it.
  • Severity tiers. Page (wake someone) for "users are affected now." Ticket (business hours) for "will be a problem soon." Log (no notification) for everything else.
The anti-pattern: alert fatigue A channel that fires 40 times a day trains everyone to ignore it. The failure is not too few alerts - it is too many bad ones. Ruthlessly delete alerts nobody acts on. A quiet alert channel where every message means "go look now" is worth more than a firehose.
Part 3 · covers secrets management + Infrastructure as Code

Secrets and Infrastructure as Code 4 min live

RetailPulse now needs a Postgres password, an MLflow URI, maybe a Slack webhook. None of those belong in Git - a committed secret is compromised forever, because history keeps it. And the stack now has enough moving parts (Postgres, Airflow, MLflow, the API) that "set it up by hand" is not reproducible. Two disciplines fix both: secrets kept out of code, and the environment itself described as code.

Secrets password · webhook · URI .env (gitignored) or a secret manager Git repo never IaC · code docker-compose.yml Whole env rebuilt anywhere committed - describes services, not secrets values injected at runtime Config as code goes in Git. Secret values never do - they are injected at runtime.
🔍 Click to zoom - secrets stay out of Git while IaC reproduces the environment from a committed file
LiveSecrets management and Infrastructure as Code3 min

Secrets management has one iron rule and a ladder of maturity:

  • Never in Git. Not in code, not in config, not in a notebook cell. History is forever; a leaked key is a rotate-everything incident.
  • Environment variables + a gitignored .env. The lightweight standard: code reads os.environ["DB_PASSWORD"], the value lives in .env, and .env is in .gitignore. Commit a .env.example with keys but no values so teammates know what to set.
  • A secret manager for anything shared or production: AWS Secrets Manager, SSM Parameter Store, HashiCorp Vault. Rotation, access control, and audit come built in.

Infrastructure as Code (IaC) means the environment is described in a file you commit, not clicked together by hand. Tonight docker-compose.yml is your lightweight IaC - one file that declares Postgres, Airflow, and MLflow and brings them all up together. Terraform is the same idea pointed at the cloud: it declares the real VPC, database, and cluster so prod is reproducible too. Same principle at two altitudes.

Prompt A - .gitignore and .env.example# .gitignore (make sure these lines exist) .env data/ *.parquet # .env.example (commit THIS - keys only, no real values) POSTGRES_PASSWORD=changeme MLFLOW_TRACKING_URI=http://mlflow:5000 SLACK_WEBHOOK_URL=
Self-studyWhen to graduate from .env to a secret manager2 min read

A gitignored .env is right for local dev and small teams. Move to a managed secret store the moment secrets are shared across people or environments, or the moment one lands in production. Signals it is time: you are pasting passwords in Slack, you cannot answer "who has access to the prod DB password," or you need to rotate a key and have no idea what would break. AWS Secrets Manager and SSM Parameter Store both inject values as environment variables at container start, so your code - os.environ[...] - does not change. Only the source of the value does.

Demo 1 of 2

Structured logging and a freshness alert ★ 12 min · everyone builds

RetailPulse v0.7, part one: make the pipeline observable. Add structured (JSON) logs so every run is searchable, compute a freshness and a volume check against the Postgres sales table, and emit an alert to a Slack webhook (or a log stub) when the data is stale or the row count falls out of band.

Add structured logging to pipeline.py: configure a JSON log formatter and log the row count in and out of clean_sales. Now every run leaves a searchable trail instead of a bare print.

Write monitor.py with two checks against the sales table: freshness (hours since max(loaded_at)) and volume (today's row count vs a historical band). Read the DB password from os.environ, never a literal.

Add an alert(msg, severity) function that posts to the Slack webhook from SLACK_WEBHOOK_URL if set, and otherwise logs at WARNING. This is the "route to a human" step - stubbed so it works with no webhook too.

Wire the check into the Airflow DAG as a task that runs after the load. Trip it on purpose: hold back today's load and watch the freshness alert fire.

Confirm the alert names the symptom ("sales stale: 30.2h > 26h SLA"), not a cause. That phrasing is what makes a page trustworthy.

★ RetailPulse v0.7 - monitor.py, freshness + volume + alertimport os, json, logging import psycopg2 log = logging.getLogger("retailpulse.monitor") def _alert(msg: str, severity: str = "page") -> None: url = os.environ.get("SLACK_WEBHOOK_URL") if url: import requests requests.post(url, json={"text": f"[{severity}] {msg}"}, timeout=5) else: log.warning(json.dumps({"alert": msg, "severity": severity})) def check_sales(sla_hours: float = 26, low: int = 30_000, high: int = 70_000) -> None: conn = psycopg2.connect( host=os.environ["PG_HOST"], dbname="retailpulse", user=os.environ["PG_USER"], password=os.environ["POSTGRES_PASSWORD"], # from .env, never hardcoded ) with conn, conn.cursor() as cur: cur.execute("SELECT EXTRACT(EPOCH FROM now() - max(loaded_at))/3600 FROM sales") age = float(cur.fetchone()[0] or 1e9) cur.execute("SELECT count(*) FROM sales WHERE loaded_at::date = current_date") rows = cur.fetchone()[0] if age > sla_hours: _alert(f"sales stale: {age:.1f}h > {sla_hours}h SLA", "page") if not (low <= rows <= high): _alert(f"sales volume out of band: {rows} rows (expect {low}-{high})", "page") log.info(json.dumps({"freshness_h": round(age, 1), "rows_today": rows})) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) check_sales()
Data tip Derive the volume band from history, not a guess - query the last 30 days and set low/high to roughly the 5th and 95th percentile. A band that is too tight cries wolf; too wide catches nothing. Store the band in config so it can be tuned without a code change.
Demo 2 of 2

Docker Compose stands the stack up ★ 10 min · build your own

RetailPulse v0.7, part two: one docker compose up brings the whole platform online - Postgres, Airflow, and MLflow, wired together, with every secret read from a gitignored .env that is never committed. New teammate, new laptop, one command, working stack.

Write docker-compose.yml (Prompt B) declaring three services: postgres, airflow, and mlflow, on a shared network so they can reach each other by name.

Pull every secret from the environment with ${VAR} syntax. Compose reads .env automatically. Confirm .env is in .gitignore and only .env.example is committed.

Add a named volume for Postgres data so the database survives a restart, and mount your dags/ folder into Airflow so DAG edits show up without a rebuild.

Run docker compose up -d. Watch all three come up. Open Airflow on :8080 and MLflow on :5000 - the same stack you built piecemeal across b2-b6, now reproducible from one file.

Tear it down with docker compose down, then bring it back. Same environment, every time. That reproducibility is the whole point of IaC.

★ Prompt B - docker-compose.yml (secrets from .env, never committed)services: postgres: image: postgres:16 environment: POSTGRES_DB: retailpulse POSTGRES_USER: ${PG_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # from .env volumes: [pgdata:/var/lib/postgresql/data] ports: ["5432:5432"] mlflow: image: ghcr.io/mlflow/mlflow:v2.14.1 command: mlflow server --host 0.0.0.0 --backend-store-uri postgresql://${PG_USER}:${POSTGRES_PASSWORD}@postgres/retailpulse ports: ["5000:5000"] depends_on: [postgres] airflow: image: apache/airflow:2.9.2 env_file: .env # never checked into Git environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor volumes: ["./dags:/opt/airflow/dags"] ports: ["8080:8080"] depends_on: [postgres] volumes: pgdata:
Real world

The onboarding that used to take three days. A data team's setup doc was 40 steps of "install this, then edit that." Every new hire lost their first three days to it, and it was subtly wrong for half of them. They replaced it with a docker-compose.yml and a .env.example. New setup: clone, copy the example to .env, fill three values, docker compose up. Three days became twenty minutes - and the environment was finally identical for everyone.

☁️ AWS mapping Three OSS jobs, three AWS services, same three ideas. Observability: CloudWatch for logs, metrics, and alarms - your JSON logs become CloudWatch Logs Insights queries, your freshness metric a CloudWatch alarm. Secrets: Secrets Manager or SSM Parameter Store inject values at container start, so os.environ[...] is unchanged. IaC: Terraform or CloudFormation declares the real VPC, RDS, and cluster the way docker-compose.yml declares your local stack. The disciplines are identical; only the scale changes.
Homework

Try it yourself - this week ◐ 40-55 min total

Source material

What this session covers

This track teaches DataOps on an open-source stack from the tools' official docs, with AWS mapping notes. This page covers:

The three signals + five pillars of data observabilityPart 1 · logs, metrics, lineage; freshness/volume/schema/distribution/lineage
Alerting design + alert fatiguePart 2 · symptoms not causes, route to owner
Secrets management + Docker Compose as IaCPart 3 + Demo 2 · .env, secret managers, compose file
Python structured logging + Slack webhooksDemo 1 · JSON logs and a stubbed alert path
CloudWatch / Secrets Manager / Terraform mappingDemo 2 sidebar · concept parity, not a full AWS tutorial
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the difference between app observability and data observability?

A service can return HTTP 200 in 40ms while serving off a sales table that stopped updating on Tuesday. Freshness, volume, schema, distribution, and lineage are the data lens the service lens misses.

2 · What causes alert fatigue, and what is the fix?

The failure is too many noisy alerts, not too few. A quiet channel where every message means "go look now" and routes to a clear owner is worth more than a firehose everyone mutes.

3 · Where should the Postgres password for RetailPulse live?

A committed secret is compromised forever because history keeps it. Config as code goes in Git; secret values are injected at runtime from .env or a secret manager, so os.environ[...] stays the same either way.

Builder session 7 cheat sheet · pin this

Three signalsLogs (what happened) · metrics (how much/how fast) · lineage (what depends on what).
Five pillars of data observabilityFreshness · volume · schema · distribution · lineage. The data lens app monitoring misses.
App vs data observabilityService can be HTTP 200 and fast while the data is stale and wrong. Watch both.
Alerts worth havingPipeline failed · stale (freshness) · row count out of band (volume) · schema changed · drift high.
Beat alert fatigueAlert on symptoms users feel, route to the owner, delete alerts nobody acts on.
SecretsNever in Git. Env vars + gitignored .env (commit .env.example); secret manager when shared or prod.
IaCdocker-compose.yml is lightweight IaC - one command stands the whole stack up. Terraform is the cloud step.
Running projectRetailPulse v0.7: observable, alerting, secrets clean, one-command stack. Next: b8 full CD + capstone.