WORKBENCH
04 · ScaleLesson 4.7

Logs, metrics and traces.

How do you know what a running system is doing, and why it got slow at 3am?

Listen · 15 min0:00 / 15:15

This is the craft of making a running program keep a diary, so that when something goes wrong you can work out what happened afterwards, without having been there and without reading the code.

You need it because the code is the same for every request and the behavior isn’t. Your server on the Spark will one day be slow for some requests and fine for others, and the source file will look exactly like it did yesterday. The only evidence is what the program wrote down while it was happening. It’s the difference between a teammate saying “the agent feels slow sometimes” and you answering with which step, which requests, and since when. It’s also how you’d back up any claim about your server against Fireworks.

It builds on p99 and load testing, where the slowest few requests turned out to matter more than the average, and on the streaming server from Baseline: my Spark vs Fireworks. After this, every change you make to that server, starting with Batching and continuous batching, is something you can watch working or failing.

Today

You’ll want p99 and load testing first (histograms, percentiles, and load that keeps arriving whether or not the server keeps up), plus the streaming server from Baseline: my Spark vs Fireworks, where you met time to first token and the gap between tokens. Everything runs on a stand-in server on your Mac first, so the Spark only matters for the last task. Budget two evenings.

The Problem

Your Spark is serving Llama to an agent harness a teammate wrote. One morning there is a message: “the agent sometimes stalls for a second or so before it starts answering. Not always. Started last night maybe?”

You look at what you have. The benchmark harness from the baseline lesson says p50 TTFT is fine. You run five requests by hand: all fast. You open the server’s terminal and scroll through forty thousand lines of got request and done, with no way to tell which done belongs to which got request, or which ones were slow. You read the code. It looks exactly like it did last week, because it is exactly the same code.

That last point is the whole lesson. You can’t debug production by reading code, because the code is the same for every request and the behavior isn’t. What changed is everything around the code: the inputs (one customer started sending prompts with code blocks), the load (a batch job runs at 2am now), the dependencies (one database shard is sick), and the timing between requests (two of them grabbing the same lock at once). None of that is in the source file. It only exists in the running system, and the running system forgets everything the moment it happens, unless you made it write things down.

Making it write the right things down, in a form you can search and add up later, is observabilityHow well you can figure out what a running system is doing, and why, from the outside, using only what it reports about itself. The word comes from control theory.. The three classic forms of “writing things down” are logs, metrics and traces. By the end of this lesson you will have all three on your server, and you will use them to find a fault you don’t know the location of.

The Idea

Three ways to write things down

Think of a restaurant on a busy night. There are three ways to find out why table 12 waited forty minutes.

Those are logs, metrics and traces.

FIG_001[ ONE REQUEST, THREE VIEWS ]
LOG{"ts": "...:31.204", "level": "INFO", "msg": "request done", "request_id": "7f3a", "user": "user-0412", "ttft_ms": 431, "dur_ms": 1067}ONE LINE PER EVENTDETAIL, NO SHAPEMETRICSrequests_total +1in_flight 12+10.5 0.75 1 1.5 2 sNUMBERS OVER TIMESHAPE OF ALL, NO DETAILTRACE 7f3aquota_checktokenize, cacheprefilldecodeSHAPE OF ONE REQUEST
The same slow request as each pillar sees it. The log line has the details (who, which prompt, how long). The metrics only know it as +1 on a counter and +1 in the 1.5 s bucket. The trace shows its shape: which step ate the time.

Each pillar is cheap where another is expensive, which is why you want all three and why they are wired together by one shared thing: an ID.

Logs: events, made searchable

A print("done") is a log, a bad one. Three upgrades turn it into something you can debug with.

Structure. Write each event as one JSON object per line, not a sentence. "request done in 1067 ms for user-0412" is readable but you can only grep it. {"msg": "request done", "dur_ms": 1067, "user": "user-0412"} you can filter (dur_ms > 1000), group (by user), and count, with jq on your laptop or a log store at work. This is a structured logA log line written as machine-readable key-value fields (usually one JSON object per line) instead of free text, so tools can filter and aggregate on the fields..

A request id on every line. A server handling 16 requests at once interleaves their log lines. Without an ID you can’t tell which lines belong together. Generate an ID when the request arrives (or take the one the caller sent in an X-Request-ID header), stash it somewhere every function can reach without passing it by hand, and have the log formatter stamp it on every line. In async Python that “somewhere” is a contextvarA variable from Python’s contextvars module whose value is private to the current task or thread, so concurrent requests each see their own value. The async-safe version of a global.. Return the same ID to the caller in a response header, so when your teammate says “request 7f3a was slow”, you can find every line it wrote.

Levels. Each line gets a severity: DEBUG (chatter for developers), INFO (normal events worth keeping), WARNING (something odd that recovered), ERROR (a request failed). In production you usually keep INFO and above.

Then the cost, which is the thing nobody tells you. Say 1,000 requests a second, ten log lines per request, 500 bytes per line. That’s 5 MB a second, which is 432 GB a day. Log stores charge by the gigabyte ingested and stored. So big systems log samplingKeeping only a fraction of events, for example every error but 1% of successful requests, to cut storage cost while keeping the interesting cases.: keep every error and every slow request, and maybe 1% of the boring successful ones. On your one server you can keep everything. Your company can’t.

The other cost is time. A JSON log line formatted in Python cost about 4 µs on my Mac (writing to memory, Python 3.14). Nothing next to a 500 ms request, a lot inside a loop that runs per token.

Metrics: numbers you can add up

A metric throws away the details on purpose so it can be kept forever and summed across machines. There are three kinds, and Prometheus (the standard open-source metrics system) names them like this:

Prometheus histograms are cumulative bucketsEach bucket counts all observations less than or equal to its upper bound (le = “less or equal”), so the counts only grow from bucket to bucket and the last one, le=“+Inf”, equals the total.. Here’s what my instrumented stand-in server printed for its end-to-end latency after 300 requests:

llm_request_duration_seconds_bucket{le="0.25"} 0.0
llm_request_duration_seconds_bucket{le="0.5"} 102.0
llm_request_duration_seconds_bucket{le="0.75"} 298.0
llm_request_duration_seconds_bucket{le="1.0"} 300.0
... (every bucket after this also says 300)
llm_request_duration_seconds_bucket{le="+Inf"} 300.0
llm_request_duration_seconds_count 300.0
llm_request_duration_seconds_sum 167.57

Read it as: 102 requests took at most 0.5 s, 298 at most 0.75 s, all 300 at most 1 s, and together they took 167.6 s (so the mean was 559 ms). That’s the whole histogram: a dozen numbers, however many requests it has seen. Recording one observation took about 0.4 µs on my Mac.

Which metrics? Two recipes cover almost everything:

The Google SRE book’s “four golden signals” (latency, traffic, errors, saturation) are the same idea, the two recipes merged.

Labels, and the explosion

Metrics get dimensions through labelA key-value tag on a metric, like status=“error” or endpoint=“/v1/chat”. Every distinct combination of label values creates a separate time series that is stored on its own.s. llm_requests_total{status="ok"} and llm_requests_total{status="error"} are two separate counters, two time seriesOne stream of (timestamp, value) points for one metric with one exact set of label values. The unit Prometheus stores, indexes and charges memory for..

The trap is in the multiplication. One histogram with 12 buckets, labelled by endpoint (5 values) and status (3 values), is about 12 × 5 × 3 = 180 series. Fine. Now someone adds user_id as a label “so we can see per-user latency”. With 100,000 users that’s 18 million series. Each active series costs memory in Prometheus (on the order of kilobytes), so this is tens of gigabytes, and it happens silently the day traffic grows. This is a cardinality explosionWhen a metric’s label values have so many possible combinations that the number of time series blows up, eating memory and slowing every query. Classic culprits: user ids, request ids, full URLs, raw prompts., and it is one of the most common ways people take down their own monitoring.

The rule: metric labels must come from a small, fixed set (status code, endpoint, model name, region). Anything unbounded (user id, request id, prompt text) goes in logs and trace attributes, which are stored per event and built for it.

Percentiles come from buckets, not from averages

The dashboard most people build first plots average latency. Play with this and you’ll see why that’s a mistake.

Predict firstwrite it down

Your server’s requests take about 270 ms on average. You plant a fault: 5% of requests get an extra 400 ms. How much does the average latency change? How much does p99 change? Guess both in milliseconds.

Reveal

The average moves by 5% × 400 ms = 20 ms, about 7%. Easy to dismiss as noise on a dashboard. p99 moves from roughly 530 ms to about 730 ms, around 200 ms, because p99 now lands inside the slow 5%. On my stand-in server under real load (below) it was the same story: mean +31 ms, p99 +326 ms. The users who hit the fault see 400 ms more; the average spreads that over everybody and hides it.

FIG_002[ AVERAGE VS HISTOGRAM ]

Drag the slow fraction and the delay. The mean (dashed) barely moves; p99 (blue) jumps into the slow hump. The bucket estimate is only as good as your bucket edges. Then switch to the fleet: averaging four servers' p99s under-reports; summing the buckets and then estimating gets close to the truth.

Two lessons from that figure.

Percentiles are estimated from buckets. Prometheus’s histogram_quantile() finds the bucket that the 99th-percentile request falls in and assumes requests are spread evenly inside it. With a wide bucket the estimate drifts: set 5% and +1000 ms, and p99 lands in the 1 to 1.25 s bucket, where the estimate is off by almost 100 ms. So put bucket edges where your decisions are. If your goal is “TTFT under 250 ms”, have an edge at exactly 0.25, so “how many were under 250 ms” is an exact count, not a guess.

You cannot average percentiles. The p99 of the fleet is not the average of each machine’s p99. In fleet mode at 5% and +1000 ms, one sick server out of four pushes the true fleet p99 to about 1.2 s, while the average of the four p99s says 730 ms. Counts add up, percentiles don’t. Prometheus histograms are counters, so you add them first and estimate once:

# right: sum the bucket rates across all instances, then estimate p99
histogram_quantile(0.99, sum by (le) (rate(llm_request_duration_seconds_bucket[5m])))

# wrong: a p99 per instance, then averaged
avg(histogram_quantile(0.99, rate(llm_request_duration_seconds_bucket[5m])))

This is the same rule the tail latency lesson gave for HDR histograms, now in the language your dashboards speak. (Prometheus also has “summaries”, which compute percentiles inside each process. Those can’t be added across machines at all, which is why histograms are usually the better choice.)

Traces: where the time went in one request

A metric told you p99 got worse. It can’t tell you why, because it has forgotten every individual request. A trace keeps one request whole.

A trace is a tree of spanOne timed step inside a trace: a name, a start time, a duration, a parent, and key-value attributes. “tokenize took 2 ms”, “database query took 400 ms”.s. The first span, the root spanThe top span of a trace, with no parent. Usually “the whole request as the first service saw it”., covers the whole request. Each step inside it is a child span; steps inside those are grandchildren. Every span records which span is its parent, and every span in the tree shares one trace idA random 128-bit number shared by every span of one request, across every service. It is how the collector knows which spans belong together..

Drawn on a timeline, a trace is a waterfall. Here is one streaming request to an inference server, through a gateway, a quota database check, the server, and the model. Plant a fault and see which span grows.

FIG_003[ TRACE WATERFALL ]
Request total
TTFT
Span that grew
Planted fault

One request's spans, nested. Indentation is parent and child; the dashed line is the first token, so everything left of it is TTFT. Each fault shows up as one span growing, and the readouts show what that does to TTFT and total time. Notice which faults the server's own metrics could never see.

Things to notice while you click:

Spans also carry span attributeA key-value pair attached to a span, like user=“user-0412” or prompt_chars=176. Unlike metric labels, these can have unbounded values, because they are stored per span.s: the user, the prompt length, the model, the number of output tokens. This is where the high-cardinality detail that’s banned from metrics lives. When you find the slow traces, their attributes tell you what they have in common.

Context propagation: one trace, many processes

Within one process, the tracing library keeps track of “the current span” in a contextvar, so any new span automatically becomes its child. Across processes there’s no shared memory, so the parent has to travel with the request. That’s context propagationPassing the trace id and current span id from one service to the next inside the request itself (usually an HTTP header), so the receiving service’s spans join the same trace., and on HTTP it’s one header defined by the W3C Trace ContextThe W3C standard that defines the traceparent header (and tracestate) so tracing works across services written in different languages with different tracing libraries. standard: traceparent.

FIG_004[ THE TRACEPARENT HEADER ]
CLIENT / GATEWAYspan 00f067aa…inject(headers)SERVERextract(headers)child spanPOST /v1/chat/completionstraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01VERSION 00TRACE ID · 16 BYTES · SAME EVERYWHEREPARENT SPAN · 8 BFLAGS: 01 = SAMPLED
The client injects its current trace id and span id into the outgoing request. The server extracts them and makes its first span a child of the client's span. Same trace id on both sides: the collector stitches them into one tree.

The last field carries the head samplingDeciding at the very start of a request whether to record its trace (say, 1 in 100), and passing that decision downstream in the traceparent flags so every service agrees.. If the gateway decided not to record this request, every service downstream skips it too, so you never get half a trace. The alternative is tail samplingDeciding whether to keep a trace after it finishes, so you can keep every slow or failed one and only a few fast ones. Needs a collector that buffers all spans of a trace until it completes., which keeps all the slow and broken traces and drops most of the boring ones. When I ran the current Python SDK, it printed flags 03: the extra bit is a newer Trace Context addition that marks the trace id as random. Your version may differ.

OpenTelemetry, Prometheus, Jaeger, Grafana

OpenTelemetryAn open standard plus SDKs (for most languages) for producing traces, metrics and logs, and a wire protocol (OTLP) for shipping them. Vendor neutral: you instrument once and send the data anywhere. Often shortened to OTel. (OTel) is the standard way to produce telemetry. You write against its API (tracer.start_as_current_span(...)), and an exporter ships the data over OTLPOpenTelemetry Protocol: the wire format for sending spans, metrics and logs to a backend, over gRPC (port 4317) or HTTP (port 4318). to whatever backend you choose. Before OTel, switching tracing vendors meant re-instrumenting every service. Now it means changing an endpoint.

The backends you’ll run:

FIG_005[ PUSH TRACES, PULL METRICS ]
YOUR SERVERcounters in RAMGET /metricsspan batcherJAEGERstores traces · UIPROMETHEUStime series DBGRAFANAdashboardsPUSH · OTLP :4318PULL · SCRAPEevery 5-15 sPromQL
Traces are pushed: the app batches finished spans and sends them to Jaeger over OTLP. Metrics are pulled: the app just keeps counters in memory and serves them at /metrics, and Prometheus scrapes every few seconds. Grafana draws dashboards by querying Prometheus.

Recent FastAPI releases have started shipping optional built-in OpenTelemetry support, and there are auto-instrumentation packages for most frameworks. They’re worth using at work. Today you’ll write the spans by hand, so you know what those packages do.

SLIs, SLOs and error budgets

Dashboards are for looking. At 3am nobody is looking, so something has to decide when to wake you. The Google SRE book’s answer starts with three terms:

Why not aim for 100%? Because the last nines are wildly expensive, users can’t tell 99.99% from 100% through their own flaky Wi-Fi, and a team that may never break anything never ships anything. The budget turns “is it reliable enough?” from an argument into arithmetic: budget left means ship; budget gone means slow down and fix things.

Then the burn rateHow fast you’re spending the error budget, relative to spending it exactly evenly over the window. Burn rate = (bad fraction right now) / (1 − SLO target). 1 means “on track to use exactly all of it”; 10 means “gone in a tenth of the window”.:

burn rate = (fraction of bad requests, recently) / (1 − SLO)

With a 99% SLO, 1% bad is burn rate 1: you’ll use exactly the whole budget by day 30. 6% bad is burn rate 6: the budget is gone in 5 days.

FIG_006[ BURNING THE BUDGET ]
100%50%0051015202530DAYS INTO THE 30-DAY WINDOW1x · LASTS 30 DAYS6x14.4xPAGE HERE: 2% GONE IN 1 HOUR
Error budget left over a 30-day window, for three constant burn rates. At 1x the budget lasts exactly the window. At 6x it's gone on day 5; at 14.4x, in about two days. The SRE workbook's suggested pages: 14.4x sustained over an hour (2% of the budget gone), or 6x over six hours (5%).
Predict firstwrite it down

Your SLO: 99% of requests get TTFT under 250 ms, over 30 days. A fault makes 6% of requests miss that. What’s the burn rate, and how many days until the month’s budget is gone? And would a dashboard of average TTFT have shown it?

Reveal

Burn rate = 0.06 / 0.01 = 6. The 30-day budget lasts 30 / 6 = 5 days. Average TTFT on my stand-in rose from 44 ms to 69 ms with exactly this kind of fault: still far under 250 ms, so an “average TTFT above 250 ms” alert would never fire. The SLO counts bad requests one by one, so it can’t be fooled by the good ones.

Alert on symptoms, not causes

There are two kinds of alerts. A symptom is something users feel: errors, slowness, the SLO burning. A cause is something you guess might lead to that: CPU at 90%, one replica down, a disk 80% full, GPU memory high.

Page on symptoms. A cause alert fires when nothing is wrong (the GPU at 100% is the GPU doing its job; one replica down while the others absorb the load is fine) and stays silent when something is (the slow-DB fault raises no CPU, no memory, nothing). Every page that turns out not to matter teaches the person on call that pages don’t matter. That’s alert fatigueWhen people get so many alerts that don’t need action that they start ignoring or muting them, including the real ones., and it’s how real incidents get slept through. Rob Ewaschuk’s rule of thumb from Google: every page should be urgent, actionable, and about something users notice. Causes belong on dashboards, for the human who got paged to look at next.

Burn rate is the symptom alert done well. “14.4x over the last hour” means you’ll lose 2% of the month’s budget in that hour, which is worth a page. Requiring a short window (say 5 minutes) to also be burning makes the alert stop as soon as the problem stops.

The fourth pillar: profiles

A trace says which span is slow. It doesn’t say which line of code inside the span is burning the time. That’s a profilerA tool that measures where a program spends its time, by function and line. A sampling profiler looks at the call stack many times a second and counts what it sees.’s job, and you used one, py-spy, in Why is Python slow?. The production version is continuous profilingRunning a low-overhead sampling profiler all the time on every production machine and storing the results, so you can ask “what was this service doing at 3:12am?” after the fact.: a sampler runs always, at low rate, on every machine, so you can ask what the code was doing at 3:12am after the fact. Google described doing this fleet-wide in 2010 (Google-Wide Profiling); open-source tools like Pyroscope and Parca do it today, and OpenTelemetry is adding profiles as a fourth signal.

One subtlety the cache-lock fault teaches: a normal CPU profile only counts threads that are running. A thread stuck waiting for a lock isn’t running, so it’s invisible. You need a wall-clock profileA profile that samples every thread whether it is running or waiting, so time spent blocked on locks, I/O or sleep shows up. py-spy’s —idle flag includes idle threads., or py-spy dump, which prints every thread’s current stack, waiting or not.

Observing an LLM server

Everything above applies, with a few numbers that only LLM serving has. Most come straight from the baseline lesson:

MetricKindWhy
TTFThistogramWhat chat users feel. Covers queue + prefill.
ITL (time per output token)histogramDecode speed. Catches slowdowns TTFT misses.
Output tokens/scounter (rate() of it)Aggregate throughput, what you’d bill for.
Prompt and output tokenscountersCost, and the input to every capacity estimate.
Requests running / waitinggaugesSaturation (the U and S of USE). Waiting > 0 for long = add capacity.
KV cache utilizationgaugeFraction of KV memory in use. Near 100%, engines stop admitting or preempt requests (continuous batching).
Prefix cache hit ratecountersWhether prefix caching is paying off.

vLLM and SGLang already expose most of these on a /metrics endpoint in Prometheus format. The names change between versions, so curl it and look rather than trusting a blog post.

For agents, the trace is the natural unit. One agent run is one trace; each model call and each tool call is a span, with attributes for the model, input and output tokens, the tool name and whether it errored. The waterfall then answers questions that are otherwise miserable: “why did this run take 4 minutes?” (eleven sequential tool calls, one of them a 90 s web fetch), “where do the tokens go?” (the context re-sent on every step). OpenTelemetry has draft conventions for these attribute names (the gen_ai.* attributes); most LLM tracing products are this idea with a nice UI.

Build It

You’ll instrument a stand-in server that has the same shape as server_naive.py (a FastAPI app streaming SSE, a limit on concurrent “GPU” work, prefill then decode) but fakes the model with sleeps, so it runs anywhere and the numbers are repeatable. Then you’ll port the instrumentation to the real one on the Spark.

obs/
  deps.py          # fake dependencies, with one hidden fault (don't read the bottom)
  fakellm.py       # the server you instrument
  loadgen.py       # open-loop load
  docker-compose.yml, prometheus.yml, grafana/   # the backends
  app.log          # your JSON logs
Set up the stand-in server and load generatorAgent: fine
mkdir -p ~/workbench/obs && cd ~/workbench/obs
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn httpx prometheus_client \
  opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

deps.py picks one of three faults at startup from FAULT_SEED. Each hits roughly 5% of requests. It’s folded away because it’s a spoiler: the point of today is finding the fault from the outside. Best: ask an agent to copy it into your folder for you, so you never see it. Even if you do read it, you won’t know which of the three is live.

deps.py (spoilers: the three faults)
# deps.py: the "dependencies" of the fake inference server.
# One of three faults is switched on at startup, chosen from FAULT_SEED.
# Don't read past this comment until you've found it with telemetry.
import asyncio, hashlib, os, random, threading, time

_rng = random.Random(os.environ.get("FAULT_SEED", "0"))
_FAULT = _rng.choice(["cache_lock", "slow_quota", "tokenizer"])
if os.environ.get("NO_FAULT"):
    _FAULT = "none"
_cache_lock = threading.Lock()
_cache: dict[str, bool] = {}


def reveal() -> str:
    return _FAULT


async def quota_check(user_id: str) -> bool:
    """Pretend database call: is this user under their rate limit?"""
    await asyncio.sleep(0.003)
    if _FAULT == "slow_quota" and int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 20 == 0:
        await asyncio.sleep(0.4)  # this tenant's row lives on a sick shard
    return True


def tokenize(prompt: str) -> list[int]:
    """Pretend tokenizer. ~1 token per 4 chars."""
    time.sleep(0.0005 + len(prompt) * 2e-7)
    if _FAULT == "tokenizer" and "```" in prompt:
        time.sleep(0.25)  # pathological regex on code fences
    return list(range(max(1, len(prompt) // 4)))


def cache_lookup(prefix_key: str) -> bool:
    """Pretend prefix cache. Shared by every request, guarded by a lock."""
    with _cache_lock:
        if _FAULT == "cache_lock" and random.random() < 0.05:
            time.sleep(0.3)  # "compaction" while holding the lock
        hit = prefix_key in _cache
        _cache[prefix_key] = True
        return hit

The server. Four “batch slots” stand in for the GPU; prefill costs 400 µs per prompt token and each output token 20 ms.

# fakellm.py: a stand-in for server_naive.py with the same shape and fake timings.
import asyncio, json, random, time, uuid

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

import deps

app = FastAPI()
gpu = asyncio.Semaphore(4)       # 4 "batch slots"; the rest queue
PREFILL_S_PER_TOKEN = 0.0004     # 400 µs per prompt token
ITL_S = 0.02                     # 20 ms per output token (50 tok/s)


class ChatRequest(BaseModel):
    messages: list[dict]
    max_tokens: int = 64
    user: str = "anon"


@app.get("/health")
async def health():
    return {"ok": True}


@app.post("/v1/chat/completions")
async def chat(req: ChatRequest):
    rid = f"chatcmpl-{uuid.uuid4().hex[:12]}"
    prompt = "\n".join(m.get("content", "") for m in req.messages)

    async def events():
        await deps.quota_check(req.user)
        ids = await asyncio.to_thread(deps.tokenize, prompt)
        await asyncio.to_thread(deps.cache_lookup, prompt[:64])
        async with gpu:
            await asyncio.sleep(len(ids) * PREFILL_S_PER_TOKEN)        # prefill
            n = random.randint(req.max_tokens // 2, req.max_tokens)
            for i in range(n):                                          # decode
                yield f"data: {json.dumps({'id': rid, 'choices': [{'delta': {'content': f'tok{i} '}}]})}\n\n"
                await asyncio.sleep(ITL_S)
        usage = {"prompt_tokens": len(ids), "completion_tokens": n}
        yield f"data: {json.dumps({'id': rid, 'choices': [{'delta': {}, 'finish_reason': 'length'}], 'usage': usage})}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(events(), media_type="text/event-stream")

The load generator is open-loop (from the tail latency lesson): it sends at a fixed rate whether or not the server keeps up, from 500 fake users, with a mix of prompts.

# loadgen.py: open-loop load at a fixed rate.  usage: python loadgen.py RATE SECONDS
import asyncio, random, sys, time
import httpx

URL = "http://127.0.0.1:8000/v1/chat/completions"
RATE = float(sys.argv[1]) if len(sys.argv) > 1 else 5.0
DURATION = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0
USERS = [f"user-{i:04d}" for i in range(500)]
PROMPTS = [
    "Summarize this page about solar panels." * 8,
    "Rank these five documents for the query 'cheap flights'." * 12,
    "Explain this code:\n```python\nprint('hi')\n```" * 4,
    "Write a haiku about latency.",
]
WEIGHTS = [50, 30, 5, 15]


async def one(client: httpx.AsyncClient):
    body = {"messages": [{"role": "user", "content": random.choices(PROMPTS, WEIGHTS)[0]}],
            "max_tokens": 32, "user": random.choice(USERS)}
    try:
        async with client.stream("POST", URL, json=body) as r:
            async for _ in r.aiter_lines():
                pass
    except httpx.HTTPError as e:
        print("error:", type(e).__name__)


async def main():
    async with httpx.AsyncClient(timeout=30, limits=httpx.Limits(max_connections=500)) as client:
        tasks, t0, i = [], time.perf_counter(), 0
        while time.perf_counter() - t0 < DURATION:
            tasks.append(asyncio.create_task(one(client)))
            i += 1
            await asyncio.sleep(max(0, i / RATE - (time.perf_counter() - t0)))
        await asyncio.gather(*tasks)
        print(f"sent {i} requests")

asyncio.run(main())

Smoke test: NO_FAULT=1 uvicorn fakellm:app --port 8000 in one terminal, python loadgen.py 5 20 in another. Keep the rate at about 5 per second: with 4 slots and ~0.55 s per request the server tops out near 7 per second, and past that the queue grows without bound (try 10 once to see it).

Start Jaeger, Prometheus and GrafanaAgent: fine

Three containers. Image tags move; pin versions from each project’s docs once it works.

# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/jaeger:latest      # Jaeger v2; UI on 16686, OTLP on 4317/4318
    ports: ["16686:16686", "4317:4317", "4318:4318"]
  prometheus:
    image: prom/prometheus:latest
    ports: ["9090:9090"]
    volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml:ro"]
    extra_hosts: ["host.docker.internal:host-gateway"]   # needed on Linux (the Spark)
  grafana:
    image: grafana/grafana:latest
    ports: ["3000:3000"]
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
    volumes: ["./grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro"]
# prometheus.yml
global:
  scrape_interval: 5s
scrape_configs:
  - job_name: fakellm
    static_configs:
      - targets: ["host.docker.internal:8000"]
# grafana/datasources.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    isDefault: true

docker compose up -d, then open Jaeger at localhost:16686, Prometheus at localhost:9090 (Status → Targets will show your server as down until the next task adds /metrics), and Grafana at localhost:3000. If a UI doesn’t load, docker compose logs <service> usually says why. An agent can debug this part freely.

Structured JSON logs with a request idHands only

Make every log line one JSON object carrying the request id, without passing the id through every function by hand.

import contextvars, json, logging, uuid

request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        """Return one JSON line: ts, level, msg, request_id, plus any extra fields."""
        ...

log = logging.getLogger("fakellm")
# a handler writing to app.log with your formatter; level INFO

In the endpoint:

  • Take the id from an incoming X-Request-ID header if present, else generate one. Set the contextvar at the top of events() (the generator runs as its own step; set it there, not only in chat).
  • Return it in the response: StreamingResponse(..., headers={"x-request-id": rid}).
  • Log one "request done" line per request with user, prompt_chars, ttft_ms, dur_ms, out_tokens, status. Put it in a finally, so failed and cancelled requests log too.

Hints:

  • log.info("request done", extra={"fields": {...}}) puts a fields attribute on the record; your formatter can merge it in with getattr(record, "fields", {}).
  • asyncio.to_thread copies the current context into the worker thread, so a log line written inside deps.tokenize would see the right id too.
  • Check yourself: jq -c 'select(.ttft_ms > 200)' app.log | head should print slow requests, and jq -r .request_id app.log | sort | uniq -d should print nothing (one line per request).
Prometheus metrics: RED, in-flight, tokens, TTFTHands only

Add a /metrics endpoint and these metrics. The names are suggestions; the kinds are not.

MetricKindLabels
llm_requestsCounterstatus (ok / error)
llm_request_duration_secondsHistogramnone
llm_ttft_secondsHistogramnone
llm_itl_secondsHistogramnone
llm_requests_in_flightGaugenone
llm_output_tokens, llm_prompt_tokensCounternone
llm_queue_waitingGaugenone (requests waiting for a GPU slot)

Shape:

from prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST
from fastapi import Response

TTFT = Histogram("llm_ttft_seconds", "Time to first token", buckets=(...))  # choose them

@app.get("/metrics")
def metrics():
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

Hints:

  • Choose buckets on purpose. The default buckets (5 ms to 10 s) are generic. Your TTFT SLO in the last task will be 250 ms, so 0.25 must be an edge. For ITL, a 20 ms decode step needs edges around 0.01 to 0.1.
  • prometheus_client appends _total to counter names on output: llm_output_tokens is scraped as llm_output_tokens_total.
  • In-flight: inc() at the start of events(), dec() in the finally. If it ever drifts upward under steady load, you have a request path that skips the finally (a client disconnect, say).
  • Time the first token where you yield it, not where the request starts being handled: TTFT is from arrival to first token.
  • Run load, then curl -s localhost:8000/metrics | grep ttft and check the bucket counts are cumulative and the +Inf bucket equals _count.
OpenTelemetry traces, across two processesHands only

The SDK wiring is boilerplate; take it as is:

from opentelemetry import propagate, trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider(resource=Resource.create({"service.name": "fakellm"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("fakellm")

The spans are yours. Inside events():

ctx = propagate.extract(request.headers)        # the caller's trace, if any
with tracer.start_as_current_span("chat", context=ctx, attributes={...}) as root:
    with tracer.start_as_current_span("quota_check"):
        ...
    # tokenize, cache_lookup, queue_wait (just the wait for the semaphore), prefill, decode

Then make it cross a process boundary. In loadgen.py, give it its own TracerProvider with service.name = loadgen, wrap each request in a client_request span, and call propagate.inject(headers) inside that span to add traceparent to the outgoing request. In Jaeger you should see one trace with spans from two services.

Hints:

  • chat needs the Request object: add request: Request to the endpoint’s parameters (from fastapi).
  • Attributes on the root span: user, request_id, prompt_chars, out_tokens. These are your clues later.
  • queue_wait should cover only the await gpu.acquire(), then release in a finally. Using async with gpu: makes it awkward to time the wait separately.
  • BatchSpanProcessor exports in a background thread, so a slow or dead Jaeger doesn’t slow requests. Remember that for Break It.
  • Each span cost about 8 µs on my Mac. With 7 spans per 550 ms request, that’s noise. With a span per decoded token on a busy server, it isn’t.
A RED + TTFT dashboardAgent: review after

In Grafana, build one dashboard with these panels. The first two queries are given; write the rest.

# Rate: requests per second
sum(rate(llm_requests_total[1m]))

# Errors: fraction of requests failing
sum(rate(llm_requests_total{status="error"}[1m])) / sum(rate(llm_requests_total[1m]))
  • Duration: p50 and p99 of llm_request_duration_seconds (see the “right” query in The Idea).
  • TTFT: p50 and p99. Plus the mean, computed as rate(..._sum[1m]) / rate(..._count[1m]), on the same panel, so you can watch it lie.
  • ITL p99 and output tokens per second.
  • Saturation: llm_requests_in_flight and llm_queue_waiting.

Run python loadgen.py 5 600 against NO_FAULT=1 and screenshot the healthy dashboard. That’s your baseline. Then have an agent review your PromQL (common mistakes: averaging quantiles, rate() over a window shorter than two scrapes, forgetting by (le)).

The hunt: find the planted fault from telemetry aloneHands only

Start the server with a random fault and don’t look at which:

FAULT_SEED=$RANDOM uvicorn fakellm:app --port 8000      # don't echo the seed
python loadgen.py 5 600

Then find it. The rules: dashboards, Jaeger, jq over app.log, curl /metrics, and py-spy. No reading deps.py, no print debugging.

A method that works for any incident:

  1. Dashboard: what’s the symptom? Which percentiles moved, TTFT or ITL, all requests or some? Did saturation change?
  2. Traces: where does the time go? In Jaeger, search for traces with a minimum duration (say 900 ms). Open five. Which span is fat in every one of them?
  3. Attributes and logs: what do the slow ones share? Same user? Same prompt length? Spread evenly? jq is your friend: group slow requests by user and by prompt_chars and compare against all requests.
  4. Profile, if the span is fat but its code is trivial. py-spy dump --pid <uvicorn pid> a few times during load (macOS needs sudo). Where are the worker threads sitting?

Write a five-line incident note: symptom, which span, which requests, your guess at the root cause, and the evidence. Then check: FAULT_SEED=<seed> python -c "import deps; print(deps.reveal())" (you’ll need the seed: start with export FAULT_SEED=$RANDOM next time). Repeat with new seeds until you’ve found all three faults.

Define an SLO and compute its burn rateHands only

SLO: 99% of requests get their first token within 250 ms, over 30 days.

  1. Write the SLI as PromQL: the fraction of requests with TTFT above 0.25 s over the last 5 minutes. You need the le="0.25" bucket and the _count. (This is why that edge had to exist.)
  2. Turn it into a burn rate by dividing by the budget, 0.01.
  3. Add two panels: burn rate over 5 minutes and over 1 hour. Write the alert condition the SRE workbook suggests for a page: both the 1 h and the 5 m burn rate above 14.4. Then the slower one: 6 h and 30 m above 6.
  4. Run each fault for 10 minutes. Record the burn rate in the Measure table, and how many days of budget that leaves.

Don’t build real alert routing; just write the conditions down and say which would have paged.

Port it to the real server on the SparkAgent: review after

Apply the same instrumentation to server_naive.py. The spans map directly: queue_wait is the wait for gpu_lock, prefill ends when the streamer yields its first text, decode is the rest. Record ITL from the real gaps between streamed pieces of text (remember that one SSE event can hold several tokens; record per-event gaps divided by tokens, or just the request’s mean). Add a gauge for requests waiting on the lock. Point the Spark’s Prometheus at it, run your benchmark harness at c = 1 and c = 8, and watch queue_wait turn into the biggest span. That’s the picture the batching lesson is about to fix. Have an agent review the diff for spans that are never ended and metrics with unbounded labels.

Done when
  • app.log has one JSON line per request with a request id, and the response carries the same id.
  • /metrics shows the RED counters, TTFT/ITL/duration histograms with deliberate bucket edges, in-flight and waiting gauges, and token counters.
  • Jaeger shows traces with spans from both loadgen and fakellm under one trace id.
  • Your Grafana dashboard shows rate, errors, p50/p99 duration and TTFT (with the mean next to it), ITL, tokens/s and saturation.
  • You found all three planted faults from telemetry, each with a five-line incident note, before running reveal().
  • You can state the TTFT SLO’s burn rate for each fault, and which alert would have paged.

Measure

Run each configuration at 5 requests per second for 10 minutes. Fill in your numbers.

RunMean durationp50p99TTFT meanTTFT p99Bad (TTFT > 250 ms)Burn rate (99% SLO)Culprit span
No fault—
Fault A
Fault B
Fault C
Overload (loadgen.py 8)

What I measured on a Mac with the stand-in, one minute (300 requests) per run, so the p99s are rough (300 samples is thin for a p99):

RunMeanp50p99TTFT meanTTFT p99BadBurn
No fault559 ms554 ms741 ms44 ms75 ms0%0
A fault590 ms582 ms1067 ms69 ms441 ms6.0%6.0
Another570 ms569 ms906 ms60 ms285 ms5.7%5.7
The third582 ms586 ms902 ms67 ms381 ms4.7%4.7

(Not labelled, on purpose.) What the numbers should teach you:

Break It

  1. Add user as a metric label. Put labelnames=["user"] on the duration histogram, run load for a minute, and count lines: curl -s localhost:8000/metrics | wc -l. With 500 users and ~15 lines per histogram, expect several thousand lines where there were a few dozen. Now imagine 100,000 users and 50 servers, and Prometheus’s memory. Take it out and put user on the span instead.
  2. Forget to extract the context. Remove context=ctx from the root span. Jaeger now shows two separate traces per request, one from loadgen and one from the server, with nothing joining them. This is what a single service that doesn’t propagate headers does to a whole company’s traces: a hole in the middle of every request.
  3. Make telemetry the outage. Swap BatchSpanProcessor for SimpleSpanProcessor (exports synchronously, on every span end) and point the exporter at an address that never answers, such as http://192.0.2.1:4318/v1/traces. Watch your p99. Each span end now waits for a network timeout inside the request. Lesson: telemetry must never block the work, and its failure must not be your failure. (Check how long the exporter’s default timeout is in your version.)
  4. Average the p99s. Run two copies of the server on ports 8000 and 8001, plant the fault in only one, send load to both, and add a Grafana panel with the “wrong” query from The Idea next to the right one. Measure the gap. Then write a cause alert, “CPU above 80%”, and check it against your runs: it fires for none of the three faults, because every one of them is time spent waiting, not computing. On the Spark, the equivalent “GPU above 90%” would fire on every healthy benchmark, because a busy inference server is supposed to be busy. Missed real problems plus constant false alarms: that’s alert fatigue in one panel.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. What do logs, metrics and traces each answer, and why not just pick one?

Logs answer “what exactly happened in this event”: full detail, one line per event, searchable by any field, but expensive at volume and they don’t add up into trends easily. Metrics answer “how much, how often, how fast, over time, across everything”: a few counters and histograms, cheap to keep forever and to sum across machines, but they forget individual requests, so they can tell you p99 got worse and not why. Traces answer “where did the time go in this request”: a tree of timed spans across services, with attributes. Usually sampled because they’re heavier. In an incident you use them in order: a metric alerts you (symptom), traces show you which step (location), logs and span attributes show you which requests and what they share (cause). A request id or trace id on all three is what lets you jump between them. Profiles are the fourth: which line of code inside the slow span.

2. Why can’t you average p99s across servers, and what do you do instead?

A percentile isn’t additive. The fleet’s p99 is the value 1% of all requests exceed, and that depends on how many requests each server handled and on the shape of each one’s tail, none of which the per-server p99s preserve. In Figure 2, one sick server among four pushes the true fleet p99 to about 1.2 s while the average of the four p99s says 730 ms. Counts do add, so you keep histograms (bucket counters) per server, sum the bucket counts across servers, and estimate the percentile once from the total: histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))). The estimate is only as precise as the bucket edges, so put edges at the thresholds you care about, like the SLO’s 250 ms.

3. How does a trace follow a request from one service to another?

The first service creates a trace id (128 random bits) and a span id for its current span. When it calls the next service, it adds a traceparent HTTP header containing a version, the trace id, its current span id (the parent), and flags including whether this trace is being sampled. The receiving service reads the header and starts its first span as a child of that parent, with the same trace id. Every service ships its own spans to a collector separately; the collector groups them by trace id and uses the parent ids to rebuild the tree. If any service in the middle drops the header, the trace breaks into pieces. Because the sampling decision travels in the header, every service agrees whether to record a given request, so you don’t get half-traces.

4. What is an error budget and a burn rate, and why page on burn rate rather than CPU?

An SLO says what fraction of events must be good over a window, like 99% of requests with TTFT under 250 ms over 30 days. The error budget is the rest: 1% of requests may be bad. Burn rate is how fast you’re spending it compared with spending it evenly: current bad fraction divided by 0.01. At 1 you’ll use exactly the whole budget by the end of the window; at 6, it’s gone in 5 days; at 14.4 for an hour, you’ve lost 2% of the month’s budget in that hour, which is worth waking someone. CPU is a cause, not a symptom: it’s high when the server is simply busy (fine) and normal when a database shard is slow (not fine). Paging on it gives false alarms that train people to ignore pages, and misses real problems. Burn rate measures exactly what users feel, and the size of the threshold maps directly to how urgent it is.

5. Your inference service goes from 1 server to 200, with 50 customers. How does your observability have to change?

Metrics mostly scale fine if labels stay bounded: 200 instances times a few hundred series each is tens of thousands of series, easy for Prometheus. The danger is any label that grows with customers or requests; “customer” with 50 values might be acceptable, “user” or “prompt” never is. You’d add recording rules that pre-aggregate fleet-wide histograms so dashboards don’t sum 200 instances on every refresh. Logs stop being free: 200 servers at hundreds of requests a second is terabytes a day, so you keep all errors and slow requests and sample the rest. Traces need sampling too, ideally tail sampling so every slow or failed trace survives. Per-customer questions go to trace attributes and logs. Alerts must be on the fleet SLO, not on individual machines, or one bad host pages you every night. And you’d run a profiler continuously, because with 200 machines something is always slow somewhere.

Go Deeper

Connections

This lesson stands on p99 and load testing (histograms, why percentiles don’t average, open-loop load) and uses the TTFT and ITL definitions from Baseline: my Spark vs Fireworks. The profiling pillar is the same py-spy from Why is Python slow?, and the cache-lock fault is the lock from Processes, threads and the GIL seen from outside. Saturation metrics are the queue from Queues and logs and Rate limits and backpressure, measured.

Next, it pays off everywhere you change the server. Batching and continuous batching should shrink the queue_wait span you just watched dominate; Paged attention and prefix caching gives you the KV-utilization and hit-rate gauges to watch; and Inside an agent harness is where one trace per agent run becomes the main debugging tool. Every one of those lessons is now a before-and-after on a dashboard instead of a guess.