Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.

This project adheres to [Semantic Versioning](http://semver.org/).
The format is based on [Keep a Changelog](http://keepachangelog.com/).
## Unreleased

- fix: FastAPI application logs and `json_logging.get_correlation_id()` now use the request's correlation id
instead of `-` in synchronous (`def`) endpoints (fixes #124, PR #123). When request instrumentation is enabled with
`init_request_instrument(app)` (Python 3.9+):
- the current request is kept in a context variable for the duration of the request, including for URLs
excluded from request logging. It is visible to `def` and `async def` endpoints, sync and async
`BackgroundTasks`, `StreamingResponse` iterators, and work that inherits the request's context, such as
`asyncio.create_task()` (also after the response is sent), `asyncio.to_thread()`,
`contextvars.copy_context().run()` and AnyIO/Starlette threadpool workers;
- if no correlation id header is present, the id is now created once at the start of each request (also for
excluded URLs), so all threads and logs of one request share the same id.

Threads that do not inherit the request's context (`threading.Thread`, `loop.run_in_executor()`), WebSocket
endpoints, applications without `init_request_instrument(app)`, and Python < 3.9 keep the previous behaviour.
There, the request is found only by inspecting the current thread's call stack, so a thread or handler that
does not hold the request object in one of its own frames (for example as a `request` argument) logs `-`.
Other framework integrations are not affected.

## 1.5.1 - 2025-07-06

- fix: wrap debug log when logger upated to using JSONLogFormatter #105
Expand Down
66 changes: 52 additions & 14 deletions json_logging/framework/fastapi/implementation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import sys

import json_logging
import json_logging.framework
Expand All @@ -17,6 +18,13 @@
from starlette.types import ASGIApp

_request_config_class = None
# Only enabled on the Python versions covered by CI (3.9+). On older versions, including 3.6 where at
# most the thread-local `contextvars` backport exists, the previous call-stack lookup is kept unchanged.
if sys.version_info >= (3, 9):
from contextvars import ContextVar
_current_request = ContextVar('json_logging_fastapi_current_request', default=None)
else: # pragma: no cover
_current_request = None


class JSONLoggingASGIMiddleware(BaseHTTPMiddleware):
Expand All @@ -27,18 +35,39 @@ def __init__(self, app: ASGIApp, exclude_url_patterns=tuple()) -> None:
logging.getLogger("uvicorn.access").propagate = False

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
log_request = is_not_match_any_pattern(request.url.path, self.exclude_url_patterns)

if not log_request:
return await call_next(request)

request_response_data = _request_config_class(request)
response = await call_next(request)
request_response_data.on_request_complete(response)
self.request_logger.info(
"", extra={"request_response_data": request_response_data, "type": "request"}
)
return response
"""Run request instrumentation with a context shared by downstream work.

On Python 3.9+, resolve the correlation ID before workers run and
restore the previous request context when processing completes.
"""
# The current request must also be visible in FastAPI's sync worker threads.
# Context is propagated by AnyIO, unlike Python call-stack frame locals.
token = _current_request.set(request) if _current_request is not None else None
try:
# Resolve (and if needed create) the correlation id once per request, before handlers
# run, so concurrent worker threads of one request read the same id instead of each
# creating their own. Failures are left to the later lazy lookups, as before. Only
# done when the request context is in use; otherwise the previous behaviour is kept.
if token is not None:
try:
json_logging._request_util.get_correlation_id(request)
except Exception:
pass
log_request = is_not_match_any_pattern(request.url.path, self.exclude_url_patterns)

if not log_request:
return await call_next(request)

request_response_data = _request_config_class(request)
response = await call_next(request)
request_response_data.on_request_complete(response)
self.request_logger.info(
"", extra={"request_response_data": request_response_data, "type": "request"}
)
return response
finally:
if token is not None:
_current_request.reset(token)


class FastAPIAppRequestInstrumentationConfigurator(BaseAppRequestInstrumentationConfigurator):
Expand All @@ -65,11 +94,20 @@ def get_request_class_type():

@staticmethod
def support_global_request_object():
return False
return _current_request is not None

@staticmethod
def get_current_request():
raise NotImplementedError
"""Return the active request, falling back to call-stack discovery.

The fallback preserves request lookup when middleware context is absent.
"""
request = _current_request.get()
if request is None:
# Outside JSONLoggingASGIMiddleware (e.g. init_request_instrument() was not
# called) keep the previous call-stack discovery.
request = json_logging._request_util.get_request_from_call_stack()
return request

def get_remote_user(self, request: starlette.requests.Request):
try:
Expand Down
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ sanic
fastapi
uvicorn
requests
httpx
flake8
pytest
-e .
Loading
Loading