Course 6 · Capstone

Lab 04 — The Telemetry Firehose

Your marketplace doesn't just rent GPUs — it watches them. Every active rental emits a constant stream of metrics: how hard the card is working, how hot it's running, how much power it's drawing. That's the telemetry firehose, and it's the highest-volume data in the whole platform. This lab teaches you the single most important pattern for taming it: land it raw, then roll it up — so the warehouse only ever touches small, pre-aggregated facts.

Setup — do this first

Make sure the Compose stack from Lab 01 is up — you need Redpanda running (docker compose up -d redpanda; check with docker compose ps). You also need the generator from Lab 01 and the DuckDB warehouse from Labs 02–03 (warehouse.duckdb with gold.dim_gpu already populated). Work from your mini-griddp repo root, and have your Python env active (uv). We'll use the confluent-kafka client, which talks to Redpanda unchanged: uv add confluent-kafka duckdb.

The goal

So far your data has been batchy: rentals and customers extracted from Postgres, modeled into a star schema. Telemetry is different. A single GPU under load emits a metric reading every few seconds — and the reference marketplace has thousands of cards running at once. If you tried to load every raw reading into the warehouse and let the BI tool query it, you'd melt your laptop and your bill. The pattern that saves you is the dual path:

PRODUCE STREAM LAND (bronze) ROLL UP (gold) ┌────────────┐ ┌────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ generator │ │ Redpanda │ │ bronze.telemetry │ │ fct_telemetry_hourly│ │ per active │──────▶│ topic: │───────▶│ raw readings │───▶│ avg util, max temp, │ │ rental │ emit │ telemetry │ consume│ (cheap, aside) │ agg│ sample count / hour │ │ every ~5s │ │ │ │ NEVER queried │ │ joins dim_gpu │ └────────────┘ └────────────┘ └──────────────────┘ └──────────┬──────────┘ │ ───────────────────── dual path: raw stays cheap and to the side ────────── │ only the small rollup joins the warehouse ────────────▶ BI / metrics

By the end of this lab you'll produce a live stream of per-GPU metrics, consume and batch-land them into a bronze telemetry table, and build fct_telemetry_hourly — a gold fact with one row per GPU per hour (average utilization, peak temperature, sample count), joined to dim_gpu. The dashboard in Lab 08 will read that fact, never the raw firehose.

The one idea to remember

High-volume telemetry is landed once, cheaply, in a place the warehouse never touches. Everything downstream consumes rollups — pre-aggregated summaries. This is the same shape used at full scale (Systems Design ch. 05 storage and ch. 14 scale); you're building the small version of exactly that.

Produce the telemetry stream

A telemetry event is small and regular. We'll emit one JSON message per active rental every few seconds, with this shape:

FieldTypeMeaning
gpu_idintegerWhich card — the Lab 01 gpu_id, so it joins to dim_gpu
tsISO timestampWhen the reading was taken
util_pctfloat 0–100How busy the GPU is
temp_cfloatCore temperature, Celsius
power_wfloatPower draw, watts

Create a small producer beside the generator. It reads the set of currently-active GPUs (here we simulate a fixed fleet: one card per catalog GPU from Lab 01) and pushes a reading for each, on a loop. Notice we key each message by gpu_id — that keeps all of one card's readings on the same partition, which matters once you scale to multiple consumers.

generator/telemetry_producer.py
import json, random, time
from datetime import datetime, timezone
from confluent_kafka import Producer

TOPIC = "telemetry"
BROKER = "localhost:19092"   # Redpanda's external listener from the Lab 01 compose

# A small simulated fleet: one active card per catalog GPU, keyed by the
# Lab 01 gpu_id (1-10) so readings join dim_gpu. In the real platform this
# list would come from the rentals table; for the lab a fixed set is plenty.
ACTIVE_GPUS = list(range(1, 11))   # 10 cards


def reading(gpu_id: int) -> dict:
    """One realistic-ish telemetry sample for a GPU under load."""
    util = random.gauss(78, 12)                 # mostly busy, some idle dips
    util = max(0.0, min(100.0, util))
    return {
        "gpu_id": gpu_id,
        "ts": datetime.now(timezone.utc).isoformat(),
        "util_pct": round(util, 1),
        "temp_c": round(45 + util * 0.45 + random.gauss(0, 2), 1),   # hotter when busier
        "power_w": round(120 + util * 2.6 + random.gauss(0, 8), 1),  # more draw when busier
    }


def main(interval_s: float = 5.0):
    producer = Producer({"bootstrap.servers": BROKER})
    print(f"Producing telemetry for {len(ACTIVE_GPUS)} GPUs every {interval_s}s → topic '{TOPIC}'. Ctrl-C to stop.")
    sent = 0
    try:
        while True:
            for gpu_id in ACTIVE_GPUS:
                event = reading(gpu_id)
                producer.produce(TOPIC, key=str(gpu_id), value=json.dumps(event))
                sent += 1
            producer.flush()             # push this batch to Redpanda
            print(f"  sent {len(ACTIVE_GPUS)} readings (total {sent})")
            time.sleep(interval_s)
    except KeyboardInterrupt:
        producer.flush()
        print(f"\nStopped. {sent} readings produced.")


if __name__ == "__main__":
    main()
Auto-creating the topic

Redpanda creates the telemetry topic automatically on first produce (auto-topic-creation is on by default). If you'd rather be explicit, create it first: docker compose exec redpanda rpk topic create telemetry -p 3 — that gives it 3 partitions, so gpu_id keys spread across them.

Consume & land to bronze

The consumer is the other half of the stream path. It subscribes to the topic, pulls messages in batches, and writes them into a bronze.telemetry table in DuckDB. Two things make this production-shaped:

  • Batch the writes. Inserting one row per message is slow. We buffer a few hundred readings, then do a single bulk insert — far fewer, fatter writes.
  • Land it raw and untouched. Bronze keeps the data exactly as it arrived (plus a load timestamp). No cleaning, no joins — that's the next stage's job. Raw stays cheap and to the side.
ingest/consume_stream.py
import json
import sys
import duckdb
from confluent_kafka import Consumer

TOPIC = "telemetry"
BROKER = "localhost:19092"
DB = "warehouse.duckdb"
BATCH = 200          # land in chunks of this many readings
DRAIN = "--drain" in sys.argv   # land what is waiting, then exit once the topic is idle
IDLE_POLLS_TO_EXIT = 5          # ...idle meaning this many empty 1s polls in a row

DDL = """
CREATE SCHEMA IF NOT EXISTS bronze;
CREATE TABLE IF NOT EXISTS bronze.telemetry (
    gpu_id     INTEGER,
    ts         TIMESTAMPTZ,                 -- an instant, not a wall-clock reading
    util_pct   DOUBLE,
    temp_c     DOUBLE,
    power_w    DOUBLE,
    loaded_at  TIMESTAMPTZ DEFAULT now()    -- when the row landed (Lab 06 reads this)
);
"""


def flush(con, rows):
    """Bulk-insert a buffer of readings into bronze, then clear it."""
    if not rows:
        return
    con.executemany(
        "INSERT INTO bronze.telemetry (gpu_id, ts, util_pct, temp_c, power_w) "
        "VALUES (?, ?, ?, ?, ?)",
        rows,
    )
    print(f"  landed {len(rows)} readings → bronze.telemetry")
    rows.clear()


def main():
    con = duckdb.connect(DB)
    con.execute(DDL)

    consumer = Consumer({
        "bootstrap.servers": BROKER,
        "group.id": "telemetry-lander",
        "auto.offset.reset": "earliest",   # read from the start the first time
    })
    consumer.subscribe([TOPIC])
    print(f"Consuming '{TOPIC}' → {DB} (batch={BATCH}). Ctrl-C to stop.")

    buffer = []
    idle_polls = 0
    try:
        while True:
            msg = consumer.poll(1.0)        # wait up to 1s for a message
            if msg is None:
                flush(con, buffer)          # idle → flush whatever we have
                idle_polls += 1
                if DRAIN and idle_polls >= IDLE_POLLS_TO_EXIT:
                    break                   # drained: nothing new for a few seconds
                continue
            idle_polls = 0
            if msg.error():
                print("  consumer error:", msg.error())
                continue
            e = json.loads(msg.value())
            buffer.append((e["gpu_id"], e["ts"], e["util_pct"], e["temp_c"], e["power_w"]))
            if len(buffer) >= BATCH:
                flush(con, buffer)
    except KeyboardInterrupt:
        pass
    finally:
        flush(con, buffer)
        total = con.execute("SELECT count(*) FROM bronze.telemetry").fetchone()[0]
        print(f"\nStopped. bronze.telemetry now holds {total} readings.")
        consumer.close()
        con.close()


if __name__ == "__main__":
    main()
Why a consumer group?

The group.id makes Redpanda remember how far this consumer has read (its offset). Stop it and restart, and it resumes where it left off instead of re-landing everything — that's how streaming ingestion stays idempotent without you tracking position by hand.

Two ways to stop

Run it bare and it listens until you press Ctrl-C: that is how you'll use it in this lab. Run it with --drain and it lands everything waiting in the topic, then exits on its own once nothing new has arrived for five seconds. That is the mode an orchestrator needs, and Lab 05 will call it that way.

Roll up to an hourly fact

Here's the payoff. Bronze now holds hundreds of tiny raw readings (thousands if you let it run) — and we are never going to query them from the warehouse. Instead we collapse them into one row per GPU per hour. That's a 700-to-1 reduction (a card sampled every 5s = 720 readings/hour → 1 rollup row), and it's the only telemetry the rest of the platform ever sees.

Add this as a dbt model in your gold marts (the dbt project from Lab 03). It buckets readings into hourly windows, aggregates them, and joins dim_gpu so business attributes ride along:

dbt/models/marts/fct_telemetry_hourly.sql
{{ config(materialized='table') }}

with readings as (
    select
        gpu_id,
        date_trunc('hour', ts) as hour,
        util_pct,
        temp_c,
        power_w
    from {{ source('bronze', 'telemetry') }}
),

rolled as (
    select
        gpu_id,
        hour,
        round(avg(util_pct), 1)  as avg_util_pct,
        max(temp_c)              as max_temp_c,
        round(avg(power_w), 1)   as avg_power_w,
        count(*)                 as sample_count
    from readings
    group by gpu_id, hour
)

select
    r.gpu_id,
    r.hour,
    g.model,
    r.avg_util_pct,
    r.max_temp_c,
    r.avg_power_w,
    r.sample_count
from rolled r
left join {{ ref('dim_gpu') }} g on r.gpu_id = g.gpu_id

You'll need to register the new bronze table so source('bronze', 'telemetry') resolves. Add one line to the Lab 03 sources file (a second file declaring a source called bronze would make dbt stop with a duplicate-source error):

dbt/models/staging/_sources.yml (add one table)
    tables:
      - name: customers
      - name: gpus
      - name: rentals
      - name: gpu_prices
      - name: telemetry     # landed by the Lab 04 stream consumer

Prefer raw SQL while you're learning the shape? The same rollup, runnable directly against DuckDB, looks like this — useful for sanity-checking before the dbt run:

standalone SQL (DuckDB)
CREATE SCHEMA IF NOT EXISTS gold;
CREATE OR REPLACE TABLE gold.fct_telemetry_hourly AS
SELECT
    t.gpu_id,
    date_trunc('hour', t.ts)        AS hour,
    g.model,
    round(avg(t.util_pct), 1)       AS avg_util_pct,
    max(t.temp_c)                   AS max_temp_c,
    round(avg(t.power_w), 1)        AS avg_power_w,
    count(*)                        AS sample_count
FROM bronze.telemetry t
LEFT JOIN gold.dim_gpu g ON t.gpu_id = g.gpu_id
GROUP BY t.gpu_id, date_trunc('hour', t.ts), g.model;
The dual path, stated plainly

Raw telemetry lives in bronze: high-volume, append-only, never joined to dimensions, never hit by a dashboard. Only the rollup — orders of magnitude smaller — enters the modeled warehouse and joins business data. Keeping the firehose to one side and serving aggregates is the core scale move from Systems Design ch. 05 and ch. 14. Get this pattern into your bones; you'll reach for it on every high-volume source you ever meet.

Run it end to end

You'll run the producer and consumer at the same time, in two terminals. Start the consumer first so it's listening, then the producer, and let them run for a few minutes to accumulate a meaningful sample.

terminal 1 — land the stream
cd mini-griddp
uv run python ingest/consume_stream.py
# leave this running; it prints "landed N readings" as batches arrive
terminal 2 — produce the firehose
cd mini-griddp
uv run python generator/telemetry_producer.py
# prints "sent 10 readings (total N)" every 5 seconds — let it run ~3–5 min
✓ You should see

Terminal 2 ticking out sent 10 readings every five seconds, and terminal 1 answering with landed 200 readings → bronze.telemetry as each batch fills (every 100 seconds) and a smaller landed line whenever the stream goes quiet for a second. After a few minutes, Ctrl-C both (consumer prints its final total). Five minutes gives you about 600 readings.

Now build the rollup. With dbt:

terminal — build the rollup
cd mini-griddp/dbt
uv run dbt run --select fct_telemetry_hourly

Then inspect what you built:

terminal — inspect
duckdb ../warehouse.duckdb -c "
  SELECT count(*) AS raw_readings FROM bronze.telemetry;
  SELECT count(*) AS rollup_rows  FROM gold.fct_telemetry_hourly;
  SELECT gpu_id, hour, model, avg_util_pct, max_temp_c, sample_count
  FROM gold.fct_telemetry_hourly
  ORDER BY hour, gpu_id LIMIT 8;"
✓ You should see

raw_readings in the hundreds, but rollup_rows only 10 (one per GPU for the single hour you ran; 20 if your run straddled the top of an hour). The sample rows show avg_util_pct near 78, max_temp_c in the high 80s, and a sample_count of a few dozen per GPU — and each row carries model from the join. That collapse from hundreds of rows to 10 is the win: the warehouse now answers "how hot did each card run this hour?" without ever touching the firehose.

What this looks like at real scale

The real firehose

You ran 10 GPUs sampled every 5 seconds for a few minutes. The Systems Design reference marketplace runs thousands of GPUs sampled every second — on the order of 150 million telemetry samples per day. At that volume, DuckDB on a laptop is the wrong tool: raw telemetry lands in a columnar engine built for ingest and aggregation at scale (the reference uses ClickHouse), and the hourly rollup is itself rolled up into a daily one, which may feed a monthly — a rollup cascade. Each tier is read far more often than it's written, so paying the aggregation cost once and reading the small result many times is what keeps queries fast and costs sane.

But the shape is identical to what you just built: land raw to the side, aggregate into time-bucketed facts, serve only the rollups. You've learned the pattern; production just swaps the engines and adds tiers. See Systems Design ch. 14 for the full-scale version.

Commit Lab 04

You've added the streaming path and the firehose rollup to the platform. Save it.

terminal
cd mini-griddp
git add generator/telemetry_producer.py ingest/consume_stream.py \
        dbt/models/marts/fct_telemetry_hourly.sql dbt/models/staging/_sources.yml
git commit -m "Lab 04: telemetry firehose — produce, land to bronze, roll up to fct_telemetry_hourly"
Don't commit the data

Make sure warehouse.duckdb is in your .gitignore — the warehouse is a build artifact, regenerated by running the pipeline, not something to version. Your commit history should tell the story of the code that produces the data.

✓ Check yourself

Answer from memory before opening each one.

  1. An analyst asks for a Metabase chart straight off bronze.telemetry so they can see every reading. What breaks first as the fleet grows, and what do you offer instead?

    Show answer

    Every dashboard load would scan the raw firehose — 720 readings per GPU per hour at a 5-second interval, on the order of 150 million a day at the reference scale — so query time and cost grow with the fleet. Raw stays landed to one side; dashboards read fct_telemetry_hourly, where those 720 readings are one row. See the dual path and the rollup.

  2. You run the producer (10 GPUs, one reading each every 5 seconds) for four minutes, all inside one clock hour. Roughly how many rows should bronze.telemetry and fct_telemetry_hourly hold — and what changes if the four minutes straddle the top of the hour?

    Show answer

    About 10 × 12 × 4 = 480 raw readings and 10 rollup rows, each with a sample_count near 48. Straddle the hour and you get 20 rollup rows: date_trunc('hour', ts) buckets by clock hour, not by elapsed time, so each GPU's samples split across two rows. See Roll up to an hourly fact and Run it end to end.

  3. Every message is produced with key=gpu_id. With a 3-partition topic and three consumers, what does that key guarantee — and what does it not?

    Show answer

    The same key always goes to the same partition, so all of one card's readings stay in order and are handled by a single consumer. It does not give each GPU a partition of its own: ten keys are hashed across three partitions, so every partition carries several cards. See Produce the telemetry stream.

  4. You rename the consumer's group.id from telemetry-lander to telemetry-lander-v2 and restart it. What lands in bronze.telemetry?

    Show answer

    Everything still held in the topic, all over again. Redpanda remembers the read position per group; a new group has none, and auto.offset.reset: earliest starts it from the beginning, so each retained reading is inserted a second time and every sample_count doubles at the next rollup. The group id is the thing that lets a restart resume. See Why a consumer group?

  5. For one GPU, hour A has 700 samples averaging 90% utilization and hour B has 20 samples averaging 10%. A daily rollup built on the hourly fact takes avg(avg_util_pct). What does it report, what is the true figure, and which hourly column rescues you?

    Show answer

    It reports 50%; the true mean is (700 × 90 + 20 × 10) ÷ 720 ≈ 87.8%. An average of averages gives the 20-sample hour the same weight as the 700-sample one, so weight each hour by sample_count — which is why the rollup keeps it. max(max_temp_c) needs no such care: maxima compose, averages do not. See the rollup's columns and the exercise below.

  6. Ops wants to know whenever a card runs above 90 °C for more than two minutes. How far does the hourly rollup get them, and what decides whether you build something finer?

    Show answer

    max_temp_c shows that a card peaked above 90 at some point in the hour — not when, nor for how long — and because the rollup is built in a batch, it arrives too late to act on. If the real question is a trend ("which cards run hot?"), hourly is enough and the cheapest thing to serve. If duration or a fast reaction matters, you need a finer bucket — a per-minute rollup, or a check on the stream itself — at the price of 60× the rows. Same pattern, different grain. See the rollup and real scale.

Exercise — Add a daily rollup on top of the hourly one (a rollup cascade)

Real platforms don't stop at one tier — they cascade. Build fct_telemetry_daily that aggregates the hourly fact (not the raw bronze table) into one row per GPU per day: average utilization across the day, the day's peak temperature, and the total sample count. Reading from the hourly rollup instead of raw bronze is the cascade — each tier feeds the next, and raw stays untouched.

dbt/models/marts/fct_telemetry_daily.sql
{{ config(materialized='table') }}

select
    gpu_id,
    model,
    cast(hour as date)                          as day,
    -- weight each hour's average by its sample_count for a true daily mean
    round(sum(avg_util_pct * sample_count)
          / nullif(sum(sample_count), 0), 1)    as avg_util_pct,
    max(max_temp_c)                             as max_temp_c,
    sum(sample_count)                           as sample_count
from {{ ref('fct_telemetry_hourly') }}
group by gpu_id, model, cast(hour as date)
terminal
cd mini-griddp/dbt
uv run dbt run --select fct_telemetry_daily
duckdb ../warehouse.duckdb -c "SELECT * FROM gold.fct_telemetry_daily ORDER BY gpu_id LIMIT 5;"

You should see one row per GPU per day, with even fewer rows than the hourly fact. Two things to notice: (1) we read from ref('fct_telemetry_hourly'), never from bronze — that's the cascade, and dbt now knows daily depends on hourly. (2) Averaging the hourly averages naïvely would be wrong if hours had different sample counts, so we weight by sample_count to recover the true daily mean. That's the subtle trap in every rollup cascade — aggregating an aggregate needs care about what you're averaging.

Takeaway

  • High-volume telemetry is landed raw to the side, never joined to dimensions and never queried by a dashboard, and only the rollup enters the modeled warehouse.
  • The rollup is one row per GPU per hour, which cuts the volume by orders of magnitude and keeps business joins cheap.
  • Key each message by gpu_id so one card's readings stay on one partition and in order, which matters once you add consumers.
  • The consumer buffers readings and writes them in batches, because one insert per message is slow.
  • A consumer group id makes Redpanda remember the consumer's offset, so a restart resumes where it stopped instead of landing everything again.
  • The rollup row count tracks the hours you ran: ten GPUs over one hour give ten rows, and twenty if the run crossed the top of an hour.
  • The production shape is the same as the lab, land raw, aggregate into time buckets, serve the rollups, and only the engines and tiers change.

You now have ingest, models and a rollup, all run by hand, so the next lab makes the platform run itself as a scheduled graph. → Lab 05 — Orchestrate with Dagster

Further reading