Skip to main content

FastAPI middleware for request IDs, trace correlation, and structured access logs

Project description

fastapi-request-observability

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

This package deliberately does not create traces, metrics, or profiles. It has no OpenTelemetry, provider SDK, exporter, or logging-framework dependency. It uses standard-library logging and pure ASGI middleware 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.

Installation

uv add fastapi-request-observability

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

GCP setup

When this documentation shows one configuration, it uses GCP. Complete runnable GCP, provider-neutral, AWS, and Azure applications are available in examples.

import logging

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

handler = logging.StreamHandler()
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. Generated values are validated too, and an invalid custom generator falls back to the package's safe format. Custom validators cannot admit empty, non-ASCII, or non-visible values. 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 is strict. Invalid, duplicate, uppercase, zero-ID, or oversized values are ignored. Version 00 must be exactly 55 characters; future versions follow W3C extension framing. tracestate fields are combined in wire order and retained only when their W3C key/value grammar, unique-key rule, 32-member limit, and 512-byte limit are valid. 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.

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

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.

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.

The access record message is request completed and includes:

Field Meaning
method HTTP method
path Escaped concrete path, without query string
path_template Matched FastAPI route template when available
operation_id Only an explicitly configured APIRoute.operation_id
status Status sent on the wire
duration_ms Handling and streaming time in milliseconds
remote_ip scope["client"][0], when present
user_agent Incoming user agent, when present
error Observed exception type and message

The default level is ERROR for 5xx, WARNING for 4xx, and INFO otherwise. Package and provider fields are reserved: extra values and access callbacks cannot replace them.

AccessLogConfig also accepts a monotonic clock, a status_level(status) callback, a synchronous extra_fields(scope) callback, and a custom message. 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 aggregation key; path is useful for individual-request diagnostics and has unbounded cardinality. Query strings are omitted because they frequently carry secrets and high-cardinality values. Bodies, authorization, cookies, and arbitrary headers are never logged.

Cloud presets

Pass the same preset to the formatter and access configuration:

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.
  • 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.

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 http.response.start logs status 500.
  • Once response headers are sent, that committed status wins even if streaming later fails.
  • 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 and status 500.

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

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 v0.1 scope.

Proxy trust

remote_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 trusted proxy handling in the ASGI server or deployment so scope["client"] is already normalized.

Compatibility

The v0.x line may evolve, but logged field names are treated as a compatibility contract. Field changes require changelog review. The package does not configure logging at import time and does not claim ownership of exception responses.

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 for complete configurations.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_request_observability-0.2.1.tar.gz (14.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_request_observability-0.2.1-py3-none-any.whl (18.1 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_request_observability-0.2.1.tar.gz.

File metadata

  • Download URL: fastapi_request_observability-0.2.1.tar.gz
  • Upload date:
  • Size: 14.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_request_observability-0.2.1.tar.gz
Algorithm Hash digest
SHA256 837d923920046d2e506a00014acf39ae7d044173427ce7ef34c85b931b8a1550
MD5 af3b7a201547a05c58ba5b5a2add9772
BLAKE2b-256 338537fc1fb5195d8da36edca0426584740fba8088627842268c59e2a16ad3c1

See more details on using hashes here.

File details

Details for the file fastapi_request_observability-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: fastapi_request_observability-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 18.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_request_observability-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 01b4ed27392511fa18f64823182d57e6e2feeb0dc892f0dca12b38da2af055e6
MD5 a214cb7f3cb8816da1b9ce1a295e37e9
BLAKE2b-256 19dd1f80bb8db34257ab4c42c85600354eb98521e3390f98ec51fd50859c2f6f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page