Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package
python-opentelemetry-instrumentation-fastapi for openSUSE:Factory checked in at
2025-09-23 16:07:16
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing
/work/SRC/openSUSE:Factory/python-opentelemetry-instrumentation-fastapi (Old)
and
/work/SRC/openSUSE:Factory/.python-opentelemetry-instrumentation-fastapi.new.27445
(New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-opentelemetry-instrumentation-fastapi"
Tue Sep 23 16:07:16 2025 rev:5 rq:1306349 version:0.58b0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-opentelemetry-instrumentation-fastapi/python-opentelemetry-instrumentation-fastapi.changes
2025-06-03 17:51:57.141656126 +0200
+++
/work/SRC/openSUSE:Factory/.python-opentelemetry-instrumentation-fastapi.new.27445/python-opentelemetry-instrumentation-fastapi.changes
2025-09-23 16:07:40.352552585 +0200
@@ -1,0 +2,17 @@
+Sun Sep 21 15:16:09 UTC 2025 - Dirk Müller <[email protected]>
+
+- update to 0.58b0:
+ * `opentelemetry-instrumentation-fastapi`: Fix middleware
+ ordering to cover all exception handling use cases.
+ * `opentelemetry-instrumentation-fastapi`: Fix memory leak in
+ `uninstrument_app()` by properly removing apps from the
+ tracking set
+ * `opentelemetry-instrumentation-fastapi`: Don't pass bounded
+ server_request_hook when using
+ `FastAPIInstrumentor.instrument()`
+ * `opentelemetry-instrumentation-fastapi`: fix wrapping of
+ middlewares
+ * `opentelemetry-instrumentation-fastapi`: Drop support for
+ FastAPI versions earlier than `0.92`
+
+-------------------------------------------------------------------
Old:
----
opentelemetry_instrumentation_fastapi-0.54b1.tar.gz
New:
----
opentelemetry_instrumentation_fastapi-0.58b0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-opentelemetry-instrumentation-fastapi.spec ++++++
--- /var/tmp/diff_new_pack.H2zzaw/_old 2025-09-23 16:07:40.796571232 +0200
+++ /var/tmp/diff_new_pack.H2zzaw/_new 2025-09-23 16:07:40.796571232 +0200
@@ -1,7 +1,7 @@
#
# spec file for package python-opentelemetry-instrumentation-fastapi
#
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2025 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -27,7 +27,7 @@
%{?sle15_python_module_pythons}
Name: python-opentelemetry-instrumentation-fastapi%{?psuffix}
-Version: 0.54b1
+Version: 0.58b0
Release: 0
Summary: OpenTelemetry FastAPI Instrumentation
License: Apache-2.0
++++++ opentelemetry_instrumentation_fastapi-0.54b1.tar.gz ->
opentelemetry_instrumentation_fastapi-0.58b0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/PKG-INFO
new/opentelemetry_instrumentation_fastapi-0.58b0/PKG-INFO
--- old/opentelemetry_instrumentation_fastapi-0.54b1/PKG-INFO 2020-02-02
01:00:00.000000000 +0100
+++ new/opentelemetry_instrumentation_fastapi-0.58b0/PKG-INFO 2020-02-02
01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: opentelemetry-instrumentation-fastapi
-Version: 0.54b1
+Version: 0.58b0
Summary: OpenTelemetry FastAPI Instrumentation
Project-URL: Homepage,
https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-fastapi
Project-URL: Repository,
https://github.com/open-telemetry/opentelemetry-python-contrib
@@ -12,20 +12,19 @@
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
-Requires-Python: >=3.8
+Requires-Python: >=3.9
Requires-Dist: opentelemetry-api~=1.12
-Requires-Dist: opentelemetry-instrumentation-asgi==0.54b1
-Requires-Dist: opentelemetry-instrumentation==0.54b1
-Requires-Dist: opentelemetry-semantic-conventions==0.54b1
-Requires-Dist: opentelemetry-util-http==0.54b1
+Requires-Dist: opentelemetry-instrumentation-asgi==0.58b0
+Requires-Dist: opentelemetry-instrumentation==0.58b0
+Requires-Dist: opentelemetry-semantic-conventions==0.58b0
+Requires-Dist: opentelemetry-util-http==0.58b0
Provides-Extra: instruments
-Requires-Dist: fastapi~=0.58; extra == 'instruments'
+Requires-Dist: fastapi~=0.92; extra == 'instruments'
Description-Content-Type: text/x-rst
OpenTelemetry FastAPI Instrumentation
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/pyproject.toml
new/opentelemetry_instrumentation_fastapi-0.58b0/pyproject.toml
--- old/opentelemetry_instrumentation_fastapi-0.54b1/pyproject.toml
2020-02-02 01:00:00.000000000 +0100
+++ new/opentelemetry_instrumentation_fastapi-0.58b0/pyproject.toml
2020-02-02 01:00:00.000000000 +0100
@@ -8,7 +8,7 @@
description = "OpenTelemetry FastAPI Instrumentation"
readme = "README.rst"
license = "Apache-2.0"
-requires-python = ">=3.8"
+requires-python = ">=3.9"
authors = [
{ name = "OpenTelemetry Authors", email =
"[email protected]" },
]
@@ -18,7 +18,6 @@
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
@@ -27,15 +26,15 @@
]
dependencies = [
"opentelemetry-api ~= 1.12",
- "opentelemetry-instrumentation == 0.54b1",
- "opentelemetry-instrumentation-asgi == 0.54b1",
- "opentelemetry-semantic-conventions == 0.54b1",
- "opentelemetry-util-http == 0.54b1",
+ "opentelemetry-instrumentation == 0.58b0",
+ "opentelemetry-instrumentation-asgi == 0.58b0",
+ "opentelemetry-semantic-conventions == 0.58b0",
+ "opentelemetry-util-http == 0.58b0",
]
[project.optional-dependencies]
instruments = [
- "fastapi ~= 0.58",
+ "fastapi ~= 0.92",
]
[project.entry-points.opentelemetry_instrumentor]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/__init__.py
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/__init__.py
---
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/__init__.py
2020-02-02 01:00:00.000000000 +0100
+++
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/__init__.py
2020-02-02 01:00:00.000000000 +0100
@@ -182,11 +182,17 @@
from __future__ import annotations
+import functools
import logging
-from typing import Collection, Literal
+import types
+from typing import Any, Collection, Literal
+from weakref import WeakSet as _WeakSet
import fastapi
+from starlette.applications import Starlette
+from starlette.middleware.errors import ServerErrorMiddleware
from starlette.routing import Match
+from starlette.types import ASGIApp, Receive, Scope, Send
from opentelemetry.instrumentation._semconv import (
_get_schema_url,
@@ -203,9 +209,10 @@
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.semconv.trace import SpanAttributes
-from opentelemetry.trace import get_tracer
+from opentelemetry.metrics import MeterProvider, get_meter
+from opentelemetry.semconv.attributes.http_attributes import HTTP_ROUTE
+from opentelemetry.trace import TracerProvider, get_current_span, get_tracer
+from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import (
get_excluded_urls,
parse_excluded_urls,
@@ -226,18 +233,18 @@
@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,
exclude_spans: list[Literal["receive", "send"]] | None = None,
- ):
+ ): # pylint: disable=too-many-locals
"""Instrument an uninstrumented FastAPI application.
Args:
@@ -284,21 +291,114 @@
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,
+ def build_middleware_stack(self: Starlette) -> ASGIApp:
+ # Define an additional middleware for exception handling
+ # Normally, `opentelemetry.trace.use_span` covers the
recording of
+ # exceptions into the active span, but
`OpenTelemetryMiddleware`
+ # ends the span too early before the exception can be recorded.
+ class ExceptionHandlerMiddleware:
+ def __init__(self, app):
+ self.app = app
+
+ async def __call__(
+ self, scope: Scope, receive: Receive, send: Send
+ ) -> None:
+ try:
+ await self.app(scope, receive, send)
+ except Exception as exc: # pylint:
disable=broad-exception-caught
+ span = get_current_span()
+ if span.is_recording():
+ span.record_exception(exc)
+ span.set_status(
+ Status(
+ status_code=StatusCode.ERROR,
+ description=f"{type(exc).__name__}:
{exc}",
+ )
+ )
+ raise
+
+ # For every possible use case of error handling, exception
+ # handling, trace availability in exception handlers and
+ # automatic exception recording to work, we need to make a
+ # series of wrapping and re-wrapping middlewares.
+
+ # First, grab the original middleware stack from Starlette. It
+ # comprises a stack of
+ # `ServerErrorMiddleware` -> [user defined middlewares] ->
`ExceptionMiddleware`
+ inner_server_error_middleware: ServerErrorMiddleware = ( #
type: ignore
+ self._original_build_middleware_stack() # type: ignore
+ )
+
+ if not isinstance(
+ inner_server_error_middleware, ServerErrorMiddleware
+ ):
+ # Oops, something changed about how Starlette creates
middleware stacks
+ _logger.error(
+ "Skipping FastAPI instrumentation due to unexpected
middleware stack: expected %s, got %s",
+ ServerErrorMiddleware.__name__,
+ type(inner_server_error_middleware),
+ )
+ return inner_server_error_middleware
+
+ # We take [user defined middlewares] ->
`ExceptionHandlerMiddleware`
+ # out of the outermost `ServerErrorMiddleware` and instead pass
+ # it to our own `ExceptionHandlerMiddleware`
+ exception_middleware = ExceptionHandlerMiddleware(
+ inner_server_error_middleware.app
+ )
+
+ # Now, we create a new `ServerErrorMiddleware` that wraps
+ # `ExceptionHandlerMiddleware` but otherwise uses the same
+ # original `handler` and debug setting. The end result is a
+ # middleware stack that's identical to the original stack
except
+ # all user middlewares are covered by our
+ # `ExceptionHandlerMiddleware`.
+ error_middleware = ServerErrorMiddleware(
+ app=exception_middleware,
+ handler=inner_server_error_middleware.handler,
+ debug=inner_server_error_middleware.debug,
+ )
+
+ # Finally, we wrap the stack above in our actual OTEL
+ # middleware. As a result, an active tracing context exists for
+ # every use case of user-defined error and exception handlers
as
+ # well as automatic recording of exceptions in active spans.
+ otel_middleware = OpenTelemetryMiddleware(
+ 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,
+ )
+
+ # Ultimately, wrap everything in another default
+ # `ServerErrorMiddleware` (w/o user handlers) 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.
+ return ServerErrorMiddleware(
+ app=otel_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)
@@ -309,105 +409,53 @@
@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
+ # Remove the app from the set of instrumented apps to avoid calling
uninstrument twice
+ # if the instrumentation is later disabled or such
+ # Use discard to avoid KeyError if already GC'ed
+ _InstrumentedFastAPI._instrumented_fastapi_apps.discard(app)
+
def instrumentation_dependencies(self) -> Collection[str]:
return _instruments
- def _instrument(self, **kwargs):
+ def _instrument(self, **kwargs: Any):
self._original_fastapi = fastapi.FastAPI
- _InstrumentedFastAPI._tracer_provider = kwargs.get("tracer_provider")
- _InstrumentedFastAPI._server_request_hook = kwargs.get(
- "server_request_hook"
- )
- _InstrumentedFastAPI._client_request_hook = kwargs.get(
- "client_request_hook"
- )
- _InstrumentedFastAPI._client_response_hook = kwargs.get(
- "client_response_hook"
- )
- _InstrumentedFastAPI._http_capture_headers_server_request = kwargs.get(
- "http_capture_headers_server_request"
- )
- _InstrumentedFastAPI._http_capture_headers_server_response = (
- kwargs.get("http_capture_headers_server_response")
- )
- _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._meter_provider = kwargs.get("meter_provider")
- _InstrumentedFastAPI._exclude_spans = kwargs.get("exclude_spans")
+ _InstrumentedFastAPI._instrument_kwargs = kwargs
fastapi.FastAPI = _InstrumentedFastAPI
def _uninstrument(self, **kwargs):
- for instance in _InstrumentedFastAPI._instrumented_fastapi_apps:
+ # Create a copy of the set to avoid RuntimeError during iteration
+ instances_to_uninstrument = list(
+ _InstrumentedFastAPI._instrumented_fastapi_apps
+ )
+ for instance in instances_to_uninstrument:
self.uninstrument_app(instance)
_InstrumentedFastAPI._instrumented_fastapi_apps.clear()
fastapi.FastAPI = self._original_fastapi
class _InstrumentedFastAPI(fastapi.FastAPI):
- _tracer_provider = None
- _meter_provider = None
- _excluded_urls = None
- _server_request_hook: ServerRequestHook = None
- _client_request_hook: ClientRequestHook = None
- _client_response_hook: ClientResponseHook = None
- _instrumented_fastapi_apps = set()
+ _instrument_kwargs: dict[str, Any] = {}
+
+ # Track instrumented app instances using weak references to avoid GC leaks
+ _instrumented_fastapi_apps: _WeakSet[fastapi.FastAPI] = _WeakSet()
_sem_conv_opt_in_mode = _StabilityMode.DEFAULT
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
- tracer = get_tracer(
- __name__,
- __version__,
- _InstrumentedFastAPI._tracer_provider,
- schema_url=_get_schema_url(
- _InstrumentedFastAPI._sem_conv_opt_in_mode
- ),
+ FastAPIInstrumentor.instrument_app(
+ self, **_InstrumentedFastAPI._instrument_kwargs
)
- 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,
- )
- self._is_instrumented_by_opentelemetry = True
_InstrumentedFastAPI._instrumented_fastapi_apps.add(self)
- def __del__(self):
- if self in _InstrumentedFastAPI._instrumented_fastapi_apps:
- _InstrumentedFastAPI._instrumented_fastapi_apps.remove(self)
-
def _get_route_details(scope):
"""
@@ -428,7 +476,11 @@
for starlette_route in app.routes:
match, _ = starlette_route.matches(scope)
if match == Match.FULL:
- route = starlette_route.path
+ try:
+ route = starlette_route.path
+ except AttributeError:
+ # routes added via host routing won't have a path attribute
+ route = scope.get("path")
break
if match == Match.PARTIAL:
route = starlette_route.path
@@ -450,7 +502,7 @@
if method == "_OTHER":
method = "HTTP"
if route:
- attributes[SpanAttributes.HTTP_ROUTE] = route
+ attributes[HTTP_ROUTE] = route
if method and route: # http
span_name = f"{method} {route}"
elif route: # websocket
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/package.py
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/package.py
---
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/package.py
2020-02-02 01:00:00.000000000 +0100
+++
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/package.py
2020-02-02 01:00:00.000000000 +0100
@@ -13,7 +13,7 @@
# limitations under the License.
-_instruments = ("fastapi ~= 0.58",)
+_instruments = ("fastapi ~= 0.92",)
_supports_metrics = True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/version.py
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/version.py
---
old/opentelemetry_instrumentation_fastapi-0.54b1/src/opentelemetry/instrumentation/fastapi/version.py
2020-02-02 01:00:00.000000000 +0100
+++
new/opentelemetry_instrumentation_fastapi-0.58b0/src/opentelemetry/instrumentation/fastapi/version.py
2020-02-02 01:00:00.000000000 +0100
@@ -12,4 +12,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-__version__ = "0.54b1"
+__version__ = "0.58b0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/opentelemetry_instrumentation_fastapi-0.54b1/tests/test_fastapi_instrumentation.py
new/opentelemetry_instrumentation_fastapi-0.58b0/tests/test_fastapi_instrumentation.py
---
old/opentelemetry_instrumentation_fastapi-0.54b1/tests/test_fastapi_instrumentation.py
2020-02-02 01:00:00.000000000 +0100
+++
new/opentelemetry_instrumentation_fastapi-0.58b0/tests/test_fastapi_instrumentation.py
2020-02-02 01:00:00.000000000 +0100
@@ -14,13 +14,19 @@
# pylint: disable=too-many-lines
+import gc as _gc
+import logging
import unittest
+import weakref as _weakref
+from contextlib import ExitStack
from timeit import default_timer
+from typing import Any, cast
from unittest.mock import Mock, call, patch
import fastapi
+import pytest
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.testclient import TestClient
import opentelemetry.instrumentation.fastapi as otel_fastapi
@@ -37,15 +43,29 @@
from opentelemetry.instrumentation.auto_instrumentation._load import (
_load_instrumentors,
)
-from opentelemetry.instrumentation.dependencies import (
- DependencyConflict,
- DependencyConflictError,
-)
+from opentelemetry.instrumentation.dependencies import DependencyConflict
from opentelemetry.sdk.metrics.export import (
HistogramDataPoint,
NumberDataPoint,
)
from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import ReadableSpan
+from opentelemetry.semconv._incubating.attributes.http_attributes import (
+ HTTP_FLAVOR,
+ HTTP_HOST,
+ HTTP_METHOD,
+ HTTP_SCHEME,
+ HTTP_SERVER_NAME,
+ HTTP_STATUS_CODE,
+ HTTP_TARGET,
+ HTTP_URL,
+)
+from opentelemetry.semconv._incubating.attributes.net_attributes import (
+ NET_HOST_PORT,
+)
+from opentelemetry.semconv.attributes.exception_attributes import (
+ EXCEPTION_TYPE,
+)
from opentelemetry.semconv.attributes.http_attributes import (
HTTP_REQUEST_METHOD,
HTTP_RESPONSE_STATUS_CODE,
@@ -55,9 +75,9 @@
NETWORK_PROTOCOL_VERSION,
)
from opentelemetry.semconv.attributes.url_attributes import URL_SCHEME
-from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.test.globals_test import reset_trace_globals
from opentelemetry.test.test_base import TestBase
+from opentelemetry.trace.status import StatusCode
from opentelemetry.util._importlib_metadata import entry_points
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
@@ -85,15 +105,15 @@
"http.server.active_requests": _server_active_requests_count_attrs_old,
"http.server.duration": {
*_server_duration_attrs_old,
- SpanAttributes.HTTP_TARGET,
+ HTTP_TARGET,
},
"http.server.response.size": {
*_server_duration_attrs_old,
- SpanAttributes.HTTP_TARGET,
+ HTTP_TARGET,
},
"http.server.request.size": {
*_server_duration_attrs_old,
- SpanAttributes.HTTP_TARGET,
+ HTTP_TARGET,
},
}
@@ -171,9 +191,14 @@
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()
@@ -206,11 +231,20 @@
async def _():
return {"message": "ok"}
+ @app.get("/error")
+ async def _():
+ raise UnhandledException("This is an unhandled exception")
+
app.mount("/sub", app=sub_app)
+ app.host("testserver2", sub_app)
return app
+class UnhandledException(Exception):
+ pass
+
+
class TestBaseManualFastAPI(TestBaseFastAPI):
@classmethod
def setUpClass(cls):
@@ -221,6 +255,27 @@
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
@@ -244,10 +299,7 @@
spans_with_http_attributes = [
span
for span in spans
- if (
- SpanAttributes.HTTP_URL in span.attributes
- or SpanAttributes.HTTP_TARGET in span.attributes
- )
+ if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes)
]
# We expect only one span to have the HTTP attributes set (the SERVER
span from the app itself)
@@ -255,14 +307,32 @@
self.assertEqual(1, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
- self.assertEqual(
- "/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
- )
+ self.assertEqual("/sub/home", span.attributes[HTTP_TARGET])
self.assertEqual(
- "https://testserver:443/sub/home",
- span.attributes[SpanAttributes.HTTP_URL],
+ "https://testserver/sub/home",
+ span.attributes[HTTP_URL],
)
+ def test_host_fastapi_call(self):
+ client = TestClient(self._app, base_url="https://testserver2:443")
+ client.get("/")
+ spans = self.memory_exporter.get_finished_spans()
+
+ spans_with_http_attributes = [
+ span
+ for span in spans
+ if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes)
+ ]
+
+ self.assertEqual(1, len(spans_with_http_attributes))
+
+ for span in spans_with_http_attributes:
+ self.assertEqual("/", span.attributes[HTTP_TARGET])
+ self.assertEqual(
+ "https://testserver2/",
+ span.attributes[HTTP_URL],
+ )
+
class TestBaseAutoFastAPI(TestBaseFastAPI):
@classmethod
@@ -308,22 +378,17 @@
spans_with_http_attributes = [
span
for span in spans
- if (
- SpanAttributes.HTTP_URL in span.attributes
- or SpanAttributes.HTTP_TARGET in span.attributes
- )
+ if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
- self.assertEqual(
- "/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
- )
+ self.assertEqual("/sub/home", span.attributes[HTTP_TARGET])
self.assertEqual(
- "https://testserver:443/sub/home",
- span.attributes[SpanAttributes.HTTP_URL],
+ "https://testserver/sub/home",
+ span.attributes[HTTP_URL],
)
@@ -381,14 +446,10 @@
self.assertEqual(len(spans), 3)
for span in spans:
self.assertIn("GET /user/{username}", span.name)
- self.assertEqual(
- spans[-1].attributes[SpanAttributes.HTTP_ROUTE], "/user/{username}"
- )
+ self.assertEqual(spans[-1].attributes[HTTP_ROUTE], "/user/{username}")
# ensure that at least one attribute that is populated by
# the asgi instrumentation is successfully feeding though.
- self.assertEqual(
- spans[-1].attributes[SpanAttributes.HTTP_FLAVOR], "1.1"
- )
+ self.assertEqual(spans[-1].attributes[HTTP_FLAVOR], "1.1")
def test_fastapi_excluded_urls(self):
"""Ensure that given fastapi routes are excluded."""
@@ -511,21 +572,21 @@
self._client.get("/foobar")
duration = max(round((default_timer() - start) * 1000), 0)
expected_duration_attributes = {
- SpanAttributes.HTTP_METHOD: "GET",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
- SpanAttributes.NET_HOST_PORT: 443,
- SpanAttributes.HTTP_STATUS_CODE: 200,
- SpanAttributes.HTTP_TARGET: "/foobar",
+ HTTP_METHOD: "GET",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
+ NET_HOST_PORT: 443,
+ HTTP_STATUS_CODE: 200,
+ HTTP_TARGET: "/foobar",
}
expected_requests_count_attributes = {
- SpanAttributes.HTTP_METHOD: "GET",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
+ HTTP_METHOD: "GET",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
}
metrics_list = self.memory_metrics_reader.get_metrics_data()
for metric in (
@@ -593,14 +654,14 @@
duration = max(round((default_timer() - start) * 1000), 0)
duration_s = max(default_timer() - start, 0)
expected_duration_attributes_old = {
- SpanAttributes.HTTP_METHOD: "GET",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
- SpanAttributes.NET_HOST_PORT: 443,
- SpanAttributes.HTTP_STATUS_CODE: 200,
- SpanAttributes.HTTP_TARGET: "/foobar",
+ HTTP_METHOD: "GET",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
+ NET_HOST_PORT: 443,
+ HTTP_STATUS_CODE: 200,
+ HTTP_TARGET: "/foobar",
}
expected_duration_attributes_new = {
HTTP_REQUEST_METHOD: "GET",
@@ -610,11 +671,11 @@
HTTP_ROUTE: "/foobar",
}
expected_requests_count_attributes = {
- SpanAttributes.HTTP_METHOD: "GET",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
+ HTTP_METHOD: "GET",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
HTTP_REQUEST_METHOD: "GET",
URL_SCHEME: "https",
}
@@ -676,21 +737,21 @@
self._client.request("NONSTANDARD", "/foobar")
duration = max(round((default_timer() - start) * 1000), 0)
expected_duration_attributes = {
- SpanAttributes.HTTP_METHOD: "_OTHER",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
- SpanAttributes.NET_HOST_PORT: 443,
- SpanAttributes.HTTP_STATUS_CODE: 405,
- SpanAttributes.HTTP_TARGET: "/foobar",
+ HTTP_METHOD: "_OTHER",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
+ NET_HOST_PORT: 443,
+ HTTP_STATUS_CODE: 405,
+ HTTP_TARGET: "/foobar",
}
expected_requests_count_attributes = {
- SpanAttributes.HTTP_METHOD: "_OTHER",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
+ HTTP_METHOD: "_OTHER",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
}
metrics_list = self.memory_metrics_reader.get_metrics_data()
for metric in (
@@ -758,14 +819,14 @@
duration = max(round((default_timer() - start) * 1000), 0)
duration_s = max(default_timer() - start, 0)
expected_duration_attributes_old = {
- SpanAttributes.HTTP_METHOD: "_OTHER",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
- SpanAttributes.NET_HOST_PORT: 443,
- SpanAttributes.HTTP_STATUS_CODE: 405,
- SpanAttributes.HTTP_TARGET: "/foobar",
+ HTTP_METHOD: "_OTHER",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
+ NET_HOST_PORT: 443,
+ HTTP_STATUS_CODE: 405,
+ HTTP_TARGET: "/foobar",
}
expected_duration_attributes_new = {
HTTP_REQUEST_METHOD: "_OTHER",
@@ -775,11 +836,11 @@
HTTP_ROUTE: "/foobar",
}
expected_requests_count_attributes = {
- SpanAttributes.HTTP_METHOD: "_OTHER",
- SpanAttributes.HTTP_HOST: "testserver:443",
- SpanAttributes.HTTP_SCHEME: "https",
- SpanAttributes.HTTP_FLAVOR: "1.1",
- SpanAttributes.HTTP_SERVER_NAME: "testserver",
+ HTTP_METHOD: "_OTHER",
+ HTTP_HOST: "testserver:443",
+ HTTP_SCHEME: "https",
+ HTTP_FLAVOR: "1.1",
+ HTTP_SERVER_NAME: "testserver",
HTTP_REQUEST_METHOD: "_OTHER",
URL_SCHEME: "https",
}
@@ -977,49 +1038,54 @@
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 TestFastAPIManualInstrumentationHooks(TestBaseManualFastAPI):
- _server_request_hook = None
- _client_request_hook = None
- _client_response_hook = None
-
- def server_request_hook(self, span, scope):
- if self._server_request_hook is not None:
- self._server_request_hook(span, scope)
-
- def client_request_hook(self, receive_span, scope, message):
- if self._client_request_hook is not None:
- self._client_request_hook(receive_span, scope, message)
-
- def client_response_hook(self, send_span, scope, message):
- if self._client_response_hook is not None:
- self._client_response_hook(send_span, scope, message)
-
- def test_hooks(self):
- def server_request_hook(span, scope):
+class TestFastAPIManualInstrumentationHooks(TestBaseFastAPI):
+ def _create_app(self):
+ def server_request_hook(span: trace.Span, scope: dict[str, Any]):
span.update_name("name from server hook")
- def client_request_hook(receive_span, scope, message):
+ def client_request_hook(
+ receive_span: trace.Span,
+ scope: dict[str, Any],
+ message: dict[str, Any],
+ ):
receive_span.update_name("name from client hook")
receive_span.set_attribute("attr-from-request-hook", "set")
- def client_response_hook(send_span, scope, message):
+ def client_response_hook(
+ send_span: trace.Span,
+ scope: dict[str, Any],
+ message: dict[str, Any],
+ ):
send_span.update_name("name from response hook")
send_span.set_attribute("attr-from-response-hook", "value")
- self._server_request_hook = server_request_hook
- self._client_request_hook = client_request_hook
- self._client_response_hook = client_response_hook
+ self._instrumentor.instrument(
+ server_request_hook=server_request_hook,
+ client_request_hook=client_request_hook,
+ client_response_hook=client_response_hook,
+ )
+
+ app = self._create_fastapi_app()
+
+ return app
+ def test_hooks(self):
self._client.get("/foobar")
- spans = self.sorted_spans(self.memory_exporter.get_finished_spans())
- self.assertEqual(
- len(spans), 3
- ) # 1 server span and 2 response spans (response start and body)
+
+ spans = cast(
+ list[ReadableSpan],
+ self.sorted_spans(self.memory_exporter.get_finished_spans()),
+ )
+ self.assertEqual(len(spans), 3)
server_span = spans[2]
self.assertEqual(server_span.name, "name from server hook")
@@ -1065,40 +1131,34 @@
[self._instrumentation_loaded_successfully_call()]
)
+ @patch(
+
"opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts"
+ )
@patch("opentelemetry.instrumentation.auto_instrumentation._load._logger")
- def test_instruments_with_old_fastapi_installed(self, mock_logger): #
pylint: disable=no-self-use
+ def test_instruments_with_old_fastapi_installed(
+ self, mock_logger, mock_dep
+ ): # pylint: disable=no-self-use
dependency_conflict = DependencyConflict("0.58", "0.57")
mock_distro = Mock()
- mock_distro.load_instrumentor.side_effect = DependencyConflictError(
- dependency_conflict
- )
+ mock_dep.return_value = dependency_conflict
_load_instrumentors(mock_distro)
- self.assertEqual(len(mock_distro.load_instrumentor.call_args_list), 1)
- (ep,) = mock_distro.load_instrumentor.call_args.args
- self.assertEqual(ep.name, "fastapi")
- assert (
- self._instrumentation_loaded_successfully_call()
- not in mock_logger.debug.call_args_list
- )
+ mock_distro.load_instrumentor.assert_not_called()
mock_logger.debug.assert_has_calls(
[self._instrumentation_failed_to_load_call(dependency_conflict)]
)
+ @patch(
+
"opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts"
+ )
@patch("opentelemetry.instrumentation.auto_instrumentation._load._logger")
- def test_instruments_without_fastapi_installed(self, mock_logger): #
pylint: disable=no-self-use
+ def test_instruments_without_fastapi_installed(
+ self, mock_logger, mock_dep
+ ): # pylint: disable=no-self-use
dependency_conflict = DependencyConflict("0.58", None)
mock_distro = Mock()
- mock_distro.load_instrumentor.side_effect = DependencyConflictError(
- dependency_conflict
- )
+ mock_dep.return_value = dependency_conflict
_load_instrumentors(mock_distro)
- self.assertEqual(len(mock_distro.load_instrumentor.call_args_list), 1)
- (ep,) = mock_distro.load_instrumentor.call_args.args
- self.assertEqual(ep.name, "fastapi")
- assert (
- self._instrumentation_loaded_successfully_call()
- not in mock_logger.debug.call_args_list
- )
+ mock_distro.load_instrumentor.assert_not_called()
mock_logger.debug.assert_has_calls(
[self._instrumentation_failed_to_load_call(dependency_conflict)]
)
@@ -1139,9 +1199,11 @@
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):
@@ -1203,22 +1265,17 @@
spans_with_http_attributes = [
span
for span in spans
- if (
- SpanAttributes.HTTP_URL in span.attributes
- or SpanAttributes.HTTP_TARGET in span.attributes
- )
+ if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
- self.assertEqual(
- "/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
- )
+ self.assertEqual("/sub/home", span.attributes[HTTP_TARGET])
self.assertEqual(
- "https://testserver:443/sub/home",
- span.attributes[SpanAttributes.HTTP_URL],
+ "https://testserver/sub/home",
+ span.attributes[HTTP_URL],
)
@@ -1296,22 +1353,17 @@
spans_with_http_attributes = [
span
for span in spans
- if (
- SpanAttributes.HTTP_URL in span.attributes
- or SpanAttributes.HTTP_TARGET in span.attributes
- )
+ if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
- self.assertEqual(
- "/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
- )
+ self.assertEqual("/sub/home", span.attributes[HTTP_TARGET])
self.assertEqual(
- "https://testserver:443/sub/home",
- span.attributes[SpanAttributes.HTTP_URL],
+ "https://testserver/sub/home",
+ span.attributes[HTTP_URL],
)
@@ -1378,6 +1430,16 @@
)
+class TestFastAPIGarbageCollection(unittest.TestCase):
+ def test_fastapi_app_is_collected_after_instrument(self):
+ app = fastapi.FastAPI()
+ otel_fastapi.FastAPIInstrumentor().instrument_app(app)
+ app_ref = _weakref.ref(app)
+ del app
+ _gc.collect()
+ self.assertIsNone(app_ref())
+
+
@patch.dict(
"os.environ",
{
@@ -1855,3 +1917,507 @@
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 0)
+
+
+class TestTraceableExceptionHandling(TestBase):
+ """Tests to ensure FastAPI exception handlers are only executed once and
with a valid context"""
+
+ def setUp(self):
+ super().setUp()
+
+ self.app = fastapi.FastAPI()
+
+ otel_fastapi.FastAPIInstrumentor().instrument_app(
+ self.app, exclude_spans=["receive", "send"]
+ )
+ self.client = TestClient(self.app)
+ self.tracer = self.tracer_provider.get_tracer(__name__)
+ self.executed = 0
+ self.request_trace_id = None
+ self.error_trace_id = None
+
+ def tearDown(self) -> None:
+ super().tearDown()
+ with self.disable_logging():
+ otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
+
+ def test_error_handler_context(self):
+ """OTEL tracing contexts must be available during error handler
+ execution, and handlers must only be executed once"""
+
+ status_code = 501
+
+ @self.app.exception_handler(Exception)
+ async def _(*_):
+ self.error_trace_id = (
+ trace.get_current_span().get_span_context().trace_id
+ )
+ self.executed += 1
+ return PlainTextResponse("", status_code)
+
+ @self.app.get("/foobar")
+ async def _():
+ self.request_trace_id = (
+ trace.get_current_span().get_span_context().trace_id
+ )
+ raise UnhandledException("Test Exception")
+
+ try:
+ self.client.get(
+ "/foobar",
+ )
+ except UnhandledException:
+ pass
+
+ self.assertIsNotNone(self.request_trace_id)
+ self.assertEqual(self.request_trace_id, self.error_trace_id)
+
+ spans = self.memory_exporter.get_finished_spans()
+
+ self.assertEqual(len(spans), 1)
+ span = spans[0]
+ self.assertEqual(span.name, "GET /foobar")
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), status_code)
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+ self.assertEqual(len(span.events), 1)
+ event = span.events[0]
+ self.assertEqual(event.name, "exception")
+ assert event.attributes is not None
+ self.assertEqual(
+ event.attributes.get(EXCEPTION_TYPE),
+ f"{__name__}.UnhandledException",
+ )
+ self.assertEqual(self.executed, 1)
+
+ def test_exception_span_recording(self):
+ """Exceptions are always recorded in the active span"""
+
+ @self.app.get("/foobar")
+ async def _():
+ raise UnhandledException("Test Exception")
+
+ try:
+ self.client.get(
+ "/foobar",
+ )
+ except UnhandledException:
+ pass
+
+ spans = self.memory_exporter.get_finished_spans()
+
+ self.assertEqual(len(spans), 1)
+ span = spans[0]
+ self.assertEqual(span.name, "GET /foobar")
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 500)
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+ self.assertEqual(len(span.events), 1)
+ event = span.events[0]
+ self.assertEqual(event.name, "exception")
+ assert event.attributes is not None
+ self.assertEqual(
+ event.attributes.get(EXCEPTION_TYPE),
+ f"{__name__}.UnhandledException",
+ )
+
+ def test_middleware_exceptions(self):
+ """Exceptions from user middlewares are recorded in the active span"""
+
+ @self.app.get("/foobar")
+ async def _():
+ return PlainTextResponse("Hello World")
+
+ @self.app.middleware("http")
+ async def _(*_):
+ raise UnhandledException("Test Exception")
+
+ try:
+ self.client.get(
+ "/foobar",
+ )
+ except UnhandledException:
+ pass
+
+ spans = self.memory_exporter.get_finished_spans()
+
+ self.assertEqual(len(spans), 1)
+ span = spans[0]
+ self.assertEqual(span.name, "GET /foobar")
+ self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 500)
+ self.assertEqual(span.status.status_code, StatusCode.ERROR)
+ self.assertEqual(len(span.events), 1)
+ event = span.events[0]
+ self.assertEqual(event.name, "exception")
+ assert event.attributes is not None
+ self.assertEqual(
+ event.attributes.get(EXCEPTION_TYPE),
+ f"{__name__}.UnhandledException",
+ )
+
+
+# pylint: disable=attribute-defined-outside-init
+class TestFastAPIFallback(TestBaseFastAPI):
+ @pytest.fixture(autouse=True)
+ def inject_fixtures(self, caplog):
+ self.caplog = caplog
+
+ @staticmethod
+ def _create_fastapi_app():
+ app = TestBaseFastAPI._create_fastapi_app()
+
+ def build_middleware_stack():
+ return app.router
+
+ app.build_middleware_stack = build_middleware_stack
+ return app
+
+ def setUp(self):
+ super().setUp()
+ self.client = TestClient(self._app)
+
+ def test_no_instrumentation(self):
+ self.client.get(
+ "/foobar",
+ )
+
+ spans = self.memory_exporter.get_finished_spans()
+ self.assertEqual(len(spans), 0)
+
+ errors = [
+ record
+ for record in self.caplog.get_records("call")
+ if record.levelno >= logging.ERROR
+ ]
+ self.assertEqual(len(errors), 1)
+ self.assertEqual(
+ errors[0].getMessage(),
+ "Skipping FastAPI instrumentation due to unexpected middleware
stack: expected ServerErrorMiddleware, got <class 'fastapi.routing.APIRouter'>",
+ )
+
+
+class TestFastAPIHostHeaderURL(TestBaseManualFastAPI):
+ """Test suite for Host header URL functionality in FastAPI
instrumentation."""
+
+ def test_host_header_url_construction(self):
+ """Test that URLs use Host header value instead of server IP when
available."""
+ # Test with a custom Host header - should use the domain name
+ resp = self._client.get(
+ "/foobar?param=value", headers={"host": "api.mycompany.com"}
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ self.assertEqual(len(spans), 3)
+
+ # Find the server span (the main span, not internal middleware spans)
+ server_span = None
+ for span in spans:
+ if (
+ span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ):
+ server_span = span
+ break
+
+ self.assertIsNotNone(
+ server_span, "Server span with HTTP_URL not found"
+ )
+
+ # Verify the URL uses the Host header domain instead of testserver
+ expected_url = "https://api.mycompany.com/foobar?param=value"
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ # Also verify that the server name attribute is set correctly
+ self.assertEqual(
+ "api.mycompany.com", server_span.attributes.get("http.server_name")
+ )
+
+ def test_host_header_with_port_url_construction(self):
+ """Test Host header URL construction when host includes port."""
+ resp = self._client.get(
+ "/user/123", headers={"host": "staging.myapp.com:8443"}
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # Should use the host header value with non-standard port included
+ expected_url = "https://staging.myapp.com:8443/user/123"
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ def test_no_host_header_fallback_behavior(self):
+ """Test fallback to server name when no Host header is present."""
+ # Make request without custom Host header - should use testserver
(default TestClient base)
+ resp = self._client.get("/foobar")
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # Should fallback to testserver (TestClient default, standard port
stripped)
+ expected_url = "https://testserver/foobar"
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ def test_production_scenario_host_header(self):
+ """Test a realistic production scenario with Host header."""
+ # Simulate a production request with public domain in Host header
+ resp = self._client.get(
+ "/foobar?limit=10&offset=20",
+ headers={
+ "host": "prod-api.example.com",
+ "user-agent": "ProductionClient/1.0",
+ },
+ )
+ self.assertEqual(
+ 200, resp.status_code
+ ) # Valid route should return 200
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # URL should use the production domain from Host header (AS-IS, no
default port)
+ expected_url = "https://prod-api.example.com/foobar?limit=10&offset=20"
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ # Verify other attributes are still correct
+ self.assertEqual("GET", server_span.attributes[HTTP_METHOD])
+ self.assertEqual("/foobar", server_span.attributes[HTTP_TARGET])
+ self.assertEqual(
+ "prod-api.example.com",
+ server_span.attributes.get("http.server_name"),
+ )
+
+ def test_host_header_with_special_characters(self):
+ """Test Host header handling with special characters and edge cases."""
+ test_cases = [
+ (
+ "api-v2.test-domain.com",
+ "https://api-v2.test-domain.com/foobar",
+ ),
+ ("localhost", "https://localhost/foobar"),
+ (
+ "192.168.1.100",
+ "https://192.168.1.100/foobar",
+ ), # IP address as host
+ (
+ "test.domain.co.uk",
+ "https://test.domain.co.uk/foobar",
+ ), # Multiple dots
+ ]
+
+ for host_value, expected_url in test_cases:
+ with self.subTest(host=host_value):
+ # Clear previous spans
+ self.memory_exporter.clear()
+
+ resp = self._client.get(
+ "/foobar", headers={"host": host_value}
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ def test_host_header_maintains_span_attributes(self):
+ """Test that using Host header doesn't break other span attributes."""
+ resp = self._client.get(
+ "/user/testuser?debug=true",
+ headers={
+ "host": "api.testapp.com",
+ "user-agent": "TestClient/1.0",
+ },
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # Verify URL uses Host header
+ self.assertEqual(
+ "https://api.testapp.com/user/testuser?debug=true",
+ server_span.attributes[HTTP_URL],
+ )
+
+ # Verify all other attributes are still present and correct
+ self.assertEqual("GET", server_span.attributes[HTTP_METHOD])
+ self.assertEqual("/user/testuser", server_span.attributes[HTTP_TARGET])
+ self.assertEqual("https", server_span.attributes[HTTP_SCHEME])
+ self.assertEqual(
+ "api.testapp.com", server_span.attributes.get("http.server_name")
+ )
+ self.assertEqual(200, server_span.attributes[HTTP_STATUS_CODE])
+
+ # Check that route attribute is still set correctly
+ if HTTP_ROUTE in server_span.attributes:
+ self.assertEqual(
+ "/user/{username}", server_span.attributes[HTTP_ROUTE]
+ )
+
+
+class TestFastAPIHostHeaderURLNewSemconv(TestFastAPIHostHeaderURL):
+ """Test Host header URL functionality with new semantic conventions."""
+
+ def test_host_header_url_new_semconv(self):
+ """Test Host header URL construction with new semantic conventions.
+
+ Note: With new semantic conventions, the URL is split into components
+ (url.scheme, server.address, url.path, etc.) rather than a single
http.url.
+ Host header support may work differently with new semantic conventions.
+ """
+ resp = self._client.get(
+ "/foobar?test=new_semconv", headers={"host": "newapi.example.com"}
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ # With new semantic conventions, look for the main HTTP span with
route information
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and "http.route" in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # Verify we have the new semantic convention attributes
+ self.assertIn("url.scheme", server_span.attributes)
+ self.assertIn("server.address", server_span.attributes)
+ self.assertIn("url.path", server_span.attributes)
+ self.assertEqual("https", server_span.attributes.get("url.scheme"))
+ self.assertEqual("/foobar", server_span.attributes.get("url.path"))
+
+ # Current behavior: Host header may not affect server.address in new
semantic conventions
+ # This test documents the current behavior rather than enforcing Host
header usage
+ server_address = server_span.attributes.get("server.address", "")
+ self.assertIsNotNone(
+ server_address, "testserver"
+ ) # Should have some value
+
+
+class TestFastAPIHostHeaderURLBothSemconv(TestFastAPIHostHeaderURL):
+ """Test Host header URL functionality with both old and new semantic
conventions."""
+
+ def test_host_header_url_both_semconv(self):
+ """Test Host header URL construction with both semantic conventions
enabled."""
+ resp = self._client.get(
+ "/foobar?test=both_semconv", headers={"host": "dual.example.com"}
+ )
+ self.assertEqual(200, resp.status_code)
+
+ spans = self.memory_exporter.get_finished_spans()
+ server_span = next(
+ (
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and HTTP_URL in span.attributes
+ ),
+ None,
+ )
+ self.assertIsNotNone(server_span)
+
+ # Should use Host header for URL construction regardless of semantic
convention mode
+ expected_url = "https://dual.example.com/foobar?test=both_semconv"
+ actual_url = server_span.attributes[HTTP_URL]
+ self.assertEqual(expected_url, actual_url)
+
+ def test_fastapi_unhandled_exception(self):
+ """Override inherited test - use the both_semconv version instead."""
+ self.skipTest(
+ "Use test_fastapi_unhandled_exception_both_semconv instead"
+ )
+
+ def test_fastapi_unhandled_exception_both_semconv(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()
+ # With both semantic conventions enabled, we expect 3 spans:
+ # 1. Server span (main HTTP span)
+ # 2. ASGI receive span
+ # 3. ASGI send span (for error response)
+ self.assertEqual(len(spans), 3)
+
+ # Find the server span (it should have HTTP attributes)
+ server_spans = [
+ span
+ for span in spans
+ if span.kind == trace.SpanKind.SERVER
+ and hasattr(span, "attributes")
+ and span.attributes
+ and HTTP_URL in span.attributes
+ ]
+
+ self.assertEqual(
+ len(server_spans),
+ 1,
+ "Expected exactly one server span with HTTP_URL",
+ )
+ server_span = server_spans[0]
+
+ # Ensure server_span is not None
+ assert server_span is not None
+
+ self.assertEqual(server_span.name, "GET /error")