Watching the System Over Time
The turn that was slow yesterday
Chapter 28 bolted a clock onto every stage: @timed writes one DEBUG line
per call saying which function ran and how many milliseconds it took. That answers the
question while you are watching. It cannot answer the question you ask a week later,
because that one is comparative: is her thinking stage slower tonight than it was on
Tuesday, and by how much? A line in a text log is an observation. Comparing observations
needs records, stored in time order, in a form arithmetic can reach.
The timing line is also missing the machine underneath it. A stage that usually finishes in 900 milliseconds and takes four seconds tonight may contain no bug at all: memory crept up until the kernel started swapping, or a backup job took every core, or the laptop dropped to battery. A duration on its own gives you a suspect and no alibi.
So this chapter's rule: a metric is state you write down. Collect a snapshot of CPU, memory and the latest per-stage timings, append it to the same bounded log her sensors use, and render it only when a human asks. Collect, persist, display: three functions, none of which needs to know the other two exist.
Collect, then render
# labs/metrics.py
import time
import psutil
def collect_metrics() -> dict:
mem = psutil.virtual_memory()
return {
"timestamp": time.time(),
"cpu_pct": psutil.cpu_percent(interval=0.1),
"ram_mb": round(mem.used / 1024 ** 2, 1),
"ram_pct": mem.percent,
}
if __name__ == "__main__":
print(collect_metrics())
$ uv add psutil && uv run python labs/metrics.py
{'timestamp': 1787492103.882, 'cpu_pct': 7.4, 'ram_mb': 5218.3, 'ram_pct': 33.1}
Measured on the bench, so your numbers will land elsewhere; the keys are what stays
fixed. Two decisions are already made here. The timestamp is time.time(),
the same Unix float the sensor log stamps its readings with, because the two logs will
be read side by side one day and one sort key beats two. And
virtual_memory() is called once into mem: call it twice and
the used-megabytes figure and the percentage come from two different moments, a small
lie that surfaces later as a snapshot whose own fields disagree.
interval=0.1 is the load-bearing argument in the file, and the failure
box explains what it buys.
# labs/timing.py — two lines added to chapter 28's decorator
STAGE_TIMINGS: dict[str, float] = {}
elapsed = (time.perf_counter() - start) * 1000
STAGE_TIMINGS[func.__name__] = elapsed
logger.debug(f"{func.__name__} completed in {elapsed:.1f}ms")
# labs/metrics.py
from labs.timing import STAGE_TIMINGS
def collect_metrics(turns: int = 0) -> dict:
mem = psutil.virtual_memory()
return {
"timestamp": time.time(),
"cpu_pct": psutil.cpu_percent(interval=0.1),
"ram_mb": round(mem.used / 1024 ** 2, 1),
"ram_pct": mem.percent,
"turns": turns,
"stage_ms": {name: round(ms, 1) for name, ms in STAGE_TIMINGS.items()},
}
$ uv run python labs/metrics.py # after two decorated stubs have run
stage_ms: {'transcribe_stub': 10.2, 'llm_stub': 20.1}
The decorator was already computing this number and throwing it away after printing
it. Now it keeps the most recent duration per stage in a module-level dict, and the
collector copies that dict into the snapshot. The copy matters: writing
"stage_ms": STAGE_TIMINGS stores a reference to a dict that keeps
mutating, so every snapshot in memory would show the last turn's numbers. The
comprehension freezes the values at collection time. Nesting them under
stage_ms also keeps a stage called respond from colliding
with a machine field.
from datetime import datetime
def print_dashboard(m: dict) -> None:
stamp = datetime.fromtimestamp(m["timestamp"]).strftime("%H:%M:%S")
print(f"=== GLaDOS system: {stamp} ===")
print(f" CPU {m['cpu_pct']:5.1f} %")
print(f" RAM {m['ram_mb']:7.1f} MB ({m['ram_pct']:.1f} %)")
print(f" turns {m['turns']}")
for name, ms in sorted(m["stage_ms"].items(), key=lambda kv: -kv[1]):
print(f" {name:<16} {ms:7.1f} ms")
if __name__ == "__main__":
print_dashboard(collect_metrics(turns=2))
$ uv run python labs/metrics.py
=== GLaDOS system: 21:15:41 ===
CPU 7.4 %
RAM 5218.3 MB (33.1 %)
turns 2
llm_stub 20.1 ms
transcribe_stub 10.2 ms
The renderer does three jobs the collector refuses to do. It turns the Unix float into
a clock time a person can match against their memory of the evening. It aligns the
numbers with format specs (:7.1f pads to seven characters,
:<16 left-aligns the name in a sixteen-wide column) so the numbers can
be scanned vertically. And it
sorts the stages slowest first, putting the answer to "what is costing me the most" on
the top line every time. None of that belongs in the snapshot, which stays a plain
dict of numbers for the log and every future consumer.
The log her sensors already built
# labs/rolling_log.py
import json
from pathlib import Path
def append_bounded(entry: dict, log_path: Path,
max_entries: int = 500) -> int:
log_path.parent.mkdir(parents=True, exist_ok=True)
entries = []
if log_path.exists():
with open(log_path) as f:
entries = json.load(f)
entries.append(entry)
entries = entries[-max_entries:]
with open(log_path, "w") as f:
json.dump(entries, f, indent=2)
return len(entries)
# labs/metrics.py
from labs.rolling_log import append_bounded
METRICS_LOG = Path("glados/data/metrics.json")
if __name__ == "__main__":
for _ in range(12):
stored = append_bounded(collect_metrics(), METRICS_LOG, max_entries=5)
print(f"writes: 12, snapshots stored: {stored}")
$ uv run python labs/metrics.py
writes: 12, snapshots stored: 5
Not one line of that writer is new. It is chapter 29's sensor logger with the word
sensor taken out of the name, and the fact that nothing in the body had to change is
the evidence it belonged one level down all along: it never looked inside the dict it
was given. labs/sensor_log.py now calls it too, so
log_sensor_reading becomes a single delegating line and the retention rule
lives in one place. The default cap of 500, at one snapshot per turn, is several long
evenings of conversation, and entries[-max_entries:] still evicts the
oldest on every write, so the file has a maximum size you can state in advance.
# glados/core.py — the tail of run_turn
self.history = self.history[-2 * MAX_TURNS:]
snapshot = collect_metrics(turns=len(self.history) // 2)
append_bounded(snapshot, METRICS_LOG)
return True
# labs/metrics.py
def load_metrics(log_path: Path) -> list[dict]:
if not log_path.exists():
return []
with open(log_path) as f:
entries = json.load(f)
entries.sort(key=lambda e: e["timestamp"])
return entries
def slowest_run(entries: list[dict], stage: str) -> dict | None:
runs = [e for e in entries if stage in e.get("stage_ms", {})]
return max(runs, key=lambda e: e["stage_ms"][stage]) if runs else None
if __name__ == "__main__":
entries = load_metrics(METRICS_LOG)
span = entries[-1]["timestamp"] - entries[0]["timestamp"]
mean_cpu = sum(e["cpu_pct"] for e in entries) / len(entries)
worst = slowest_run(entries, "respond")
when = datetime.fromtimestamp(worst["timestamp"]).strftime("%H:%M:%S")
print(f"{len(entries)} snapshots over {span:.0f}s, mean CPU {mean_cpu:.1f}%")
print(f"slowest respond: {worst['stage_ms']['respond']:.1f} ms at {when}, "
f"CPU {worst['cpu_pct']:.1f}%")
$ uv run python labs/metrics.py # after a real session, bench numbers
41 snapshots over 1264s, mean CPU 22.6%
slowest respond: 4820.3 ms at 21:58:12, CPU 96.4%
Two lines inside run_turn and the whole session becomes queryable. The
sort in load_metrics is the same guarantee chapter 29 argued for: the
writer appends in order today, the reader promises order forever. Read the bench output
as a diagnosis, because it is one. Her slowest reply of the evening happened at 96
percent CPU, which points the investigation at whatever else was running instead of at
the model. Without the machine numbers beside the timing, that same 4.8 seconds looks
like her fault.
No magic and no kernel module. On Linux, psutil parses text files under
/proc: /proc/stat holds cumulative counters of the clock
ticks every core has spent idle, in user code and in the kernel, while
/proc/meminfo lists memory totals in kilobytes. You could read both with
open() and do the arithmetic yourself in twenty minutes, for one
operating system. The library earns its place by answering the same call on Linux,
macOS and Windows.
Why this works: a metric is a sensor reading with different fields
The bounded writer accepted the metrics dict without a single change because it was
never a sensor function. It takes a dict with a timestamp, appends it to an array, and
trims. Temperature in degrees or a stage in milliseconds, the store cannot tell the
difference and does not need to. That is the dividend this volume keeps paying: when the
entry format is "timestamped dict of numbers", every tool written for one producer works
on the other. The averaging helper from her sensor exercises runs on
metrics.json untouched.
The second mechanism is sampling, and it deserves respect. A snapshot is one look at a quantity that never stopped moving, not the truth about the interval around it. One sample per turn says nothing about the seconds between turns, so a spike living entirely in that gap stays invisible however carefully you read the file. Every observability system makes this trade: sample often and pay in overhead and disk, sample rarely and miss events shorter than your interval. Being able to say what your sampling rate makes visible, and what it hides, is the difference between reading a dashboard and believing one.
The obvious way to write stage 1 is to ask for the CPU percentage and take the answer:
def collect_metrics() -> dict:
return {
"timestamp": time.time(),
"cpu_pct": psutil.cpu_percent(), # BUG: no interval
"ram_mb": round(psutil.virtual_memory().used / 1024 ** 2, 1),
}
for _ in range(3):
print(collect_metrics()["cpu_pct"])
$ uv run python labs/metrics.py # with a build running on every core
0.0
0.0
0.0
The machine is loud enough to hear, top in the next terminal says 94
percent, and the dashboard reports a perfectly calm system. Nothing raised, nothing
warned. The reasoning from symptom to cause runs through the definition of the number:
a CPU percentage is a ratio computed between two readings of the tick counters, not a
value the kernel keeps lying around. Called with no interval,
cpu_percent() compares against the previous call in this process, and the
first call has no previous call, so by documented convention it returns
0.0. The next two are microseconds apart, the counters have barely moved,
and the ratio rounds to zero again. interval=0.1 takes both readings
itself: sample, sleep 100 milliseconds, sample again, return a real ratio over a real
window. The price is a tenth of a second per snapshot, paid after the reply has
already been spoken.
Checkpoint, with a history you can query
- I can say what a stored snapshot answers that a printed timing line cannot, and name the fields that turn a duration into a diagnosis.
- I can explain why
cpu_percent()reads 0.0 on a busy machine, whatinterval=0.1does, and what it costs per snapshot. - I know why the stage timings are copied into a fresh dict, and what every stored snapshot would show if they were not.
- I can defend splitting collect, persist and display into three functions.
- I can state what one sample per turn makes visible and what it hides.
- I can point at the single function that now bounds both the sensor log and the metrics log, and say what its limit works out to in real time.
Exercise 1 — thresholds that speak. Write
check_metrics(snapshot, limits) returning one alert string per metric
above its limit, then feed it a snapshot with CPU at 92 against a limit of 85 and
print the result.
Loop the limits, not the snapshot, so an unmeasured field simply has no rule:
value = snapshot.get(key), then append
f"ALERT: {key} = {value} over {limit}" when the value exists and
exceeds it. Publish each string on chapter 15's event bus and the alerts stop being
print statements: a subscriber can log them, and another can hand the text to her
voice. She is entitled to comment on her own thermal situation.
Exercise 2 — one line per snapshot instead. Rewrite the
persistence layer as newline-delimited JSON, one object per line appended in
"a" mode. Time 1,000 writes both ways and report the two numbers.
f.write(json.dumps(entry) + "\n") to write,
[json.loads(line) for line in f if line.strip()] to read. Append is
constant time per write, while the bounded array rewrites the whole file each
time, so the gap widens as the log fills. What you give up is the trim: you cannot
slice a file the way you slice a list, so bounding needs a rotation step. That is
the trade chapter 29's note drew a line at, met from the other side.
Exercise 3 — measure the measurer. Decorate
collect_metrics with @timed and run a few turns. Predict
where it lands in the dashboard's slowest-first ordering before you look.
It should sit right around 100 milliseconds and beat several real stages, because
the sleep inside interval=0.1 is nearly the whole duration. Two ways
out. Prime psutil.cpu_percent() once at startup and pass no interval
afterwards, accepting a number that means "average since the last snapshot". Or
hand the collect-and-write to a background thread and let the turn end without
waiting. The measurement you added to find overhead was overhead: instrumentation
is never free, and knowing its price is part of trusting the numbers.
She can now be asked what her evening cost her, in seconds and percentages, from a file
that will not outgrow its disk. Her memory of that evening is another matter: the
conversation history in run_turn is capped at twenty exchanges and dies with
the process, so a fact you told her on Monday is gone by Tuesday. Next chapter gives
facts a durable home in SQLite, searchable by topic and ranked so the thing she is most
sure of answers first.