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