Structured logging for LLM services
Design a log schema that answers real operational questions: one JSON event per request, correlation IDs that follow a request across gateway, engine and tools, the privacy rules for prompts, log levels, sampling and volume maths, with FastAPI code you can adapt.
Logs are the oldest observability tool and the one most often done badly. A typical first attempt is a pile of print lines: unstructured, uncorrelated, and either far too chatty or silent exactly when you need them. For an LLM service, where one request touches a gateway, retrieval, a GPU server and maybe several tools, good logging is the difference between a ten-minute diagnosis and a lost afternoon.
This lesson designs a logging approach from the questions you need to answer.
#Start from the questions
Before choosing a library, write the questions you will ask during an incident or a cost review:
| Question | Fields it needs |
|---|---|
| Which requests failed in the last hour, and why? | status, error_type, timestamp |
| Did the new release make TTFT worse? | release_id, ttft_ms |
| Which tenant is driving cost? | tenant_id, prompt_tokens, completion_tokens |
| Is the prefix cache working? | cache_hit_tokens, prompt_tokens |
| Where did this one slow request spend its time? | request_id, queue_ms, prefill_ms, decode_ms, tool_ms |
| Did the user cancel, or did we time out? | finish_reason, client_disconnected |
| Which model served it, with which settings? | model, model_release, temperature, max_tokens |
If a field answers no question you would actually ask, do not log it. If you cannot answer a question you care about, add the field.
#Logs, metrics and traces: the division of labour
| Signal | Best at | Weakness |
|---|---|---|
| Metrics | Trends, alerts, dashboards, cheap at any volume | No per-request detail |
| Logs | The full story of individual events, ad hoc search | Expensive at volume; hard to aggregate unless structured |
| Traces | Where time went inside one request across services | Sampled; needs instrumentation |
Use metrics to notice a problem, traces to locate it and logs to explain it. All three share a correlation ID so you can jump between them (observability overview).
#One event per request
The most useful pattern for a request/response service is the wide event (also called a canonical log line): at the end of each request, emit a single JSON record containing everything you know about it.
{
"ts": "2026-11-11T09:14:22.381Z",
"level": "info",
"event": "request_completed",
"service": "gateway",
"request_id": "7b1f4c2e9a",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"route": "/v1/chat/completions",
"tenant": "acme",
"key_id": "k_42",
"model": "support-8b",
"release": "support-bot-2026-11-03",
"stream": true,
"prompt_tokens": 1810,
"completion_tokens": 212,
"cache_hit_tokens": 1500,
"queue_ms": 35,
"ttft_ms": 410,
"decode_ms": 2380,
"total_ms": 2825,
"finish_reason": "stop",
"status": 200,
"retries": 0,
"replica": "llm-3",
"client_ip_hash": "a91c..."
}Why one event beats many lines:
- One place to look. Instead of ten lines you must stitch together, you get the answer in one record.
- Cheap to aggregate. Counting tokens per tenant is a field sum, not text parsing.
- Predictable volume. One event per request means volume scales with traffic, not with how many log statements developers added.
Add more events only for things that are not a request summary: startup and shutdown, model load and unload, config changes, circuit breaker transitions, scaling events, rejected requests.
#A schema you can keep stable
Treat log fields like an API. Rules that save pain:
- Consistent names across services.
request_ideverywhere, notreqIdin one service andrequest-idin another. - Fixed types.
ttft_msis always an integer; never sometimes a string like"410ms". - Units in the name (
_ms,_bytes,_tokens). - Few, bounded enumerations for
level,event,finish_reason,status. - A version field (
schema: 3) so downstream queries can adapt. - Document it in the repository, and review changes like code.
- Never log secrets. No API keys, tokens, passwords or full authorisation headers. Log a key ID, not the key.
#Correlation: follow the request
Every request needs an ID created at the edge and passed to every downstream call:
- Accept an incoming
X-Request-IDheader if it is well-formed, otherwise generate one. - Pass it (and the W3C
traceparentheader if you use tracing) to the model server, retrieval service and tools. - Return it in the response headers, so a user's bug report can include it.
- Include it in every log line the request produces, including errors from deep inside libraries.
In Python, contextvars carry the ID through async code without threading it through every function:
import contextvars, json, logging, time, uuid
from fastapi import FastAPI, Request
request_id_var = contextvars.ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record):
base = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) + f".{int(record.msecs):03d}Z",
"level": record.levelname.lower(), "msg": record.getMessage(),
"request_id": request_id_var.get()}
base.update(getattr(record, "fields", {})) # structured extras
if record.exc_info:
base["error_type"] = record.exc_info[0].__name__
return json.dumps(base, separators=(",", ":"))
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
log = logging.getLogger("gateway"); log.addHandler(handler); log.setLevel(logging.INFO)
app = FastAPI()
@app.middleware("http")
async def request_log(request: Request, call_next):
rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]
token = request_id_var.set(rid)
t0 = time.perf_counter()
status, extra = 500, {}
try:
response = await call_next(request)
status = response.status_code
response.headers["x-request-id"] = rid
return response
finally:
log.info("request_completed", extra={"fields": {
"event": "request_completed", "route": request.url.path, "status": status,
"total_ms": int((time.perf_counter() - t0) * 1000),
"tenant": getattr(request.state, "tenant", None), **getattr(request.state, "usage", {}), **extra}})
request_id_var.reset(token)The handler that fills request.state.usage (token counts, cache hits, timings) adds those fields to the same event. For streaming responses, emit the event when the stream ends (or is cancelled), since that is when TTFT, decode time and completion tokens are known.
#What to log for each hop
| Component | Events worth logging | Key fields |
|---|---|---|
| Gateway | request_completed, rejected (rate limit, auth), retry, fallback, circuit state | request_id, tenant, status, upstream, retries, rejection reason |
| Retrieval | retrieval_completed | k, latency_ms, no_result, index_version, filter, doc IDs (not text) |
| Model server | request_completed, model_loaded, oom, preemption | tokens, queue/prefill/decode ms, finish_reason, batch size |
| Tools | tool_call | tool, duration_ms, status, error_type, argument shape (not values) |
| Orchestrator / agent | step_completed | step number, tool chosen, tokens, stop reason, loop detected |
| Scheduler / autoscaler | scale_decision | metric value, desired vs current replicas |
#The prompt question
The most consequential logging decision for an LLM service is whether to record prompt and completion text.
Why teams want to: debugging bad answers, building evaluation sets, understanding user needs.
Why it is risky: prompts contain personal data, secrets pasted by users, confidential documents and sometimes credentials. A log store is typically readable by more people, kept longer and backed up more widely than your primary database (data leakage, PII, compliance).
A policy that works for most teams:
- Default: metadata only. Lengths, token counts, hashes, language, detected categories, flags such as
contains_pii_detected. - Sampled content with redaction (for example 1 percent), stored in a separate, access-controlled, short-retention index.
- Full content on demand for a single request ID, with an explicit reason and an audit record, for support cases.
- Always off for sensitive tenants and categories.
- Hash for dedupe. A hash of the prompt lets you spot repeated prompts and cache opportunities without storing the text (use a keyed hash so it cannot be reversed by guessing).
Whatever you choose, write it down, tell users, and make it enforceable in code (a single log_content() function guarded by policy), not by convention.
#Levels: what each means
| Level | Use | Production policy |
|---|---|---|
| ERROR | A request failed or an invariant broke; someone may need to act | Always on; alert on rates, not individual lines |
| WARN | Degraded but handled: retry, fallback, near-limit | On |
| INFO | Request summaries and lifecycle events | On, one event per request |
| DEBUG | Internals: prompts, headers, intermediate values | Off; enable per request ID, per tenant or temporarily |
A per-request debug switch is worth building: an internal header or a tenant flag that raises the level for that request only, with the debug output tagged and short-lived.
#Volume, sampling and cost
Do the arithmetic early:
events/day = requests/s ร 86,400
bytes/day = events/day ร bytes per event| Traffic | 1 KB events | Per day | Per 30 days |
|---|---|---|---|
| 10 req/s | 0.86 GB | 26 GB | |
| 100 req/s | 8.6 GB | 260 GB | |
| 1,000 req/s | 86 GB | 2.6 TB |
At 1,000 requests per second a verbose schema is a real bill. Levers:
- Trim fields. A 1 KB event is generous; most can be 300 to 500 bytes.
- Sample successes, keep all failures. Keep 100 percent of errors, slow requests and rejected requests; keep, say, 10 percent of fast successes, and record the sample rate in each event so counts can be re-weighted. (Metrics still count everything.)
- Tier retention. Keep detail for 7 days, aggregated rollups for months.
- Compress. JSON compresses 5 to 10 times; most log stores do it for you.
- Do not log inside hot loops. Per-token log lines are an anti-pattern: 300 tokens ร 1,000 req/s is 300,000 lines per second. If you need token-level timing, record it as a histogram or a few summary fields.
#Logging errors usefully
An error line should answer what failed, for whom, and what was tried:
{"level":"error","event":"upstream_failed","request_id":"7b1f4c2e9a","upstream":"llm-3",
"error_type":"ConnectTimeout","timeout_ms":2000,"attempt":2,"will_retry":true,"fallback":"llm-1"}- Log once per failure at the point where it is handled, not at every layer that re-raises it (this produces ten lines for one error).
- Include error type and a stable error code, not only the message, so you can count by cause.
- Include what you decided to do (retry, fall back, give up), since that is what an on-call engineer needs.
- Keep stack traces for unexpected exceptions; do not attach them to routine, expected errors such as client disconnects.
- Remove any content that came from the prompt: exception messages from validation libraries sometimes echo input.
#Logging for streaming and long requests
Streaming makes timing richer and logging trickier:
- Record
ttft_mswhen the first token is sent, andtotal_msat the end. - Record
finish_reason:stop,length,client_disconnect,timeout,error. - A client that disconnects mid-stream still produced work. Log it, along with
completion_tokensso far, so you can quantify wasted GPU time (reliability patterns). - For agent runs lasting minutes, log a start event, one event per step and an end event with a run ID, rather than a single event at the end that may never arrive if the process dies.
#Testing your logging
Logging code is code. Test it:
- Schema tests: parse a sample of events and check required fields and types.
- A "no secrets" test: run requests containing fake keys and card numbers through the service and assert they never appear in log output (a canary approach, data leakage).
- Correlation test: send a request with a known ID and confirm every service logs it.
- Volume test: during load tests, measure log bytes per request and extrapolate (load testing).
- Failure test: kill the log destination and confirm the service does not block or crash. Logging must never take down serving; use a bounded buffer and drop on overflow, counting drops in a metric.
#Where logs go
On containers, write logs to stdout as JSON and let the platform collect them (Docker deployment). Do not write log files inside containers, and do not make the application responsible for shipping them. The next lesson covers collectors, storage, redaction, retention and cost.
#Common mistakes
- Free-text logs that no query can aggregate.
- No request ID, so an incident means matching timestamps by eye.
- Logging prompts by default.
- Per-token or per-chunk log lines.
- Different field names in each service.
- Logging the same error at every layer.
- Unbounded log volume with no sampling or retention plan.
- Logging that blocks the request path.
- Secrets in logs, including in URLs and exception messages.
#Exercise
Your gateway handles 200 requests/s. Each event is 800 bytes. Failures are 2 percent of traffic and you want to keep all of them plus 10 percent of successes.
- How much log data per day without sampling?
- How much with sampling?
- What must each event record so your dashboards still count correctly?
Answers:
200 ร 86,400 ร 800 B = 13.8 GBper day.- Kept fraction
0.02 + 0.98 ร 0.10 = 0.118, so about1.6 GBper day, an 88 percent reduction. - A
sample_ratefield (1.0 for kept failures, 0.1 for sampled successes), so counts from logs can be re-weighted. Dashboards for totals and rates should come from metrics, which count every request regardless of log sampling.
#Going deeper
- Add the wide event to your gateway and compare how long an incident query takes before and after.
- Continue with log pipelines, cost and retention.
- Pair logs with metrics and tracing.
Quick quiz
+10 XP per correct answer, +25 XP for a perfect score
1. Why log one structured JSON event per request instead of free-text lines?
2. What is the main job of a request ID (or trace ID) in your logs?
3. A service handles 1,000 requests/s and writes a 1 KB log event per request. About how much log data per day?
4. What is the safest default for logging prompts and completions in production?
5. Which log level policy works best for a high-traffic inference gateway?