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, when the framework records one.
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.
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.

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}.

There is no standard ASGI key for it, so each framework is read the way it records it. FastAPI and Litestar both do. Plain Starlette records no template, so the field is left out rather than guessed from the values that filled it, which is what OpenTelemetry's own ASGI instrumentation does with the same problem.

Framework http.route
FastAPI Yes
Litestar Yes
Starlette Left out

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",))