Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> Use [this search for a list of all CHANGELOG.md files in this repo](https://github.com/search?q=repo%3Aopen-telemetry%2Fopentelemetry-python-contrib+path%3A**%2FCHANGELOG.md&type=code).

## Unreleased
- `opentelemetry-instrumentation-fastapi` Support for Middleware Wrapped FastAPI Application [#4041](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4031)

## Version 1.39.0/0.60b0 (2025-12-03)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ def instrument_app(
http_capture_headers_sanitize_fields: Optional list of HTTP headers to sanitize.
exclude_spans: Optionally exclude HTTP `send` and/or `receive` spans from the trace.
"""
# unwraps any middleware to get to the FastAPI or Starlette app
app = _unwrap_middleware(app)
if not hasattr(app, "_is_instrumented_by_opentelemetry"):
app._is_instrumented_by_opentelemetry = False

Expand Down Expand Up @@ -391,6 +393,12 @@ async def __call__(
app=otel_middleware,
)

# add check if the app object has build_middleware_stack method
if not hasattr(app, "build_middleware_stack"):
_logger.error(
"Skipping FastAPI instrumentation due to missing build_middleware_stack method on app object."
)
return
app._original_build_middleware_stack = app.build_middleware_stack
app.build_middleware_stack = types.MethodType(
functools.wraps(app.build_middleware_stack)(
Expand All @@ -409,6 +417,9 @@ async def __call__(

@staticmethod
def uninstrument_app(app: fastapi.FastAPI):
# Unwraps any middleware to get to the FastAPI or Starlette app
app = _unwrap_middleware(app)

original_build_middleware_stack = getattr(
app, "_original_build_middleware_stack", None
)
Expand Down Expand Up @@ -514,3 +525,17 @@ def _get_default_span_details(scope):
else: # fallback
span_name = method
return span_name, attributes


def _unwrap_middleware(app):
"""
Unwraps the middleware stack to find the underlying FastAPI or Starlette app.

Args:
app: The ASGI application potentially wrapped in middleware.
Returns:
The unwrapped FastAPI or Starlette application.
"""
while hasattr(app, "app"):
app = app.app
return app
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import fastapi
import pytest
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.routing import APIRoute
Expand Down Expand Up @@ -1487,6 +1488,38 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
)


class TestMiddlewareWrappedApplication(TestBase):
def setUp(self):
super().setUp()
self.fastapi_app = fastapi.FastAPI()

@self.fastapi_app.get("/foobar")
async def _():
return {"message": "hello world"}

self.app = CORSMiddleware(self.fastapi_app, allow_origins=["*"])

otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)

def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)

def test_instrumentation_with_existing_middleware(self):
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)

span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)

server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertEqual(server_span.name, "GET /foobar")


class TestFastAPIGarbageCollection(unittest.TestCase):
def test_fastapi_app_is_collected_after_instrument(self):
app = fastapi.FastAPI()
Expand Down
Loading