A Window Into Her Vitals
Everything that happens between two samples
Chapter 36 taught her to write down how she was doing. Once a turn, a snapshot of CPU, memory and the latest per-stage timings goes into a bounded log on disk, and a week later you can ask which evening was slow. That layer has one honest limit, and its own closing pages named it: a sample says nothing about the interval around it. Between two snapshots, whatever happened is gone.
The gap used to be cheap. It is not cheap any more, because she has a body now. A turn is no longer transcribe, think, speak: it is those three plus a dispatcher draining a queue of hardware actions, a mood that moves, servos that answer slower when they are warm. All of that lives inside the interval. Worse, the timing dict the snapshot copies from keeps only the most recent duration per stage, so a turn where the model stalled for six seconds leaves no trace at all if a faster turn overwrote it before the next snapshot landed. You end up with a log full of numbers that were true at the instant they were taken and silent about the minute in between.
So this chapter's rule: every measurement is recorded in memory at the instant it happens, and the collector can be asked for the current state at any instant after that. Three kinds of measurement, three places for them to land: recent durations go into a fixed-size window, totals go into counters that only climb, and current values go into gauges that only ever hold now. That collector is the live view. Chapter 36's file is still where history goes; this is the pulse you can watch while she runs.
Counter, gauge, and a windowed distribution of durations are not a house invention. They are the metric types Prometheus, StatsD and OpenTelemetry all expose, under those names, for the same reason: they are the three questions you can ask about a running system. How many times has this happened since start, what is this value right now, and how long does that step usually take. Build the small version by hand here and the big tools stop being magic later; you will recognise every one of their primitives.
A window, two dictionaries, one snapshot
# labs/metrics_collector.py
from collections import deque
WINDOW = 120
class MetricsCollector:
def __init__(self, window: int = WINDOW) -> None:
self._window: deque[tuple[str, float]] = deque(maxlen=window)
def record_latency(self, stage: str, ms: float) -> None:
self._window.append((stage, ms))
def avg_latency(self, stage: str) -> float:
readings = [ms for name, ms in self._window if name == stage]
return sum(readings) / len(readings) if readings else 0.0
if __name__ == "__main__":
m = MetricsCollector()
for ms in (410.2, 389.0, 502.4):
m.record_latency("stt", ms)
print(f"stt avg {m.avg_latency('stt'):7.1f} ms")
print(f"arm avg {m.avg_latency('arm'):7.1f} ms")
$ uv run python labs/metrics_collector.py
stt avg 433.9 ms
arm avg 0.0 ms
deque(maxlen=120) is the whole retention policy. Append a 121st reading
and the oldest one falls off the left end without a word, so the collector never
holds more than 120 tuples no matter how long she runs, and nothing anywhere has to
remember to prune. One window holds every stage mixed together, so
avg_latency filters by name before it divides. The
if readings else 0.0 tail looks like defensive clutter and is the most
important clause in the file; the failure box shows what the file does without it.
arm reads 0.0 here because nothing has moved yet.
def __init__(self, window: int = WINDOW) -> None:
self._window: deque[tuple[str, float]] = deque(maxlen=window)
self._counters: dict[str, int] = {}
self._gauges: dict[str, float] = {}
def increment(self, name: str, amount: int = 1) -> None:
self._counters[name] = self._counters.get(name, 0) + amount
def gauge(self, name: str, value: float) -> None:
self._gauges[name] = value
def get_counter(self, name: str) -> int:
return self._counters.get(name, 0)
def get_gauge(self, name: str) -> float:
return self._gauges.get(name, 0.0)
if __name__ == "__main__":
m = MetricsCollector()
m.increment("turns")
m.increment("turns")
m.gauge("cpu_pct", 31.5)
m.gauge("cpu_pct", 38.2)
print(f"turns {m.get_counter('turns')}")
print(f"stalls {m.get_counter('stalls')}")
print(f"cpu {m.get_gauge('cpu_pct')}")
$ uv run python labs/metrics_collector.py
turns 2
stalls 0
cpu 38.2
Two calls, two different meanings, and the code makes the difference structural.
increment reads the running total with .get(name, 0) and
adds to it, so a counter accumulates and never goes backwards.
gauge assigns, so the second CPU reading replaces the first: a gauge has
no past, only a present. Keeping them in separate dictionaries with separate methods
means no caller can accidentally add five to a temperature or overwrite a turn count.
Note stalls, a counter nobody has touched, answering 0 instead of
raising KeyError. A metric that has not happened yet has a correct value,
and it is zero.
import json
import time
STAGES = ("stt", "llm", "tts", "arm")
def snapshot(self) -> dict:
return {
"timestamp": time.time(),
"window": len(self._window),
"counters": dict(self._counters),
"gauges": dict(self._gauges),
"avg_ms": {s: round(self.avg_latency(s), 1) for s in STAGES},
}
if __name__ == "__main__":
m = MetricsCollector()
for stage, readings in (("stt", (410.2, 389.0, 502.4)),
("llm", (1204.6, 1043.2, 1293.5)),
("tts", (251.9, 276.1, 277.3))):
for ms in readings:
m.record_latency(stage, ms)
m.increment("turns", 3)
m.gauge("cpu_pct", 31.5)
m.gauge("ram_pct", 34.2)
print(json.dumps(m.snapshot(), indent=2))
$ uv run python labs/metrics_collector.py # bench numbers; yours will differ
{
"timestamp": 1787694412.518,
"window": 9,
"counters": {
"turns": 3
},
"gauges": {
"cpu_pct": 31.5,
"ram_pct": 34.2
},
"avg_ms": {
"stt": 433.9,
"llm": 1180.4,
"tts": 268.4,
"arm": 0.0
}
}
Two decisions carry this method. dict(self._counters) copies instead of
handing out the live dictionary, so a snapshot taken at 21:04 still says what it said
at 21:04 after another forty turns have gone by; a caller who kept it is holding a
frozen moment, not a moving reference. And avg_ms is computed over a
fixed STAGES tuple, not over whatever names happen to be in the window,
so a stage that stopped reporting shows up as a row of zeros instead of silently
vanishing from the display. The timestamp is time.time(), the same Unix
float chapter 29's sensor log and chapter 36's metrics log both stamp their entries
with, so this dict drops into either store without translation.
# labs/metrics_collector.py — full file
import random
import time
from collections import deque
from contextlib import contextmanager
from pathlib import Path
import psutil
from labs.rolling_log import append_bounded
METRICS_LOG = Path("glados/data/metrics.json")
STAGES = ("stt", "llm", "tts", "arm")
WINDOW = 120
class MetricsCollector:
def __init__(self, window: int = WINDOW) -> None:
self._window: deque[tuple[str, float]] = deque(maxlen=window)
self._counters: dict[str, int] = {}
self._gauges: dict[str, float] = {}
def record_latency(self, stage: str, ms: float) -> None:
self._window.append((stage, ms))
def increment(self, name: str, amount: int = 1) -> None:
self._counters[name] = self._counters.get(name, 0) + amount
def gauge(self, name: str, value: float) -> None:
self._gauges[name] = value
def get_counter(self, name: str) -> int:
return self._counters.get(name, 0)
def get_gauge(self, name: str) -> float:
return self._gauges.get(name, 0.0)
def avg_latency(self, stage: str) -> float:
readings = [ms for name, ms in self._window if name == stage]
return sum(readings) / len(readings) if readings else 0.0
@contextmanager
def measure(self, stage: str):
start = time.perf_counter()
try:
yield
finally:
self.record_latency(stage, (time.perf_counter() - start) * 1000)
def snapshot(self) -> dict:
return {
"timestamp": time.time(),
"window": len(self._window),
"counters": dict(self._counters),
"gauges": dict(self._gauges),
"avg_ms": {s: round(self.avg_latency(s), 1) for s in STAGES},
}
def live_line(snap: dict) -> str:
avg = snap["avg_ms"]
return (f"turns {snap['counters'].get('turns', 0):3d}"
f" stt {avg['stt']:7.1f} llm {avg['llm']:7.1f} tts {avg['tts']:7.1f}"
f" cpu {snap['gauges'].get('cpu_pct', 0.0):5.1f}%"
f" window {snap['window']:3d}")
def stub_turn(m: MetricsCollector) -> None:
with m.measure("stt"):
time.sleep(random.uniform(0.38, 0.52))
with m.measure("llm"):
time.sleep(random.uniform(1.02, 1.31))
with m.measure("tts"):
time.sleep(random.uniform(0.24, 0.29))
m.increment("turns")
m.gauge("cpu_pct", psutil.cpu_percent())
m.gauge("ram_pct", psutil.virtual_memory().percent)
def main() -> None:
psutil.cpu_percent() # prime the counter, as chapter 36 insisted
m = MetricsCollector()
for _ in range(3):
stub_turn(m)
print(live_line(m.snapshot()))
stored = append_bounded(m.snapshot(), METRICS_LOG)
print(f"snapshot appended to {METRICS_LOG} ({stored} entries)")
if __name__ == "__main__":
main()
$ uv run python labs/metrics_collector.py # bench numbers; yours will differ
turns 1 stt 410.2 llm 1204.6 tts 251.9 cpu 31.5% window 3
turns 2 stt 399.6 llm 1123.9 tts 264.0 cpu 38.2% window 6
turns 3 stt 433.9 llm 1180.4 tts 268.4 cpu 29.7% window 9
$ uv run python labs/metrics_collector.py # tail of the same run
snapshot appended to glados/data/metrics.json (42 entries)
measure is the whole instrumentation story. The
@contextmanager decorator turns a generator into something usable with
with: everything before yield runs on entry, everything
after runs on exit, and the body of the with block runs at the
yield. The finally is deliberate. A stage that raises still
records its duration on the way out, so the turn where the model timed out after
eleven seconds shows up in the window instead of being the one measurement you lose.
Compare the averages down the three lines and you can watch the window fill: line one
averages one reading per stage, line three averages three.
Inside glados/core.py, wiring this into the real loop is one more
constructor argument, three with blocks and two calls. The core takes
metrics alongside its three collaborators and keeps it on
self. Then wrap the existing self.stt(audio_path) call in
with self.metrics.measure("stt"):, do the same around
self.llm(text, self.history) and self.tts(reply), wrap
whatever call moves the arm in measure("arm") so the fourth row stops
reading zero, then increment("turns") and one gauge for CPU
at the end. The providers stay untouched. Nothing behind the contracts from chapter
44 knows it is being timed. Whether self.stt holds a bound
WhisperSTT.transcribe or a two-line fake, the timing code is identical,
because the core only ever knew it as something you call.
Why this works: a ring buffer, and what an average forgets
A deque is a doubly linked list of fixed-size blocks, and setting
maxlen turns it into a ring buffer: appending is constant time, and so is
the eviction that comes with it, because dropping the leftmost item is a pointer move
and no memory is copied. The obvious alternative costs more than it looks.
readings.append(x) followed by readings = readings[-120:]
builds a brand new 120-element list on every single append, forever, and worse, the cap
now lives at the call site where somebody will eventually forget it. Put the limit in
the container and overflow becomes structurally impossible; put it in a habit and it
holds until the day you are tired.
The cost sits on the read side. avg_latency walks all 120 entries filtering
by name, so each call is linear in the window, which is microseconds here and a bad idea
inside a loop that runs per audio frame. Reads are rare and writes are hot, so the
collector is built the right way round: appends are cheap and unconditional,
arithmetic happens only when a human or a dashboard asks.
Now the part that decides how much you should trust the number. An average over 120 readings is a trend line, and trend lines are calm by construction. Suppose her thinking stage usually takes 1,200 ms and one turn stalls at 6,000. That reading replaces a typical one in the window, so the average moves by (6000 - 1200) / 120, which is 40 ms: a rise from 1,200 to 1,240, about three percent, invisible on any display. The stall that made someone give up waiting does not register in the mean, and no amount of staring at that column will find it. What the window does give you is the ability to ask a better question, because all 120 readings are still there. Sort them and take the value 95 percent of the way up and the stall is impossible to miss. That is exercise 1, and it is the metric on-call engineers actually watch.
Write avg_latency as the obvious one-liner, without the empty guard,
and every test you run by hand will pass. Then add a startup banner that prints the
live line before the first turn:
def avg_latency(self, stage: str) -> float:
readings = [ms for name, ms in self._window if name == stage]
return sum(readings) / len(readings) # BUG: no empty guard
m = MetricsCollector()
print(live_line(m.snapshot())) # startup banner
$ uv run python labs/metrics_collector.py
Traceback (most recent call last):
File "labs/metrics_collector.py", line 68, in <module>
print(live_line(m.snapshot())) # startup banner
^^^^^^^^^^^^
File "labs/metrics_collector.py", line 52, in snapshot
"avg_ms": {s: round(self.avg_latency(s), 1) for s in STAGES},
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "labs/metrics_collector.py", line 52, in <dictcomp>
"avg_ms": {s: round(self.avg_latency(s), 1) for s in STAGES},
^^^^^^^^^^^^^^^^^^^^
File "labs/metrics_collector.py", line 41, in avg_latency
return sum(readings) / len(readings) # BUG: no empty guard
~~~~~~~~~~~~~~^~~~~~~~~~~~~~~
ZeroDivisionError: division by zero
Read the traceback bottom up and the cause is arithmetic:
sum([]) / len([]) is 0 / 0. Read it top down and the cause is timing.
The collector was built one microsecond ago, the window is empty, so every one of the
four stages divides by zero, and the observability layer takes the assistant down
before she has heard a word. This is the failure mode of monitoring code generally:
it runs in the states nobody rehearses, at boot, after a restart, on the machine
where the arm was never plugged in. Any average over a filtered collection needs an
answer for the empty case, and here the honest answer is 0.0, printed in a row you
can see, telling you that stage has no readings yet.
Checkpoint, and a pulse you can watch
- I can say what
deque(maxlen=120)does with the 121st append, and why that is cheaper than appending to a list and reslicing it. - I can decide, for any new measurement, whether it is a counter, a gauge or a latency reading, and defend the choice in one sentence.
- I can compute how far a single six-second stall moves a 120-reading average, and explain why the mean is the wrong place to look for it.
- I can time any block of code without editing the code inside it, and say why the
finallyinmeasureis not optional. - Handed a monitoring layer that crashes at startup, I look first at every division whose denominator is a count of things that have not happened yet.
- I know why
snapshotcopies its dictionaries, and what a caller who stored one would otherwise see an hour later.
Exercise 1 — the number that catches the stall. Add
p95_latency(stage), the reading 95 percent of the way up the sorted
window, with the same empty-safe behaviour. Record 1.0 through 100.0 ms for one
stage, then a single 6000.0, and print the average beside the p95.
Sort the filtered readings, then index at 95 percent of the length, clamped so small windows cannot run off the end:
def p95_latency(self, stage: str) -> float:
readings = sorted(ms for name, ms in self._window if name == stage)
if not readings:
return 0.0
idx = min(int(len(readings) * 0.95), len(readings) - 1)
return readings[idx]
With 1 through 100 recorded, p95 answers 96.0. Add the 6000.0 and the average
moves from 50.5 to 109.0 while the p95 jumps to 6000.0, because the outlier is
now the top of the sorted list. The clamp with len(readings) - 1 is
what keeps int(len * 0.95) from indexing past the end on a window
holding one or two readings.
Exercise 2 — an alarm that reads the live view. Write
over_budget(snap, limits) returning one human-readable string per
stage whose average exceeds its limit in limits, then run it against a
snapshot with a deliberately low limit so you see the alarm fire.
Ten lines, and no new machinery: for stage, limit in limits.items(),
read snap["avg_ms"].get(stage, 0.0), and append
f"{stage} averaging {avg:.0f} ms, budget {limit} ms" when it is
higher. With {"llm": 900, "tts": 400} against the stage-3 snapshot
you get exactly one line, naming llm at 1180 ms. The empty stages
report 0.0 and stay quiet, which is the guard from this chapter paying for itself
a second time. Hand that list to the recovery callback the watchdog already owns
and slow becomes something she reports instead of something you notice.
Exercise 3 — speak the industry's dialect. Write
export_prometheus(snap) that prints the snapshot in Prometheus text
exposition format: counters with a _total suffix, gauges plain, and
latencies carrying a stage label.
One loop per section of the snapshot, joined with newlines:
glados_turns_total 3
glados_cpu_pct 31.5
glados_ram_pct 34.2
glados_latency_ms{stage="stt"} 433.9
glados_latency_ms{stage="llm"} 1180.4
The label syntax is f'glados_latency_ms{{stage="{s}"}} {v}', doubled
braces because f-strings eat single ones. Serve that text on a small HTTP handler
and a Prometheus running on your own network can scrape her every fifteen
seconds, with Grafana drawing the graph. Nothing about the collector changes: it
already produces the three types those tools expect, so the export is a
formatting function and nothing more.
She can now be asked how she is doing and answer with numbers that are seconds old. The window forgets on purpose, and that is right for measurements. It is wrong for the things you learn while reading them: the setting that fixed the stall, the servo that runs hot after twenty minutes, the model that was slower than its reputation. Those belong somewhere permanent and searchable, and building that archive is how this volume ends.