Skip to content

Access Log

One structured record per HTTP request, written by grelmicro.

Uvicorn already writes an access line, and it carries what uvicorn knows: the socket peer, the request line, and the status. Behind an ingress the socket peer is the ingress, which makes that line useless for the question people ask an access log. This one carries what the app knows.

from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.log import AccessLog, Log
from grelmicro.security import ClientAddressMiddleware, TrustedProxies

micro = Grelmicro(uses=[Log(), AccessLog()])

app = FastAPI()
app.add_middleware(
    ClientAddressMiddleware, trusted=TrustedProxies(["10.0.0.0/8"])
)
micro.install(app)

That is the whole setup. micro.install(app) adds the middleware, and every request writes a record like this:

{
  "time": "2026-09-04T15:02:00.115116+00:00",
  "level": "INFO",
  "msg": "GET /orders/7 200",
  "logger": "grelmicro.access",
  "trace_id": "e3c64457486c59d0ba764839dc404da5",
  "span_id": "92495292bd9b0710",
  "http.request.method": "GET",
  "url.path": "/orders/7",
  "url.query": "token=***&page=***",
  "url.scheme": "http",
  "http.route": "/orders/{order_id}",
  "http.response.status_code": 200,
  "client.address": "203.0.113.9",
  "user_agent.original": "curl/8.4",
  "network.protocol.version": "1.1",
  "http.server.request.duration": 0.000171
}

The field names

They are OpenTelemetry semantic conventions, the same names the request span carries. A backend reads one vocabulary across the log and the trace instead of a mapping between two, and trace_id and span_id are on the record already, so a line joins the span it belongs to.

Field What it carries
http.request.method The method.
url.path The path the caller asked for, mount prefix and all.
url.query Query parameter names with every value redacted. Turn it off with query=False.
url.scheme http or https.
http.route The route template, on an app install wired.
http.response.status_code The status the caller got.
client.address The caller, resolved.
user_agent.original The User-Agent header. Turn it off with user_agent=False.
enduser.id Who called: the subject of an authenticated caller. Turn it on with enduser=True.
network.protocol.version 1.1, 2, and so on.
http.server.request.duration Seconds, the unit the matching metric uses.
error.type The exception a handler raised, when one did.

The User-Agent is the only header on the record, and user_agent=False drops it. Nothing else a request carries is logged, so an Authorization header, an API key header and a cookie cannot leak through here. A query parameter can carry a credential under any name, so the record keeps parameter names for diagnosis and masks every value: token=secret&page=2 reaches the sink as token=***&page=***.

The caller, not the proxy

client.address is the address ClientAddressMiddleware resolved, which is why the example registers it. Without it there is nothing to resolve from and the record falls back to the transport peer, which behind an ingress is the ingress. Which forwarded headers are believed is a trust decision, so it stays where that decision is made.

Who called

AccessLog(enduser=True)

The record then carries enduser.id, the subject of the caller AuthenticatedRequests verified. One line says who asked for what, from where, and what they got.

The caller is read from request.user, so your own authentication is logged too when its user has is_authenticated set to True and a non-empty string subject. A user missing either is not written. Starlette's SimpleUser has no subject, and a display name is not an identifier.

  • It is off by default. A subject can be personal data, such as an email address some providers use as sub, so writing it is a decision you make.
  • Only an authenticated caller naming a subject is written. A refused request carries none: a token that did not verify names nobody, whatever it claims.
  • A subject is unique within the issuer that assigned it, and a service trusts one issuer unless its verifier accepts several.

The level follows the answer

Answer Level
5xx, or a handler that raised ERROR
4xx WARNING
Anything else INFO
A quiet path that answered DEBUG
A caller that hung up, or a shutdown mid-request DEBUG
No response at all, and no exception DEBUG

The last two carry no http.response.status_code, because nothing was sent. A cancelled request is the caller leaving or the process stopping, which is not the service failing, and on a busy port reporting each one as an error would bury the failures that are.

Kubernetes polls /livez, /readyz and /healthz every few seconds for the life of the pod, and Prometheus scrapes /metrics as often. At INFO they crowd out every request a person wanted to read, so those four paths are quiet by default: their record is written at DEBUG, where it is there when someone goes looking and absent from the stream a person reads.

Those are the four paths exactly. A router mounted under a prefix answers /internal/livez, which is a different path, so name it: quiet=("/internal/*",).

A probe that fails is logged like any other failure. A refused readiness check is often the only line in the log saying the kubelet asked and was turned away, so it is the one worth keeping.

from grelmicro import Grelmicro
from grelmicro.log import AccessLog, Log

micro = Grelmicro(
    uses=[
        Log(),
        AccessLog(
            exclude=("/internal/*",),
            quiet=("/livez", "/readyz", "/healthz", "/metrics", "/ping"),
        ),
    ]
)

quiet=() logs the probes like anything else. exclude= is the stronger word: an excluded path writes nothing at any level, whatever it answered. include= narrows to the paths you name, and a pattern ending in * matches as a prefix.

A pattern ignores the prefix a proxy adds, so it is the same whether the service is served at the root or behind root_path=/api. A mount is different: the path is read as the request arrives, before the mount has written its prefix anywhere, so an app mounted inside another one is matched by the path the caller asked for. Name /api/livez there, not /livez.

Uvicorn's access log

Registering AccessLog() silences uvicorn.access for as long as the app is open. Two access logs on one stream is worse than either alone, and the one grelmicro writes is the one carrying the resolved caller, the route and the trace context.

Nothing else about uvicorn's logging changes: its error and startup records still go through the formatting grelmicro applies. Without AccessLog(), uvicorn's access log is untouched and ProbeFilter is still the way to drop probe noise from it.

An app that serves no HTTP is never asked for the middleware, so its uvicorn access log is left alone: taking it away would leave that app with neither.

Where it sits

The middleware wraps outside the middleware your app added itself, so a request an outer layer refuses is still recorded, with the status it was refused with, and http.server.request.duration covers what the caller waited for rather than what the handler took. It answers no request of its own, so it cannot be the reason one skipped a layer.

Sampling

There is none, on purpose. An access log you cannot count requests from is not an access log, and a sampled line often has no span to join to, which is the thing the record is for. To cut volume, name the paths: quiet= for what should only speak up when it fails, exclude= for what should never be written. For counting requests at any volume, use the metrics the components already emit.

Route templates

http.route is the template, not the path that matched it, so a dashboard groups /orders/7 and /orders/9 under /orders/{order_id}.

It is the route the request span names, so a log line and its span group under the same route. On FastAPI, that is the route FastAPI's own request span names. A request answered before routing, such as a refused token or a CORS preflight, names the route its path would reach, and its span names none. A cached or replayed response is answered at the route, so both name it.

Framework http.route
FastAPI Yes
Litestar Yes
Starlette Yes

Without install

The middleware is pure ASGI, so an app on a framework install does not know wraps itself with it:

from grelmicro.log import AccessLogMiddleware

app = AccessLogMiddleware(app, quiet=("/healthz",))

Without install, the record names no route.