# Add Request ID Logging to a FastAPI App

Yes. An agent adds a request ID middleware to your FastAPI app, threads the ID through every log line and the response header, tests it, and opens a PR.

[Run this on Rush](https://cloud.getrush.ai/run/add-request-id-logging/fastapi/), in a Project that has your repository.

## What the agent does

The agent starts by reading how the app is assembled and how it logs today, because a request ID is only useful if every log line carries it. In an app like `testdrivenio/fastapi-tdd-docker`, `project/app/main.py` builds the app in `create_application()`, includes the `ping` router and the `summaries` router under `/summaries`, and calls `init_db`; `app/config.py` and `app/db.py` each take `logging.getLogger("uvicorn")` and log through it, so the app has no logger of its own and no log configuration. `requirements.txt` pins `fastapi 0.115.12`, `uvicorn 0.34.1`, `gunicorn 22.0.0` and `tortoise-orm 0.25.0`. `docker-compose.yml` runs `uvicorn app.main:app --reload --workers 1`, and `Dockerfile.prod` runs `gunicorn` with `uvicorn.workers.UvicornWorker`, which means the uvicorn access log is what prints each request in both setups.

It also reads the parts that run outside a request handler. `app/api/summaries.py` queues `generate_summary` on `BackgroundTasks` after `POST /summaries/`, and that function in `app/summarizer.py` downloads an article and may fetch NLTK data, which is exactly the work a support engineer wants to tie to the request that started it. `tests/conftest.py` builds the app with `TestClient` and overrides `get_settings`, `tests/test_summaries.py` monkeypatches `generate_summary`, and `Dockerfile.prod` and `.github/workflows/main.yml` run `flake8` with the 119-column limit from `setup.cfg`, `black` and `isort`, so any new module has to pass all three.

From that the agent adds `project/app/request_id.py`: a `ContextVar` holding the current ID, a pure ASGI middleware that reads `X-Request-ID` from the incoming request, accepts it when it is short and made of safe characters and otherwise generates a `uuid4`, sets the variable for the rest of the call, and adds the same `X-Request-ID` header to the response. Because the middleware wraps the whole ASGI call, the variable is still set while Starlette runs the background task after the response, so `generate_summary` logs with the right ID. A `logging.Filter` copies the variable into every record as `request_id`, with `-` when no request is active, and a `dictConfig` applied in `create_application()` gives the `uvicorn`, `uvicorn.error` and a new `app` logger one format that includes it. The uvicorn access line cannot carry the field, so the middleware logs its own line with method, path, status, duration and ID, and the compose command and the gunicorn command gain the flag that turns the uvicorn access log off.

Then it proves it. `tests/test_request_id.py` uses the existing `test_app` fixture: a request without the header gets a UUID back, a request with a valid header gets the same value back, an oversized or unsafe header is replaced, and with `caplog` a record logged inside a handler carries the ID while one logged before any request carries `-`. A test on `POST /summaries/` with the monkeypatched `generate_summary` replaced by one that logs confirms the background task sees the ID. It runs `pytest` with the coverage flags from `.coveragerc`, `flake8 .`, `black --check .` and `isort --check-only .` the way `Dockerfile.prod` does, and fixes anything until all four pass.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Add request ID logging to this FastAPI app. Read project/app/main.py, project/app/config.py, project/app/db.py, project/app/api/summaries.py, project/app/summarizer.py, project/tests/conftest.py, project/tests/test_summaries.py, project/requirements.txt, project/setup.cfg, project/.coveragerc, project/Dockerfile.prod, docker-compose.yml and .github/workflows/main.yml first. Add a pure ASGI middleware that reads X-Request-ID, accepts it only when it is short and made of safe characters and otherwise generates a uuid4, stores it in a ContextVar for the whole call including background tasks, and sets X-Request-ID on the response. Add a logging filter that puts the ID on every record and a dictConfig applied when the app is created so the uvicorn loggers and an app logger share one format with the ID. Log one access line per request from the middleware with method, path, status, duration and ID, and turn off uvicorn's own access log in the compose command and the gunicorn command. Write tests with the existing fixtures for the generated ID, an echoed valid ID, a rejected invalid ID, the ID on log records via caplog and the ID inside a background task. Run pytest, flake8, black --check and isort --check-only in the sandbox, fix anything until they pass, and open a pull request that documents the header and the log format.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on its own branch with `app/request_id.py`, the middleware and logging setup in `main.py`, the two command changes in `docker-compose.yml` and `Dockerfile.prod`, and `tests/test_request_id.py`. The description documents the `X-Request-ID` contract, the validation rule for an incoming value, the log format with an example line, the `-` placeholder outside requests, and the fact that background tasks inherit the ID.

The transcript on the session keeps every command with its output: the pytest run with the new tests and the coverage summary, `flake8`, `black --check`, `isort --check-only`, and a `docker compose up` followed by `curl` calls showing the response header and the matching log lines, including one from the background task. The repository's own workflow runs on the pull request too, building the production image and running the same four checks inside the container.

Before merging, a reviewer checks that the log format is what the team's log pipeline parses, that the header name matches what the load balancer already injects so one ID spans the proxy and the app, that the access line does not duplicate anything the proxy logs, and that nothing sensitive from the URL lands in the line. One follow-up message to the same session switches the format to JSON if the pipeline prefers it.

## Run it on every Linear issue labelled observability

A team that tracks operational work in Linear starts one run per issue from an automation with a `linear` trigger on the `observability` label. The automation adds the issue after this prompt, so the agent reads the header name and log format the issue asks for and starts in the repository the automation names.

Each run opens its own pull request from its own sandbox, and an automation run has its own `max_runs_per_hour` cap. The pull request links back to the issue, and the transcript holds the test run and the sample log lines for that change.

## Why run it on Rush?

The subtle part of this job is the context: a request ID set in a middleware has to survive into the background task that runs after the response, show up in records from loggers the app did not create, and disappear cleanly when the request ends. Getting that wrong is invisible until someone reads logs in production. In a Rush sandbox the agent runs the real stack from the compose file, sends requests and reads the lines back, and the sandbox parks when the turn ends.

The pull request carries its own proof. The response header, the log lines with the same ID including the one from the background task, and the green lint and test runs are in the transcript beside the commands that produced them, so a reviewer can see the ID propagate rather than take a diagram's word for it.

## Frequently asked questions

### Why a pure ASGI middleware instead of @app.middleware("http")?

Because `BaseHTTPMiddleware` runs the handler in a separate task, which complicates context variables and background tasks. A pure ASGI middleware wraps the single call, so the `ContextVar` set at the start is visible in the handler, in the dependency functions and in the `BackgroundTasks` that Starlette runs after the response.

### Will it accept any X-Request-ID a client sends?

No. The middleware accepts an incoming value only when it is within a length limit and made of letters, digits and a few separators, and generates a UUID otherwise. That keeps a client from injecting newlines or very long strings into the logs while still letting a load balancer's ID flow through.

### What happens to the uvicorn access log?

It is turned off in both start commands, because its format cannot include the request ID. The middleware logs one line per request in its place with method, path, status, duration and the ID, in the same format as every other line, so nothing is lost and every line is correlated.

### Does the background summary job log with the request's ID?

Yes. Starlette runs background tasks inside the same ASGI call the middleware wraps, so the `ContextVar` is still set, and a test confirms it by replacing `generate_summary` with a function that logs and asserting the record carries the ID from the response header.
