Skip to content

FastAPI (Python)

The Python story is prometheus-fastapi-instrumentator for metrics and structlog for structured JSON logs. The agent observes both.

services:
api:
image: my-fastapi-api:latest
labels:
lumin.logs: "true"
lumin.service: "api"
lumin.metrics: "true"
lumin.metrics.port: "8000"
lumin.metrics.framework: "fastapi"
Terminal window
pip install prometheus-fastapi-instrumentator
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
app = FastAPI()
Instrumentator(
should_group_status_codes=False,
excluded_handlers=["/metrics", "/health"],
).instrument(app).expose(app, endpoint="/metrics")

This exposes http_request_duration_seconds, http_requests_total, and friends on GET /metrics. The agent maps these onto Lumin’s http.* schema when lumin.metrics.framework=fastapi is set.

Two settings to know:

  • should_group_status_codes=False keeps the raw 3-digit status code, which Lumin uses for the status label on http.errors. Setting it to True would collapse to 2xx/3xx/4xx/5xx and lose the per-code breakdown for 5xx errors.
  • excluded_handlers prevents /metrics itself and /health from being recorded — otherwise your dogfood panels count the agent’s own scrapes.

The instrumentator records the templated route (/projects/{id}) by default, not the expanded path. Don’t override that — expanded paths blow up cardinality.

Terminal window
pip install structlog
import logging
import sys
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="time"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
)
log = structlog.get_logger()
log.info("server starting", port=8000)

This writes one JSON object per line with level, time, event (the message), and any kwargs as extra fields. Lumin reads level out of the object to set the row’s level and stores the whole object as attributes. The row’s timestamp comes from Docker’s log timestamp rather than the time field, and message keeps the raw JSON line. No field is promoted to a dedicated column — session_id, span_id, and a JSON trace_id are all reachable as attr.<key>.

structlog.contextvars lets you bind a session ID for the duration of a request without threading it through every call:

import structlog
from fastapi import FastAPI, Request
app = FastAPI()
log = structlog.get_logger()
@app.middleware("http")
async def bind_session(request: Request, call_next):
sid = request.headers.get("x-lumin-session")
if sid:
structlog.contextvars.bind_contextvars(session_id=sid)
try:
return await call_next(request)
finally:
structlog.contextvars.clear_contextvars()
@app.get("/projects/{project_id}")
def get_project(project_id: int):
log.info("fetched project", project_id=project_id)
return {"ok": True}

Every log.info(...) inside a request now includes session_id.

Filter the Logs page by one session with the attr. prefix, because the field lands inside attributes rather than in a column of its own:

attr.session_id:0f8c2a41-...

Live tail honours the same term, and clicking session_id in a log row’s detail panel composes it for you. There is no clickable pivot between the Sessions page and the Logs page — see Linking browser events to server logs for the whole path, including the browser half.