Skip to content

Logging

One JSON object per line, on standard output. Every record — the service's own and every third-party library's — passes through the same processing chain, so there is exactly one log format to parse.

Standard output is the source of truth. It survives a telemetry collector outage, and it captures crashes and any failure that happens before other telemetry has started. A platform log agent reads it and forwards it.

Two renderings

APP_ENVIRONMENT decides the format.

local gives colourised, aligned, human-readable lines:

2026-09-01T14:00:52.697Z [info     ] Application startup complete. [uvicorn.error] service.name=reference-service service.version=0.1.0 deployment.environment=local

Anything else gives one JSON object per line:

{"http.request.method":"POST","http.route":"/api/v1/orders","http.response.status_code":201,"duration_ms":2.787,"event":"http.access","correlation_id":"9f05f3abb60643b886eaa4b1f867a252","service.name":"reference-service","service.version":"0.1.0","deployment.environment":"production","logger":"reference_service.access","level":"info","timestamp":"2026-09-01T13:59:33.614725Z"}

Fields on every record

Field Meaning
event The event name. A stable identifier, not a sentence — http.access, not "Request finished in 3ms".
timestamp ISO 8601, in UTC.
level debug, info, warning, error, or critical.
logger Which logger emitted the record.
service.name From APP_SERVICE_NAME. Without it you cannot separate one service's records in a shared backend.
service.version The package version. Tells you which release produced a line during a rollout.
deployment.environment From APP_ENVIRONMENT.
trace_id The active trace, as 32 hexadecimal digits. Present only on records emitted inside a span, which is why a startup line does not carry one. Links a log line to its trace in Tempo.
span_id The active span, as 16 hexadecimal digits. Same condition.
correlation_id Present on every record emitted during a request.

The three service.* and deployment.* names follow OpenTelemetry's semantic conventions — agreed standard names for telemetry fields, so a dashboard written for one service works for another.

Correlation identifiers

Every request gets one identifier. It is bound for the duration of the request, so every log line that request produces carries it, including lines from libraries that know nothing about it.

The middleware reads the X-Request-ID request header and uses that value if present, or generates one if not. Either way the value is echoed in the X-Request-ID response header.

That is what makes a support report actionable. Someone reports a failure and quotes the header value; you filter the logs by correlation_id and see every line that request produced, including the traceback.

Send your own to follow one logical operation across several services:

curl -H 'X-Request-ID: my-trace-42' http://localhost:8000/api/v1/orders/...

The access log

One structured record per request, emitted by the service rather than by uvicorn. uvicorn's own access log is plain text and is switched off (--no-access-log).

{
  "event": "http.access",
  "http.request.method": "POST",
  "http.route": "/api/v1/orders",
  "http.response.status_code": 201,
  "duration_ms": 2.787,
  "correlation_id": "9f05f3abb60643b886eaa4b1f867a252"
}

http.route, not the raw path

The field records the route template — /api/v1/orders/{order_id} — not the path that was requested — /api/v1/orders/cac0acf8-....

The reason is cardinality: how many distinct values a field can take. The raw path produces one distinct value per order id, without limit. That is the usual way a log or metrics backend is overwhelmed, and it makes the field useless for grouping — you cannot ask "how slow is fetching an order?" if every request is its own unique path.

The template has one value per route. It groups, and it is bounded.

When nothing matches a route — usually a bot probing URLs that do not exist — the field records the literal <unmatched> rather than the raw path, for the same reason.

One known limitation

If a router is ever mounted under a parameterised prefix, such as /tenants/{tenant_id}, that prefix is recorded as-is rather than as a template, so that segment is not bounded. Every prefix in the reference service today is a fixed string.

Health endpoints are excluded

/healthz, /readyz and /startupz produce no access-log record. An orchestrator probes them every few seconds forever; logging that would bury real traffic in noise and cost money to store.

Per-logger levels

Silencing a chatty library is configuration, not a code change:

APP_LOG__LEVELS='{"httpx": "warning"}'

The keys are logger names, which appear in the logger field of the records you want to quieten.

Redaction

A field is masked by name, at any depth of nested objects and lists, ignoring case and treating - and _ alike. The masked value becomes the literal string [REDACTED].

The redactor runs last in the shared processor chain (_shared_processors in observability/logging.py), so it sees the fully assembled record — including values middleware bound into context earlier in the request. That same chain is also the foreign_pre_chain that formats standard-library records, so a third-party library's log line is masked by the same rule. The OTLP export leg masks a record's extra= attributes with the same rule, through a filter on that handler. That beats redacting at each call site: the one call site where someone forgets is the one that matters.

The default field names are access_token, api_key, apikey, authorization, card_number, cookie, cvv, passwd, password, refresh_token, secret, secret_access_key, secret_key, set_cookie, and token. APP_LOG__REDACT_FIELDS replaces this list rather than adding to it — see Configuration.

log.info("user.login", user="ann", password="hunter2")

produces (other fields every record carries are omitted here for clarity):

{"event": "user.login", "user": "ann", "password": "[REDACTED]"}

Matching is by key name only. A secret interpolated into the message string, as in log.info(f"token {token}"), is not something a key-name rule can see — nothing reads message text. SecretStr and the startup path's include_input=False close the other two ways a secret could reach one of our own records as a value. Put secrets in fields, never in the event string.

See ADR 0013.

Exceptions are one record

An exception becomes a single structured field containing the whole stack, rather than thirty unrelated lines that a backend then shows out of order. The record carries the correlation identifier, so a 500 response can be traced directly to its traceback.

Testing note: caplog does not work

Logging setup clears the root logger's handlers, which also removes the one pytest's logging plugin installs. Any test that builds the application gets nothing in caplog, even with caplog.at_level(...).

Assert on captured standard output instead — capsys.readouterr().out, parsed with json.loads one line at a time. Every logging test in the reference service already does this; copy one of them.