GLaDOS Vol 4 · One System
ch 36 / 99
Chapter 36

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

▣ Build · stage 1 — one snapshot of the machine
# 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.

▣ Build · stage 2 — the timings the decorator already had
# 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.

▣ Build · stage 3 — a dashboard a human reads at a glance
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

▣ Build · stage 4 — one writer, two callers
# 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.

▣ Build · stage 5 — one line inside the turn, then questions
# 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.

◆ Note — what psutil is actually reading

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.

⚠ Worked failure — the CPU that was always zero

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

✓ Checkpoint — what you can now do
  • 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, what interval=0.1 does, 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.
⚡ Exercises — try first, then reveal
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.