Skip to content

fastapi: fix wrapping of middlewares #3012

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
83d4863
fastapi: fix wrapping of middlewares
adriangb Nov 15, 2024
d635026
fix import, super
adriangb Nov 15, 2024
23a04a5
add test
adriangb Nov 15, 2024
a979966
changelog
adriangb Nov 15, 2024
8057faa
lint
adriangb Nov 15, 2024
c1c4300
lint
adriangb Nov 15, 2024
121813e
fix
adriangb Nov 15, 2024
04cf5d5
ci
adriangb Nov 15, 2024
fdba820
fix wip
adriangb Nov 26, 2024
0da827b
fix
adriangb Nov 27, 2024
af0bf2c
fix
adriangb Nov 27, 2024
d3b9910
lint
adriangb Nov 27, 2024
bfe110c
lint
adriangb Nov 27, 2024
0c32f4d
Exit?
adriangb Nov 27, 2024
c75b280
Update test_fastapi_instrumentation.py
adriangb Nov 27, 2024
3d7d537
remove break
adriangb Nov 27, 2024
f75dbef
fix
adriangb Nov 27, 2024
834392f
remove dunders
adriangb Nov 27, 2024
14d8159
add test
adriangb Nov 15, 2024
128cf89
lint
adriangb Dec 3, 2024
d8ca85e
add endpoint to class
adriangb Dec 4, 2024
4c4ac68
fmt
adriangb Dec 4, 2024
d7f7cc6
pr feedback
adriangb Feb 10, 2025
00840d7
move type ignores
adriangb Feb 10, 2025
a5db685
fix sphinx?
adriangb Feb 10, 2025
1a485a8
Merge branch 'main' into fix-asgi-middleware
xrmx Mar 11, 2025
0870986
Update CHANGELOG.md
xrmx Mar 11, 2025
b3bea3c
Merge branch 'main' into fix-asgi-middleware
adriangb Mar 12, 2025
8a2bfc7
Merge branch 'main' into fix-asgi-middleware
adriangb Mar 12, 2025
2498309
update fastapi versions
adriangb Mar 26, 2025
887475b
Merge branch 'main' into fix-asgi-middleware
adriangb Mar 26, 2025
d2cd389
Merge branch 'main' into fix-asgi-middleware
adriangb Apr 8, 2025
289f788
fix?
adriangb Apr 8, 2025
7ac54ae
generate
adriangb Apr 9, 2025
5fa64a5
stop passing on user-supplied error handler
outergod May 14, 2025
f6dd589
Merge pull request #2 from outergod/fix-asgi-middleware
adriangb May 14, 2025
faf0cef
Merge branch 'main' into fix-asgi-middleware
emdneto May 14, 2025
17e08fa
fix ci
emdneto May 14, 2025
8fcf4ec
fix ruff
emdneto May 14, 2025
fcc62f4
remove unused funcs
adriangb May 14, 2025
5a84bcc
fix lint,ruff
emdneto May 14, 2025
87aa3f6
Merge branch 'main' into fix-asgi-middleware
emdneto May 16, 2025
2a150fd
fix changelog
emdneto May 16, 2025
808d42a
add changelog note
emdneto May 16, 2025
18b8962
Merge branch 'main' into fix-asgi-middleware
adriangb May 19, 2025
60e1706
fix conflicts with main
emdneto May 19, 2025
a648666
Merge branch 'main' into fix-asgi-middleware
xrmx May 20, 2025
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- `opentelemetry-instrumentation-fastapi`: fix wrapping of middlewares
([#3012](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3012))

### Breaking changes

- `opentelemetry-instrumentation-botocore` Use `cloud.region` instead of `aws.region` span attribute as per semantic conventions.
([#3474](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3474))

- `opentelemetry-instrumentation-fastapi`: Drop support for FastAPI versions earlier than `0.92`
([#3012](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3012))

## Version 1.33.0/0.54b0 (2025-05-09)

Expand Down
1 change: 1 addition & 0 deletions docs/nitpick-exceptions.ini
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ py-class=
psycopg.Connection
psycopg.AsyncConnection
ObjectProxy
fastapi.applications.FastAPI

any=
; API
Expand Down
2 changes: 1 addition & 1 deletion instrumentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
| [opentelemetry-instrumentation-django](./opentelemetry-instrumentation-django) | django >= 1.10 | Yes | development
| [opentelemetry-instrumentation-elasticsearch](./opentelemetry-instrumentation-elasticsearch) | elasticsearch >= 6.0 | No | development
| [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 5.0.0 | Yes | migration
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | Yes | migration
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.92 | Yes | migration
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0 | Yes | migration
| [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio >= 1.42.0 | No | development
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No | migration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -665,9 +665,9 @@ def __init__(
# pylint: disable=too-many-statements
async def __call__(
self,
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
scope: typing.MutableMapping[str, Any],
receive: Callable[[], Awaitable[typing.MutableMapping[str, Any]]],
send: Callable[[typing.MutableMapping[str, Any]], Awaitable[None]],
) -> None:
"""The ASGI application

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ dependencies = [

[project.optional-dependencies]
instruments = [
"fastapi ~= 0.58",
"fastapi ~= 0.92",
]

[project.entry-points.opentelemetry_instrumentor]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,16 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A

from __future__ import annotations

import functools
import logging
import types
from typing import Collection, Literal

import fastapi
from starlette.applications import Starlette
from starlette.middleware.errors import ServerErrorMiddleware
from starlette.routing import Match
from starlette.types import ASGIApp

from opentelemetry.instrumentation._semconv import (
_get_schema_url,
Expand All @@ -203,9 +208,9 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
from opentelemetry.instrumentation.fastapi.package import _instruments
from opentelemetry.instrumentation.fastapi.version import __version__
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.metrics import get_meter
from opentelemetry.metrics import MeterProvider, get_meter
from opentelemetry.semconv.attributes.http_attributes import HTTP_ROUTE
from opentelemetry.trace import get_tracer
from opentelemetry.trace import TracerProvider, get_tracer
from opentelemetry.util.http import (
get_excluded_urls,
parse_excluded_urls,
Expand All @@ -226,13 +231,13 @@ class FastAPIInstrumentor(BaseInstrumentor):

@staticmethod
def instrument_app(
app,
app: fastapi.FastAPI,
server_request_hook: ServerRequestHook = None,
client_request_hook: ClientRequestHook = None,
client_response_hook: ClientResponseHook = None,
tracer_provider=None,
meter_provider=None,
excluded_urls=None,
tracer_provider: TracerProvider | None = None,
meter_provider: MeterProvider | None = None,
excluded_urls: str | None = None,
http_capture_headers_server_request: list[str] | None = None,
http_capture_headers_server_response: list[str] | None = None,
http_capture_headers_sanitize_fields: list[str] | None = None,
Expand Down Expand Up @@ -284,21 +289,56 @@ def instrument_app(
schema_url=_get_schema_url(sem_conv_opt_in_mode),
)

app.add_middleware(
OpenTelemetryMiddleware,
excluded_urls=excluded_urls,
default_span_details=_get_default_span_details,
server_request_hook=server_request_hook,
client_request_hook=client_request_hook,
client_response_hook=client_response_hook,
# Pass in tracer/meter to get __name__and __version__ of fastapi instrumentation
tracer=tracer,
meter=meter,
http_capture_headers_server_request=http_capture_headers_server_request,
http_capture_headers_server_response=http_capture_headers_server_response,
http_capture_headers_sanitize_fields=http_capture_headers_sanitize_fields,
exclude_spans=exclude_spans,
# Instead of using `app.add_middleware` we monkey patch `build_middleware_stack` to insert our middleware
# as the outermost middleware.
# Otherwise `OpenTelemetryMiddleware` would have unhandled exceptions tearing through it and would not be able
# to faithfully record what is returned to the client since it technically cannot know what `ServerErrorMiddleware` is going to do.

def build_middleware_stack(self: Starlette) -> ASGIApp:
inner_server_error_middleware: ASGIApp = ( # type: ignore
self._original_build_middleware_stack() # type: ignore
)
otel_middleware = OpenTelemetryMiddleware(
inner_server_error_middleware,
excluded_urls=excluded_urls,
default_span_details=_get_default_span_details,
server_request_hook=server_request_hook,
client_request_hook=client_request_hook,
client_response_hook=client_response_hook,
# Pass in tracer/meter to get __name__and __version__ of fastapi instrumentation
tracer=tracer,
meter=meter,
http_capture_headers_server_request=http_capture_headers_server_request,
http_capture_headers_server_response=http_capture_headers_server_response,
http_capture_headers_sanitize_fields=http_capture_headers_sanitize_fields,
exclude_spans=exclude_spans,
)
# Wrap in an outer layer of ServerErrorMiddleware so that any exceptions raised in OpenTelemetryMiddleware
# are handled.
# This should not happen unless there is a bug in OpenTelemetryMiddleware, but if there is we don't want that
# to impact the user's application just because we wrapped the middlewares in this order.
if isinstance(
inner_server_error_middleware, ServerErrorMiddleware
): # usually true
outer_server_error_middleware = ServerErrorMiddleware(
app=otel_middleware,
)
else:
# Something else seems to have patched things, or maybe Starlette changed.
# Just create a default ServerErrorMiddleware.
outer_server_error_middleware = ServerErrorMiddleware(
app=otel_middleware
)
return outer_server_error_middleware

app._original_build_middleware_stack = app.build_middleware_stack
app.build_middleware_stack = types.MethodType(
functools.wraps(app.build_middleware_stack)(
build_middleware_stack
),
app,
)

app._is_instrumented_by_opentelemetry = True
if app not in _InstrumentedFastAPI._instrumented_fastapi_apps:
_InstrumentedFastAPI._instrumented_fastapi_apps.add(app)
Expand All @@ -309,11 +349,12 @@ def instrument_app(

@staticmethod
def uninstrument_app(app: fastapi.FastAPI):
app.user_middleware = [
x
for x in app.user_middleware
if x.cls is not OpenTelemetryMiddleware
]
original_build_middleware_stack = getattr(
app, "_original_build_middleware_stack", None
)
if original_build_middleware_stack:
app.build_middleware_stack = original_build_middleware_stack
del app._original_build_middleware_stack
app.middleware_stack = app.build_middleware_stack()
app._is_instrumented_by_opentelemetry = False

Expand Down Expand Up @@ -341,12 +382,7 @@ def _instrument(self, **kwargs):
_InstrumentedFastAPI._http_capture_headers_sanitize_fields = (
kwargs.get("http_capture_headers_sanitize_fields")
)
_excluded_urls = kwargs.get("excluded_urls")
_InstrumentedFastAPI._excluded_urls = (
_excluded_urls_from_env
if _excluded_urls is None
else parse_excluded_urls(_excluded_urls)
)
_InstrumentedFastAPI._excluded_urls = kwargs.get("excluded_urls")
_InstrumentedFastAPI._meter_provider = kwargs.get("meter_provider")
_InstrumentedFastAPI._exclude_spans = kwargs.get("exclude_spans")
fastapi.FastAPI = _InstrumentedFastAPI
Expand All @@ -365,43 +401,29 @@ class _InstrumentedFastAPI(fastapi.FastAPI):
_server_request_hook: ServerRequestHook = None
_client_request_hook: ClientRequestHook = None
_client_response_hook: ClientResponseHook = None
_http_capture_headers_server_request: list[str] | None = None
_http_capture_headers_server_response: list[str] | None = None
_http_capture_headers_sanitize_fields: list[str] | None = None
_exclude_spans: list[Literal["receive", "send"]] | None = None

_instrumented_fastapi_apps = set()
_sem_conv_opt_in_mode = _StabilityMode.DEFAULT

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tracer = get_tracer(
__name__,
__version__,
_InstrumentedFastAPI._tracer_provider,
schema_url=_get_schema_url(
_InstrumentedFastAPI._sem_conv_opt_in_mode
),
)
meter = get_meter(
__name__,
__version__,
_InstrumentedFastAPI._meter_provider,
schema_url=_get_schema_url(
_InstrumentedFastAPI._sem_conv_opt_in_mode
),
)
self.add_middleware(
OpenTelemetryMiddleware,
excluded_urls=_InstrumentedFastAPI._excluded_urls,
default_span_details=_get_default_span_details,
server_request_hook=_InstrumentedFastAPI._server_request_hook,
client_request_hook=_InstrumentedFastAPI._client_request_hook,
client_response_hook=_InstrumentedFastAPI._client_response_hook,
# Pass in tracer/meter to get __name__and __version__ of fastapi instrumentation
tracer=tracer,
meter=meter,
http_capture_headers_server_request=_InstrumentedFastAPI._http_capture_headers_server_request,
http_capture_headers_server_response=_InstrumentedFastAPI._http_capture_headers_server_response,
http_capture_headers_sanitize_fields=_InstrumentedFastAPI._http_capture_headers_sanitize_fields,
exclude_spans=_InstrumentedFastAPI._exclude_spans,
FastAPIInstrumentor.instrument_app(
self,
server_request_hook=self._server_request_hook,
client_request_hook=self._client_request_hook,
client_response_hook=self._client_response_hook,
tracer_provider=self._tracer_provider,
meter_provider=self._meter_provider,
excluded_urls=self._excluded_urls,
http_capture_headers_server_request=self._http_capture_headers_server_request,
http_capture_headers_server_response=self._http_capture_headers_server_response,
http_capture_headers_sanitize_fields=self._http_capture_headers_sanitize_fields,
exclude_spans=self._exclude_spans,
)
self._is_instrumented_by_opentelemetry = True
_InstrumentedFastAPI._instrumented_fastapi_apps.add(self)

def __del__(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.


_instruments = ("fastapi ~= 0.58",)
_instruments = ("fastapi ~= 0.92",)

_supports_metrics = True

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# pylint: disable=too-many-lines

import unittest
from contextlib import ExitStack
from timeit import default_timer
from unittest.mock import Mock, call, patch

Expand Down Expand Up @@ -183,9 +184,14 @@ def setUp(self):
self._instrumentor = otel_fastapi.FastAPIInstrumentor()
self._app = self._create_app()
self._app.add_middleware(HTTPSRedirectMiddleware)
self._client = TestClient(self._app)
self._client = TestClient(self._app, base_url="https://testserver:443")
# run the lifespan, initialize the middleware stack
# this is more in-line with what happens in a real application when the server starts up
self._exit_stack = ExitStack()
self._exit_stack.enter_context(self._client)

def tearDown(self):
self._exit_stack.close()
super().tearDown()
self.env_patch.stop()
self.exclude_patch.stop()
Expand Down Expand Up @@ -218,11 +224,19 @@ async def _(param: str):
async def _():
return {"message": "ok"}

@app.get("/error")
async def _():
raise UnhandledException("This is an unhandled exception")

app.mount("/sub", app=sub_app)

return app


class UnhandledException(Exception):
pass


class TestBaseManualFastAPI(TestBaseFastAPI):
@classmethod
def setUpClass(cls):
Expand All @@ -233,6 +247,27 @@ def setUpClass(cls):

super(TestBaseManualFastAPI, cls).setUpClass()

def test_fastapi_unhandled_exception(self):
"""If the application has an unhandled error the instrumentation should capture that a 500 response is returned."""
try:
resp = self._client.get("/error")
assert (
resp.status_code == 500
), resp.content # pragma: no cover, for debugging this test if an exception is _not_ raised
except UnhandledException:
pass
else:
self.fail("Expected UnhandledException")

spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 3)
span = spans[0]
assert span.name == "GET /error http send"
assert span.attributes[HTTP_STATUS_CODE] == 500
span = spans[2]
assert span.name == "GET /error"
assert span.attributes[HTTP_TARGET] == "/error"

def test_sub_app_fastapi_call(self):
"""
This test is to ensure that a span in case of a sub app targeted contains the correct server url
Expand Down Expand Up @@ -975,6 +1010,10 @@ async def _(param: str):
async def _():
return {"message": "ok"}

@app.get("/error")
async def _():
raise UnhandledException("This is an unhandled exception")

app.mount("/sub", app=sub_app)

return app
Expand Down Expand Up @@ -1137,9 +1176,11 @@ def test_request(self):
def test_mulitple_way_instrumentation(self):
self._instrumentor.instrument_app(self._app)
count = 0
for middleware in self._app.user_middleware:
if middleware.cls is OpenTelemetryMiddleware:
app = self._app.middleware_stack
while app is not None:
if isinstance(app, OpenTelemetryMiddleware):
count += 1
app = getattr(app, "app", None)
self.assertEqual(count, 1)

def test_uninstrument_after_instrument(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"instrumentation": "opentelemetry-instrumentation-falcon==0.55b0.dev",
},
{
"library": "fastapi ~= 0.58",
"library": "fastapi ~= 0.92",
"instrumentation": "opentelemetry-instrumentation-fastapi==0.55b0.dev",
},
{
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ dependencies = [
"opentelemetry-instrumentation-openai-v2[instruments]",
]


# https://docs.astral.sh/uv/reference/settings/
[tool.uv]
package = false # https://docs.astral.sh/uv/reference/settings/#package
Expand Down Expand Up @@ -147,6 +148,7 @@ members = [
# TODO: remove after python 3.8 is dropped
exclude = [
"instrumentation-genai/opentelemetry-instrumentation-google-genai",
"instrumentation/opentelemetry-instrumentation-starlette",
]

[tool.ruff]
Expand Down
Loading