FastAPI (Python)
The Python story is prometheus-fastapi-instrumentator for metrics and structlog for structured JSON logs. The agent observes both.
Labels on your container
Section titled “Labels on your container”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"Emit Prometheus metrics
Section titled “Emit Prometheus metrics”pip install prometheus-fastapi-instrumentatorfrom fastapi import FastAPIfrom 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=Falsekeeps the raw 3-digit status code, which Lumin uses for thestatuslabel onhttp.errors. Setting it toTruewould collapse to2xx/3xx/4xx/5xxand lose the per-code breakdown for 5xx errors.excluded_handlersprevents/metricsitself and/healthfrom 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.
Structured logs
Section titled “Structured logs”pip install structlogimport loggingimport sysimport 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>.
Propagating browser session IDs
Section titled “Propagating browser session IDs”structlog.contextvars lets you bind a session ID for the duration of a request without threading it through every call:
import structlogfrom 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.