# FastAPI (Python)

> prometheus-fastapi-instrumentator for metrics, structlog for JSON logs, both observed by the agent.

Canonical: https://docs.getlumin.dev/stacks/fastapi/

The Python story is [`prometheus-fastapi-instrumentator`](https://github.com/trallnag/prometheus-fastapi-instrumentator) for metrics and [`structlog`](https://www.structlog.org/) for structured JSON logs. The agent observes both.

## Labels on your container

```yaml
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

```sh
pip install prometheus-fastapi-instrumentator
```

```python
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.

## Structured logs

```sh
pip install structlog
```

```python
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>`.

## Propagating browser session IDs

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

```python
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](https://docs.getlumin.dev/stacks/browser/#linking-browser-events-to-server-logs) for the whole path, including the browser half.
