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.
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:
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.
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:
| Field | Type | Meaning |
|---|---|---|
gpu_id | string | Which card — joins to dim_gpu |
ts | ISO timestamp | When the reading was taken |
util_pct | float 0–100 | How busy the GPU is |
temp_c | float | Core temperature, Celsius |
power_w | float | Power draw, watts |
Create a small producer beside the generator. It reads the set of currently-active GPUs (here we just simulate a fixed fleet for the lab) 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.
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 of active rentals. In the real platform this list
# would come from the rentals table; for the lab a fixed set is plenty.
ACTIVE_GPUS = [f"gpu-{i:03d}" for i in range(1, 21)] # 20 cards
def reading(gpu_id: str) -> 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=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()
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.
import json
import duckdb
from confluent_kafka import Consumer
TOPIC = "telemetry"
BROKER = "localhost:19092"
DB = "warehouse.duckdb"
BATCH = 200 # land in chunks of this many readings
DDL = """
CREATE SCHEMA IF NOT EXISTS bronze;
CREATE TABLE IF NOT EXISTS bronze.telemetry (
gpu_id VARCHAR,
ts TIMESTAMP,
util_pct DOUBLE,
temp_c DOUBLE,
power_w DOUBLE,
loaded_at TIMESTAMP DEFAULT now()
);
"""
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 = []
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
continue
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:
flush(con, buffer)
total = con.execute("SELECT count(*) FROM bronze.telemetry").fetchone()[0]
print(f"\nStopped. bronze.telemetry now holds {total} readings.")
finally:
consumer.close()
con.close()
if __name__ == "__main__":
main()
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.
Roll up to an hourly fact
Here's the payoff. Bronze now holds thousands of tiny raw readings — 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:
{{ 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.gpu_model,
g.region,
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 bronze as a dbt source so source('bronze', 'telemetry') resolves. Add it to your sources file:
version: 2
sources:
- name: bronze
schema: bronze
tables:
- name: telemetry
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:
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.gpu_model,
g.region,
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.gpu_model, g.region;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.
cd mini-griddp
uv run python ingest/consume_stream.py
# leave this running; it prints "landed N readings" as batches arrivecd mini-griddp
uv run python generator/telemetry_producer.py
# prints "sent 20 readings (total N)" every 5 seconds — let it run ~3–5 minTerminal 2 ticking out sent 20 readings every few seconds, and terminal 1 answering with landed 200 readings → bronze.telemetry as each batch fills. After a few minutes, Ctrl-C both (consumer prints its final total). You should have a few thousand readings landed.
Now build the rollup. With dbt:
cd mini-griddp/dbt
uv run dbt run --select fct_telemetry_hourlyThen inspect what you built:
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, avg_util_pct, max_temp_c, sample_count
FROM gold.fct_telemetry_hourly
ORDER BY hour, gpu_id LIMIT 8;"raw_readings in the thousands, but rollup_rows only around 20 (one per GPU for the single hour you ran). The sample rows show avg_util_pct near 78, max_temp_c in the 80s, and a sample_count of a few dozen per GPU — and each row carries gpu_model / region from the join. That collapse from thousands of rows to ~20 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
You ran 20 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.
cd mini-griddp
git add generator/telemetry_producer.py ingest/consume_stream.py \
dbt/models/marts/fct_telemetry_hourly.sql dbt/models/marts/_sources.yml
git commit -m "Lab 04: telemetry firehose — produce, land to bronze, roll up to fct_telemetry_hourly"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
- Why do we land telemetry in bronze and roll it up, instead of querying the raw readings directly from the dashboard?
- What does keying each message by
gpu_idbuy you when you scale to multiple consumers and partitions? - Roughly how many raw readings collapse into one
fct_telemetry_hourlyrow, and why is that ratio the whole point? - Which two systems-design chapters does this dual-path pattern map to, and what changes at full 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.
{{ config(materialized='table') }}
select
gpu_id,
gpu_model,
region,
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, gpu_model, region, cast(hour as date)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.
Next
You now have ingest, models, and a firehose rollup — but you're running them by hand. Let's make the whole platform run itself as a scheduled, dependency-aware graph. → Lab 05 — Orchestrate with Dagster