Skip to content

fix(fastapi): propagate request correlation across async tasks and sync worker threads - #123

Merged
suppagoddo merged 9 commits into
masterfrom
fix/fastapi-request-context-propagation
Oct 20, 2026
Merged

suppagoddo merged 9 commits into
masterfrom
fix/fastapi-request-context-propagation

Conversation

@suppagoddo

@suppagoddo suppagoddo commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

FastAPI def endpoints execute in AnyIO worker threads. json-logging uses Python call-stack (frame) inspection to discover the active request, and that fails when the request is not in the worker thread's own stack. As a result, logger.info(...) and json_logging.get_correlation_id() emit "-" while the same HTTP request's instrumentation log has a real correlation ID.

Fixes #124. This failure was independently reproduced against the published 1.5.1 package and current master. #80 described a similar symptom in 2021, but the reporter later confirmed it was working and that issue was closed as resolved. This PR does not claim to fix or reopen #80.

Change (FastAPI integration only)

  • A FastAPI-specific ContextVar holds the current request. It is set in JSONLoggingASGIMiddleware.dispatch for the request's lifetime (including URLs excluded from request logging) and reset in finally.
  • FastAPIRequestInfoExtractor reads it through the existing extractor interface. When no request context is active, it falls back to the previous call-stack lookup inside the FastAPI integration.
  • When the request context is active, a missing correlation ID is created once per request, before the endpoint runs. Concurrent worker threads of the same request therefore share one ID instead of racing to create their own. Errors from this call are ignored, so a failing custom CORRELATION_ID_GENERATOR behaves as before.
  • json_logging/util.py and the shared framework code are unchanged from master. Custom framework integrations (README §5) that support a global request object keep logging "-" outside a request.

Compatibility boundary

ContextVar-based propagation is enabled only on Python 3.9+, the versions covered by CI (3.9–3.13). Python 3.8 and earlier, including 3.6 where only the thread-local contextvars backport exists, keep the original behaviour unchanged:

  • call-stack lookup only;
  • no request context;
  • no eager correlation-ID creation.

We could not test those versions, so the PR deliberately preserves their legacy behaviour instead of enabling unverified functionality. Other behaviour that stays as before on every version:

  • Threads that do not inherit the request's context (threading.Thread, loop.run_in_executor()) and WebSocket endpoints: there, the request is found only if it is held in one of that thread's own frames, otherwise "-".
  • Applications without init_request_instrument(app).
  • Flask, Quart, Sanic and Connexion.

Regression coverage

  • Supplied ID: sync and async route logs and get_correlation_id() return the header ID.
  • No header: sync application and instrumentation logs agree on one generated ID.
  • Excluded instrumentation path: sync app logs still get request context.
  • Concurrency: sync requests held simultaneously in one event loop (barrier) keep their own IDs, and the context is reset after the request.
  • Two threadpool workers of one request with no header: both are forced into ID generation simultaneously, and the test asserts that both app logs and the request log share one ID.
  • No init_request_instrument(): the call-stack fallback still works.
  • Feature disabled (Python < 3.9, simulated): no ContextVar, legacy stack lookup, no eager ID creation.
  • Custom framework plugin contract: a global-request plugin that has no current request logs "-" without raising.

Validation

  • CI: Python 3.9–3.13, Flake8, and the FastAPI/Flask/Quart/Sanic smoke tests.
  • Additional local checks:
    • an adversarial suite on FastAPI 0.65 → 0.146 / Starlette 0.14 → 1.9 / AnyIO 3 and 4;
    • mutation testing: 13/13 targeted mutants detected by the repository tests;
    • cross-framework behaviour comparison with master;
    • package-content comparison: only json_logging/framework/fastapi/implementation.py changes in the wheel.

No version bump, PyPI release or tag is included. Merging requires an independent maintainer review.

Summary by CodeRabbit

  • New Features

    • FastAPI requests now preserve correlation IDs across synchronous and asynchronous endpoints, background tasks, streaming responses, and inherited request contexts.
    • Missing correlation IDs are generated once and consistently reused throughout each request.
    • Concurrent requests and thread-pool workers maintain isolated or inherited correlation contexts as appropriate.
  • Compatibility

    • Excluded URLs retain their existing instrumentation behavior.
    • WebSockets, uninstrumented applications, threads without inherited context, and Python versions below 3.9 retain legacy request-lookup behavior.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b9de776f-5383-49b2-97b9-e9fb02e446ea
📥 Commits

Reviewing files that changed from the base of the PR and between 6c8e48f and 7bb9972.

📒 Files selected for processing: 1
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes: 1
  • CHANGELOG.md

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.

📝 Walkthrough

FastAPI request tracking now uses a Python 3.9+ ContextVar to propagate correlation IDs across request execution contexts. Middleware initializes and restores request context. Older Python versions and requests without middleware context retain fallback behavior.

Changes: FastAPI request correlation

Layer / File(s) Summary
Request context storage and lookup
json_logging/framework/fastapi/implementation.py, tests/test_framework_plugin.py
Python 3.9+ enables ContextVar-based request support. Request lookup falls back to call-stack discovery when no request is bound. A custom framework test checks logging without an active request.
Middleware propagation and validation
json_logging/framework/fastapi/implementation.py, tests/test_fastapi.py, test-requirements.txt, CHANGELOG.md
Middleware sets and resets request context, initializes correlation IDs, and retains exclusion and logging behavior. Tests cover synchronous and asynchronous propagation, generated IDs, isolation, and context cleanup. HTTPX supports the tests, and the changelog describes the behavior.
Legacy runtime and lookup fallback
tests/test_fastapi.py
Tests verify that Python 3.8 retains frame-based and lazy correlation-ID behavior, including excluded requests and requests without instrumentation.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant FastAPI
  participant MiddlewareDispatch
  participant ContextVar
  participant SyncOrAsyncEndpoint
  participant CorrelationLookup
  FastAPI->>MiddlewareDispatch: Dispatch request
  MiddlewareDispatch->>ContextVar: Set current request
  MiddlewareDispatch->>CorrelationLookup: Resolve correlation ID
  MiddlewareDispatch->>SyncOrAsyncEndpoint: Continue request
  SyncOrAsyncEndpoint->>CorrelationLookup: Get correlation ID
  CorrelationLookup->>ContextVar: Read bound request
  MiddlewareDispatch->>ContextVar: Reset after dispatch
Loading
Merge Risk: ⚪ Minimal · up to 7bb99

The documented correlation-ID behavior is consistent with the inspected execution path. No actionable merge risk remains after normal checks.

Security architecture risk: 🔵 Low · up to b0ba3

The change is confined to FastAPI request attribution. Task-local isolation and cleanup limit interference between requests. Background work that inherits a request context can retain it after the response, so applications with long-lived shared workers need an explicit attribution-lifetime policy. No introduced security vulnerability was established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated exposure is correlation metadata in instrumented FastAPI application execution and logs, including context-inheriting follow-up work. A remote client can supply configured correlation headers without gaining authority over other active request contexts. Cross-service or cross-tenant authorization effects are not established by the inspected path.

Trust Boundaries and Controls

  • observed — Client-controlled correlation headers were already accepted before this PR. The change propagates that existing untrusted metadata into additional application-log contexts rather than introducing a new header-trust rule. The default formatter serializes it as a JSON field; correlation IDs are not authenticated identities.

Resilience and Maintainability Implications

  • inferred — An inherited child context can retain the originating Request and its attribution after middleware dispatch ends. This is documented behavior, not by itself evidence of cross-request disclosure. Whether a long-lived shared worker could reuse stale attribution depends on application ownership and task-creation patterns not supplied here.

Hardening Proposals

  • proposed — Applications that create process-lifetime or cross-request workers from an HTTP handler should start those workers with a clean context, reserving inherited request context for request-owned follow-up work. This is a conditional integration safeguard, not a demonstrated defect in this PR.
Pre-merge checks: Passed 3 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Linked Issues check Warning The implementation meets the main FastAPI propagation requirements on Python 3.9+ and adds the reported regression coverage. However, json_logging/framework/fastapi/implementation.py sets `current… Guard the context lookup when _current_request is None. In that case, call json_logging._request_util.get_request_from_call_stack() directly. Keep the existing context lookup and fallback for Python 3.9+.
Docstring Coverage Warning Docstring coverage is 35.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 3 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 3 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: propagating FastAPI request correlation across asynchronous tasks and synchronous worker threads.
Out of Scope Changes check Passed The changed implementation is limited to the FastAPI integration. The added tests cover issue #124 behavior and protect the custom framework contract. The httpx test dependency and changelog entry s…
Full details: Out of Scope Changes check

Explanation

The changed implementation is limited to the FastAPI integration. The added tests cover issue #124 behavior and protect the custom framework contract. The httpx test dependency and changelog entry support the regression tests and document the FastAPI behavior. No unrelated production framework or shared utility changes are shown.

Full details: Linked Issues check

Explanation

The implementation meets the main FastAPI propagation requirements on Python 3.9+ and adds the reported regression coverage. However, json_logging/framework/fastapi/implementation.py sets _current_request = None below Python 3.9, while FastAPIRequestInfoExtractor.get_current_request() unconditionally calls _current_request.get(). This raises AttributeError instead of using legacy call-stack lookup. It does not meet issue #124's requirement to retain legacy behavior on Python 3.8 and earlier.

Resolution

Guard the context lookup when _current_request is None. In that case, call json_logging._request_util.get_request_from_call_stack() directly. Keep the existing context lookup and fallback for Python 3.9+.

Full details: Docstring Coverage

Explanation

Docstring coverage is 35.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 3 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ation

Restore json_logging/util.py to master so custom framework integrations that support a global request object keep logging '-' outside a request, instead of failing in the formatter. The call-stack fallback now lives in FastAPIRequestInfoExtractor.get_current_request().

Only use the ContextVar on Python 3.7+. The contextvars backport available on 3.6 is not asyncio-aware, so 3.6 keeps the previous lookup.

Add tests for context reset, same-event-loop concurrency, the no-instrumentation fallback and the custom framework contract. Add a changelog entry, make httpx an explicit test dependency, fix the test module docstring and imports.
With the request now visible in worker threads, two threadpool workers of one request could both find no correlation id, each generate one, and disagree with each other and with the request log. The middleware now resolves the id once before the endpoint runs (including excluded URLs), so later calls in any thread read the same id. Errors from that call are ignored, so a failing custom generator behaves as before.

Add a deterministic two-worker test (instrumented and excluded route) that forces both workers into id generation, and clarify the changelog.
…ntext is active

On Python < 3.7 the request context variable is not used, so skip the eager per-request correlation-id resolution there as well and keep the previous lazy behaviour unchanged (no id is created for requests that never ask for one).

Restore CORRELATION_ID_GENERATOR in the two-worker race test with monkeypatch.
Python 3.8 and earlier cannot be tested in CI, so keep their legacy behaviour unchanged: no request ContextVar, call-stack lookup only, and no eager correlation-id creation.

Add a regression test that simulates Python 3.8 and checks the disabled-feature fallback, and document the compatibility boundary in the changelog.
@suppagoddo
suppagoddo marked this pull request as ready for review October 20, 2026 00:25
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@llamapreview llamapreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LlamaPReview — No blocking issues found

Read bounded PR-head context from json_logging/framework/fastapi/implementation.py.

Change flow

How one request's correlation ID is set once and reaches async handlers, sync worker threads, application logs, and the request log, then is reset.

sequenceDiagram
participant C as Client
participant M as Middleware
participant E as Endpoint
participant L as JSON formatter
C->>M: HTTP request
note over M: PR change — ContextVar set and ID resolved once before call_next
M->>M: Resolve ID (state, then header, then generate once)
M->>E: call_next with inherited context
alt async handler
E->>L: Log or get_correlation_id()
L-->>E: Request correlation ID
else sync def handler
E->>E: AnyIO worker with copied context
E->>L: Log or get_correlation_id()
L-->>E: Request correlation ID
end
E-->>M: Response
M->>C: Response + request log with same ID
note over M: PR change — ContextVar reset in finally
Loading
Review details and evidence

Material unknowns

  • Whether the pre-3.9 code path behaves correctly on a real Python 3.8 runtime rather than a simulated one. Could change confidence in the claim that pre-3.9 behavior is unchanged; Deep assigned it no merge-posture weight because the disabled branch is the mechanically inert master path.
    • Check: Optional: run the suite once on an actual Python 3.8 interpreter to confirm the simulated pre-3.9 path.
  • Whether the author's older FastAPI/Starlette/AnyIO compatibility matrix holds. Only affects the breadth of the stated support claim; exact-head CI covers Python 3.9–3.13 with the installed dependency set, which is the stated acceptance boundary.
    • Check: Optional: spot-check an older FastAPI/Starlette/AnyIO combination if that matrix is still supported.
  • The literal contents of the built wheel manifest. A packaging-artifact detail; the source delta confines changes under json_logging/ to implementation.py and no packaging/version metadata changed.
    • Check: Optional: inspect the built wheel file list if packaging provenance matters for release.

LlamaPReview checks

  • Read bounded PR-head context from json_logging/framework/fastapi/implementation.py.
  • Read bounded PR-head context from tests/test_fastapi.py.

LlamaPReview is open source. Explore the code behind this review.

@suppagoddo
suppagoddo merged commit 5177322 into master Oct 20, 2026
11 checks passed
suppagoddo added a commit that referenced this pull request Oct 20, 2026
The Added entry pointed to a release-readiness report that is not part of the
repository, and the Documentation entry described a compatibility limit, which
is already listed under Compatibility. Both sections are removed. The heading no
longer says 'TBD (release candidate)'; the date is added when the release is
cut. The section still lists only merged work (PR #123).
suppagoddo added a commit that referenced this pull request Oct 21, 2026
Bump the version to 1.6.0 and collect the changelog into a 1.6.0 section
covering every change merged since 1.5.1:

- Added: json_logging.EXCLUDED_FIELDS (#129, fixes #98)
- Fixed: FastAPI request-context propagation (#123, fixes #124); outside-request
  correlation IDs in Flask, Quart and Connexion (#127, fixes #125); non-web
  get_correlation_id() (#128, fixes #126); Sanic 24+ logging config (#133)
- Documentation: FastAPI/Uvicorn startup (#130, fixes #106); Gunicorn and
  Flask example (#131, fixes #25)
- Compatibility: default output unchanged unless EXCLUDED_FIELDS is set; the
  framework behaviour changes; Python 3.9+ FastAPI context path; CI coverage

The previous 'other integrations unchanged' and 'no change to the JSON schema'
statements are replaced, since they do not hold for the whole release. The
date is added when the release is cut. No library code change.
suppagoddo added a commit that referenced this pull request Oct 21, 2026
* chore(release): prepare json-logging 1.6.0

Bump the version to 1.6.0 and collect the changelog into a 1.6.0 section
covering every change merged since 1.5.1:

- Added: json_logging.EXCLUDED_FIELDS (#129, fixes #98)
- Fixed: FastAPI request-context propagation (#123, fixes #124); outside-request
  correlation IDs in Flask, Quart and Connexion (#127, fixes #125); non-web
  get_correlation_id() (#128, fixes #126); Sanic 24+ logging config (#133)
- Documentation: FastAPI/Uvicorn startup (#130, fixes #106); Gunicorn and
  Flask example (#131, fixes #25)
- Compatibility: default output unchanged unless EXCLUDED_FIELDS is set; the
  framework behaviour changes; Python 3.9+ FastAPI context path; CI coverage

The previous 'other integrations unchanged' and 'no change to the JSON schema'
statements are replaced, since they do not hold for the whole release. The
date is added when the release is cut. No library code change.

* chore(release): credit Bob T. and Udit Agrawal in 1.6.0 metadata
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FastAPI sync endpoints can lose correlation IDs across AnyIO worker threads

1 participant