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")

Reply via email to