Metadata-Version: 2.4
Name: fastapi-request-observability
Version: 2.0.0
Summary: FastAPI middleware for request IDs, trace correlation, and structured access logs
Keywords: access logging,asgi,cloud logging,fastapi,json logging,middleware,observability,request id,structured logging,trace correlation,w3c trace context
Author: Jani Mikkonen
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Logging
Classifier: Typing :: Typed
Requires-Dist: fastapi>=0.130.0
Requires-Python: >=3.13
Project-URL: Repository, https://github.com/janisto/fastapi-observability
Project-URL: Issues, https://github.com/janisto/fastapi-observability/issues
Project-URL: Changelog, https://github.com/janisto/fastapi-observability/blob/main/CHANGELOG.md
Description-Content-Type: text/markdown

# fastapi-request-observability

[![PyPI version](https://img.shields.io/pypi/v/fastapi-request-observability.svg)](https://pypi.org/project/fastapi-request-observability/)
[![Python versions](https://img.shields.io/pypi/pyversions/fastapi-request-observability.svg)](https://pypi.org/project/fastapi-request-observability/)
[![CI](https://img.shields.io/github/actions/workflow/status/janisto/fastapi-observability/ci.yml?branch=main&label=CI)](https://github.com/janisto/fastapi-observability/actions/workflows/ci.yml)
[![Socket Badge](https://badge.socket.dev/pypi/package/fastapi-request-observability)](https://socket.dev/pypi/package/fastapi-request-observability)

Focused FastAPI middleware for request IDs, W3C trace correlation, contextual
JSON logs, and one structured access record per HTTP response.

## Why this package exists

Managed platforms such as Cloud Run already collect container output.
Applications should only need to write structured JSON to standard output
(`stdout`); the platform can handle ingestion and delivery.

Compared with sending logs through an in-process cloud logging client, this
reduces container CPU, memory, and network use by removing logging API calls,
authentication, buffering, batching, and retry work from the application. Under
sustained logging load, that reduction can provide a noticeable performance
improvement. It also avoids the dependency and maintenance cost of a cloud
logging SDK, including its configuration, credentials, and upgrades.

This package turns that simple pipeline into useful production observability.
It provides validated request IDs, strict W3C trace correlation,
request-scoped fields, and one structured terminal access record. Application
and access logs share the same correlation metadata, making all records from a
request easier to find, filter, and understand.

Cloud presets map the same logging contract to provider-oriented fields without
coupling application code to a cloud logging SDK. The package focuses on
structured logging and request correlation: it does not create spans, configure
OpenTelemetry, or ship logs to a backend.

## Why newline-delimited JSON

`JSONFormatter` creates one compact, self-contained JSON object per application
or access event. A standard-library `logging.StreamHandler` follows it with one
LF (`\n`), producing newline-delimited JSON (NDJSON, also called JSON Lines).
Custom handlers must preserve that one-object-per-line framing by appending
exactly one LF and no non-JSON text. The output is a stream of objects, never a
JSON array.

NDJSON is deliberate for production logging:

- Agents such as Vector, Fluent Bit, and Datadog can parse entries as a stream
  with bounded memory instead of waiting for a closing array bracket.
- Append-only output needs no array brackets, commas, whole-file rewrites, or
  trailing-comma coordination. Each handler emission submits one complete
  encoded line; the destination and record size determine OS-level write
  atomicity.
- A crash or interrupted final write can damage the incomplete last line, while
  previously completed lines remain independently parseable.
- Analytics systems can split large inputs on newline boundaries and process
  independent records in parallel.
- Standard tools work directly on the stream, for example
  `head -n 20 app.log | jq -r '.message'`.

Standard JSON arrays are suited to complete documents; NDJSON retains JSON's
structured fields while providing framing designed for continuous log streams.

## Package scope

It uses standard-library `logging` and pure ASGI middleware, with no exporter or
logging-framework dependency, so applications retain control of recovery,
handlers, and deployment policy.

> The PyPI distribution is `fastapi-request-observability` and the import is
> `fastapi_request_observability`. The similarly named
> `fastapi-observability` distribution is an unrelated project.

## Requirements and installation

```bash
uv add fastapi-request-observability
```

Python 3.13 or newer and FastAPI 0.130.0 or newer are supported. The Python
compatibility window follows the latest two stable feature releases.

## Complete setup

When this documentation shows one configuration, it uses GCP. Complete
runnable GCP, provider-neutral, AWS, and Azure applications are available in
the public [`examples`](https://github.com/janisto/fastapi-observability/tree/main/examples).

```python
import logging
import sys

from fastapi import FastAPI
from fastapi_request_observability import (
    AccessLogConfig,
    AccessLogMiddleware,
    JSONFormatter,
    LoggingPreset,
    RequestContextMiddleware,
)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter(LoggingPreset.GCP))

root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)

app = FastAPI()

# FastAPI applies the last-added middleware first on requests. Add access
# logging first, then request context, so context remains bound during access
# record emission.
app.add_middleware(
    AccessLogMiddleware,
    config=AccessLogConfig(
        logger=logging.getLogger("http.access"),
        preset=LoggingPreset.GCP,
    ),
)
app.add_middleware(RequestContextMiddleware)


@app.get("/items/{item_id}", operation_id="get_item")
async def get_item(item_id: str) -> dict[str, str]:
    logging.getLogger(__name__).info("loading item", extra={"item_id": item_id})
    return {"item_id": item_id}
```

Normal application loggers inherit request fields when their handler uses
`JSONFormatter`. The package does not replace root handlers or configure
Uvicorn. If the structured access record replaces Uvicorn's access line, run
Uvicorn with `--no-access-log` or explicitly disable the `uvicorn.access`
logger in the application's logging configuration.

## Request and trace context

`RequestContextMiddleware` accepts a single `X-Request-ID` containing 1–128
ASCII URI-unreserved characters (`A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`). A
missing, duplicate, empty, oversized, or invalid value is replaced with 128
bits of randomness. The selected value is available from:

- `request_id()`;
- `correlation_id()` and `trace_context()`;
- `current_request_context()`;
- `request.state.request_id`;
- the response `X-Request-ID` header;
- application and access logs.

The response header, input headers, generator, and validator are configurable
with `RequestContextConfig`. A custom generator is called once. Its result is
validated; an invalid result or exception falls back to the package's safe
format. Without a custom validator, caller IDs use the same URI-unreserved
baseline. A custom validator
may admit broader RFC 9110 field content inside this adapter's ASCII response
boundary, including punctuation, internal space or tab, and values longer than
128 characters. Empty values, edge whitespace, other controls, DEL, and
non-ASCII remain outside this adapter's exact response-header boundary.
Generated and fallback IDs always retain the package baseline.
Accessors return `None` outside a request; no background-job context is
manufactured.
Invalid or empty configured HTTP header names fail immediately when the config
object is constructed; use `inject_response_header=False` to disable response
header injection.

`traceparent` parsing defaults to the pinned W3C Trace Context Level 1
Recommendation. Invalid, duplicate, uppercase, zero-ID, or unsafe native field
values are ignored. Version `00` must be exactly 55 characters; future versions
follow W3C extension framing, treat every dash-delimited suffix as opaque, and
have no package-invented length ceiling.
`tracestate` field-lines are combined in wire order,
canonicalized by removing HTTP optional whitespace around members, and retained
only when their selected-level key/value grammar, unique-key rule, and
32-member limit are valid. The package can propagate at least 512 characters
and admits valid values beyond that minimum, including the 513-character
boundary. Empty members are valid and count toward the member limit. An invalid
`tracestate` does not invalidate an otherwise valid `traceparent`. When the
trace is valid, its trace ID is the correlation ID; otherwise the request ID is.

Level 2 is an explicit opt-in. Configure the same immutable level on both
middleware components when both are installed; a mismatch fails the request
deterministically instead of silently changing the emitted access fields:

```python
from fastapi_request_observability import (
    AccessLogConfig,
    RequestContextConfig,
    TraceContextLevel,
)

trace_level = TraceContextLevel.LEVEL_2
access_config = AccessLogConfig(trace_context_level=trace_level)
request_context_config = RequestContextConfig(trace_context_level=trace_level)
```

Values other than `1` or `2` fail configuration immediately. Both levels
preserve `trace_flags` and derive `trace_sampled` from bit zero. For version
`00`, Level 2 also emits `trace_id_random` from bit one. Level 1 and unknown
higher versions deliberately omit that field.

The incoming parent ID is not a span created by this application. No preset
emits it as a current span ID.

## Structured log contract

Every JSON record contains `timestamp`, `level` (`severity` on GCP), `logger`,
and `message`. Set `include_source=True` to add source file, line, and function.
Exceptions use `stacktrace`. JSON-serializable `extra` values stay structured;
unsupported values receive a deterministic type marker instead of breaking
logging. Mapping keys are normalized to JSON strings before encoding; if two
native keys normalize to the same JSON name, the first value is retained so a
record never contains duplicate raw member names.

The formatter enriches records in the thread and context where formatting
occurs. Applications using `QueueHandler` should format or copy contextual
fields before a record crosses into a listener thread. Access records snapshot
their correlation fields before emission and remain complete when formatted
later.

During a request, records also contain `request_id` and `correlation_id`. A
valid W3C context adds `trace_id`, `parent_id`, `trace_flags`, and
`trace_sampled`; configured Level 2 additionally adds `trace_id_random`.

### Terminal access record

The access record message is `request completed` and includes:

| Field | Meaning |
| --- | --- |
| `method` | HTTP method |
| `path` | Opt-in escaped wire path, without query string; omitted when ASGI exposes decoded-only path data |
| `path_template` | Matched low-cardinality route template; simple FastAPI placeholders use `{name}` or `{*name}`, while richer authoritative native syntax is preserved |
| `operation_id` | Only an explicitly configured `APIRoute.operation_id` |
| `status` | Status accepted by the downstream ASGI sender, when observed |
| `duration_ms` | Handling and streaming time in milliseconds |
| `terminal_reason` | Standard reason for abnormal completion; absent after normal completion |
| `peer_ip` | Opt-in canonical direct IP from `scope["client"][0]`; hostnames and zoned values are omitted |
| `user_agent` | Opt-in single RFC 9110 field-content value using the lossless Latin-1 mapping of ASGI header bytes |
| `error` | Opt-in privacy-sensitive exception type and message (`capture_error=True`) |

The default level is `ERROR` for abnormal completion or a normal 5xx,
`WARNING` for a normal 4xx, and `INFO` otherwise.
Field ownership is contextual and exact. Application `extra` values cannot
replace envelope, request-context, or fields owned by the active preset, but
may use access-only names, exact aliases owned only by an inactive preset, and
unrelated custom namespaces. Access callbacks
additionally cannot replace the exact fields written by access enrichment.

`AccessLogConfig` also accepts independent `capture_path`, `capture_peer_ip`,
`capture_user_agent`, and `capture_error` booleans, all defaulting to `False`;
a monotonic `clock`; a `status_level(status)` callback; and a synchronous
`extra_fields(scope)` callback. The terminal message is fixed to
`request completed` and is not configurable. Rich errors can contain secrets
and require an explicit privacy decision. Callback and logging failures use the
default behavior and cannot replace the HTTP response. When installed without
`RequestContextMiddleware`, access middleware creates default request context
and adds `X-Request-ID` itself.

`path_template` is the default aggregation key. Opt-in `path` is useful for
individual-request diagnostics and has unbounded cardinality. Query strings,
forwarded IPs, bodies, authorization, cookies, and arbitrary headers are never
logged.

FastAPI whole-segment path converters are normalized: ordinary converters such
as `{item_id:int}` emit `{item_id}`, while `{path:path}` emits `{*path}`.
Richer authoritative matched templates are preserved in native syntax rather
than replaced with request data. Unmatched requests omit route identity.

## Cloud presets

Pass the same preset to the formatter and access configuration:

```python
from fastapi_request_observability import (
    AccessLogConfig,
    JSONFormatter,
    LoggingPreset,
)

preset = LoggingPreset.GCP  # or AWS, AZURE, DEFAULT
handler.setFormatter(JSONFormatter(preset))
access_config = AccessLogConfig(
    logger=logging.getLogger("http.access"),
    preset=preset,
)
```

- `GCP` uses `severity`, `logging.googleapis.com/trace`,
  `logging.googleapis.com/trace_sampled`, and a structured `httpRequest` access
  field. `logging.googleapis.com/trace` contains the validated raw W3C trace
  ID and requires no project-ID configuration. The preset never emits a fake
  `logging.googleapis.com/spanId`. Its `httpRequest.requestUrl` is the opt-in,
  query-free path only; scheme and authority are never included.
- `AWS` adds `xray_trace_id` in `1-8hex-24hex` form. It does not create an X-Ray
  segment.
- `AZURE` adds `operation_Id` and `operation_ParentId`. It does not start or
  export Application Insights telemetry.

Provider fields correlate logs only. Trace creation and export remain the
application's responsibility.

## Diagnostics and failure boundaries

### Response and exception behavior

The middleware observes exceptions, emits once, and re-raises the original
exception unchanged. It never synthesizes a replacement 500 response.

- Handled exceptions and validation errors use the emitted status.
- An exception before an accepted `http.response.start` omits status and uses
  terminal reason `service_error`; no synthetic 500 is logged.
- Once response headers are sent, that committed status wins even if streaming
  later fails; the record uses terminal reason `body_error` and level `ERROR`.
- An `OSError` raised specifically by the downstream ASGI `send` boundary uses
  `client_disconnect` and preserves the original exception. An application or
  body-generator `OSError` remains a service or body failure.
- Cancellation uses terminal reason `cancelled`. An application that returns
  without a complete response uses `response_dropped`, with status present only
  when response start was accepted.
- Access emission occurs after the final response body event, or after the final
  response-trailers event when trailers were declared, so duration includes
  streaming and trailers but excludes later Starlette background work.
- A background-task failure does not produce a second record.
- Logging and custom-field callback failures are diagnosed to `stderr` and do
  not replace the response.

With normal `app.add_middleware` installation, Starlette's outer
`ServerErrorMiddleware` creates the final unhandled 500 after user middleware
has re-raised. Consequently, the package cannot add `X-Request-ID` to that final
500 response. The access record still contains the request ID, omits the
unobserved outer status, and reports `service_error`.

Services that require the header on the final 500 can wrap the completed
FastAPI application exported to the ASGI server:

```python
fastapi_app = FastAPI()
fastapi_app.add_middleware(
    AccessLogMiddleware,
    config=AccessLogConfig(
        logger=logging.getLogger("http.access"),
        preset=LoggingPreset.GCP,
    ),
)

# Add routes and other FastAPI middleware to fastapi_app first.
app = RequestContextMiddleware(fastapi_app)
```

This wrapper is outside FastAPI's recovery middleware and therefore observes
the final 500 response. The trade-off is that the exported `app` is an ASGI
wrapper rather than the `FastAPI` object; retain `fastapi_app` for application
configuration and test setup.

### Streaming, concurrency, and non-HTTP scopes

All request state is local to a pure ASGI `__call__` and the `ContextVar` token
is reset in `finally`. Concurrent and sequential requests cannot share package
context. WebSocket and lifespan scopes pass through unchanged; WebSocket access
logging is outside the package scope.

### Proxy trust

Opt-in `peer_ip` comes only from the ASGI scope. The package does not parse
`Forwarded` or `X-Forwarded-For`, because trusting those headers without a known
proxy boundary allows spoofing. Configure the ASGI server so `scope["client"]`
represents the intended direct peer boundary.

## Compatibility and development

Beginning with v1.0.0, exported APIs, configuration defaults, structured log
fields, and supported runtime versions are compatibility contracts. Breaking
changes require a new major version, explicit changelog coverage, and migration
guidance. The package does not configure logging at import time and does not
claim ownership of exception responses.

Version 2 configuration and public value objects are keyword-only and expose no
v1 argument-order or option compatibility shims. Applications upgrading from
1.x must follow the
[migration guide](https://github.com/janisto/fastapi-observability/blob/main/CHANGELOG.md#migration-from-1x).

Repository tests use HTTPX2 directly with its asynchronous ASGI transport.
Deprecated HTTPX and FastAPI/Starlette `TestClient` are intentionally excluded.
If the package later needs to mock outbound HTTP, use `pytest-httpx2` and its
`httpx2_mock` fixture; do not add `pytest-httpx`.

See [EXAMPLES.md](https://github.com/janisto/fastapi-observability/blob/main/EXAMPLES.md)
for complete configurations.

### Development

Development uses [uv](https://docs.astral.sh/uv/) and
[just](https://github.com/casey/just). On macOS, install the workflow linters:

```sh
brew install actionlint zizmor
```

Then run the repository gates:

```sh
just install
just qa
just package-check
```

`just qa` runs Ruff, Ty, pytest with branch coverage,
[actionlint](https://github.com/rhysd/actionlint), and
[zizmor](https://docs.zizmor.sh/). `just package-check` builds, inspects, and
smoke-tests the wheel and source distribution in isolated environments.

## Mutation testing

The traceparent parser has a focused [mutmut](https://github.com/boxed/mutmut)
campaign. It introduces small changes to `parse_traceparent` and verifies that
the parser tests detect observable behavior changes:

```sh
just mutation
```

This intentionally runs outside `just qa`. Use `uv run mutmut results` to
list surviving mutants and `uv run mutmut show <mutant>` to inspect one. Add a
test when a survivor exposes a contract gap; equivalent transformations do not
need production pragmas or artificial assertions.

## Consumer image

Run `just e2e-image observability-e2e-local:manual` to build a
production-shaped consumer image from the exact checkout. The recipe prefers
Podman and falls back to Docker.

Building the image verifies packaging and integration only. It does not run the
image, validate emitted logs, compare implementations, or approve a release.
Optional independent tooling may exercise the package's documented public
contract. Any audit result is informational and is never a publication
requirement.

## References

- [FastAPI middleware](https://fastapi.tiangolo.com/tutorial/middleware/)
  documents middleware stacking and request/response execution order.
- [FastAPI advanced middleware](https://fastapi.tiangolo.com/advanced/middleware/)
  documents pure ASGI middleware and wrapping a completed application.
- [Starlette middleware](https://www.starlette.io/middleware/) documents the
  default error-middleware stack and pure ASGI middleware conventions.
- [ASGI HTTP and WebSocket specification](https://asgi.readthedocs.io/en/latest/specs/www.html)
  defines HTTP scopes, response-start/body events, disconnects, and send
  failures used by the middleware boundary.
- [Python `contextvars`](https://docs.python.org/3/library/contextvars.html)
  defines task-local context propagation and token-based restoration.
- [W3C Trace Context Level 1 Recommendation](https://www.w3.org/TR/2021/REC-trace-context-1-20211123/)
  defines the default `traceparent` and `tracestate` contract.
- [W3C Trace Context Level 2 Candidate Recommendation Draft](https://www.w3.org/TR/2024/CRD-trace-context-2-20240328/)
  defines the explicit Level 2 key grammar and random trace-ID flag.
- [Google Cloud trace and log integration](https://cloud.google.com/trace/docs/trace-log-integration)
  documents the bare trace ID as the preferred trace field format.
- [Google Cloud Trace release notes](https://cloud.google.com/trace/docs/release-notes)
  record when the bare trace ID became the preferred form while the full
  project resource name remained supported.
- [Google Cloud structured logging](https://cloud.google.com/logging/docs/structured-logging)
  documents `severity`, `message`, `httpRequest`, and special trace fields.
- [AWS X-Ray trace IDs](https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html#xray-api-traceids)
  document converting a W3C trace ID to `1-8hex-24hex` form.
- [Azure Application Insights data model](https://learn.microsoft.com/en-us/azure/azure-monitor/app/data-model-complete)
  defines `operation_Id` as the root-operation identifier and
  `operation_ParentId` as the immediate-parent identifier.

## License

[MIT](https://github.com/janisto/fastapi-observability/blob/main/LICENSE)
