diff --git a/CHANGELOG.md b/CHANGELOG.md index a43ec36b00..b644092d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#2860](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2860)) - `opentelemetry-instrumentation-aiokafka` Add instrumentor and auto instrumentation support for aiokafka ([#2082](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2082)) +- `opentelemetry-instrumentation-fastapi` Add path parameter rendering feature. + ([#2879](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2879)) - `opentelemetry-instrumentation-redis` Add additional attributes for methods create_index and search, rename those spans ([#2635](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2635)) - `opentelemetry-instrumentation` Add support for string based dotted module paths in unwrap diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py index 7e4d0aac07..c5b85854aa 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -178,6 +178,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A from __future__ import annotations +import re import logging from typing import Collection, Literal @@ -233,6 +234,7 @@ def instrument_app( 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, + render_path_parameters: bool = False, ): """Instrument an uninstrumented FastAPI application. @@ -253,6 +255,7 @@ def instrument_app( http_capture_headers_server_response: Optional list of HTTP headers to capture from the response. http_capture_headers_sanitize_fields: Optional list of HTTP headers to sanitize. exclude_spans: Optionally exclude HTTP `send` and/or `receive` spans from the trace. + render_path_parameters: Optional boolean to enable or disable rendering path parameters in the span name. """ if not hasattr(app, "_is_instrumented_by_opentelemetry"): app._is_instrumented_by_opentelemetry = False @@ -283,7 +286,7 @@ def instrument_app( app.add_middleware( OpenTelemetryMiddleware, excluded_urls=excluded_urls, - default_span_details=_get_default_span_details, + default_span_details=create_span_details_function(render_path_parameters), server_request_hook=server_request_hook, client_request_hook=client_request_hook, client_response_hook=client_response_hook, @@ -345,6 +348,7 @@ def _instrument(self, **kwargs): ) _InstrumentedFastAPI._meter_provider = kwargs.get("meter_provider") _InstrumentedFastAPI._exclude_spans = kwargs.get("exclude_spans") + _InstrumentedFastAPI._render_path_parameters = kwargs.get("render_path_parameters") fastapi.FastAPI = _InstrumentedFastAPI def _uninstrument(self, **kwargs): @@ -363,6 +367,7 @@ class _InstrumentedFastAPI(fastapi.FastAPI): _client_response_hook: ClientResponseHook = None _instrumented_fastapi_apps = set() _sem_conv_opt_in_mode = _HTTPStabilityMode.DEFAULT + _render_path_parameters = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -385,7 +390,7 @@ def __init__(self, *args, **kwargs): self.add_middleware( OpenTelemetryMiddleware, excluded_urls=_InstrumentedFastAPI._excluded_urls, - default_span_details=_get_default_span_details, + default_span_details=create_span_details_function(_InstrumentedFastAPI._render_path_parameters), server_request_hook=_InstrumentedFastAPI._server_request_hook, client_request_hook=_InstrumentedFastAPI._client_request_hook, client_response_hook=_InstrumentedFastAPI._client_response_hook, @@ -431,26 +436,47 @@ def _get_route_details(scope): return route -def _get_default_span_details(scope): +def create_span_details_function(render_path_parameters): + + def span_details(scope): + return _get_default_span_details(scope, render_path_parameters) + + return span_details + + +def _get_default_span_details(scope, render_path_parameters: bool = False): """ Callback to retrieve span name and attributes from scope. Args: scope: A Starlette scope + render_path_parameters: A boolean flag to indicate whether to render path parameters. Returns: A tuple of span name and attributes """ route = _get_route_details(scope) method = sanitize_method(scope.get("method", "").strip()) attributes = {} + if method == "_OTHER": method = "HTTP" + if route: + # Here we can replace path parameters with actual values + if render_path_parameters: + path_params = scope.get("path_params", {}) + pattern = re.compile(r"\{(\w+)\}") + route = pattern.sub( + lambda match: str( + path_params.get(match.group(1), match.group(0))), route) + attributes[SpanAttributes.HTTP_ROUTE] = route + if method and route: # http span_name = f"{method} {route}" elif route: # websocket span_name = route else: # fallback span_name = method + return span_name, attributes diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation_wrapped.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation_wrapped.py index 0b17173ac6..76f8b547d2 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation_wrapped.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation_wrapped.py @@ -20,6 +20,7 @@ class TestWrappedApplication(TestBase): + def setUp(self): super().setUp() @@ -29,7 +30,13 @@ def setUp(self): async def _(): return {"message": "hello world"} - otel_fastapi.FastAPIInstrumentor().instrument_app(self.app) + @self.app.get("/user/{username}") + async def _(username: str): + return {"username": username} + + otel_fastapi.FastAPIInstrumentor().instrument_app( + self.app, render_path_parameters=True + ) self.client = TestClient(self.app) self.tracer = self.tracer_provider.get_tracer(__name__) @@ -62,3 +69,59 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self): self.assertEqual( parent_span.context.span_id, span_list[3].context.span_id ) + + def test_render_path_parameters(self): + """Test that path parameters are rendered correctly in spans.""" + + # Make sure non-path parameters are not affected + resp = self.client.get("/foobar") + self.assertEqual(resp.status_code, 200) + spans = self.memory_exporter.get_finished_spans() + expected_span_name = "GET /foobar http send" + self.assertEqual( + spans[0].name, + expected_span_name, + f"Expected span name to be '{expected_span_name}', but got '{spans[0].name}'", + ) + + # Make a request to the endpoint with a path parameter + resp = self.client.get("/user/johndoe") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {"username": "johndoe"}) + + # Retrieve the spans generated + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 3) # Adjust based on expected spans + + # Check that the span for the request contains the expected attributes + server_span = [ + span for span in spans if span.kind == trace.SpanKind.SERVER + ][0] + + # Verify that the path parameter is rendered correctly + self.assertIn("http.route", server_span.attributes) + self.assertEqual( + server_span.attributes["http.route"], "/user/{username}" + ) + + # Optionally, check if the username is also included in the span attributes + self.assertIn("http.path_parameters.username", server_span.attributes) + self.assertEqual( + server_span.attributes["http.path_parameters.username"], "johndoe" + ) + + # Retrieve the spans generated + spans = self.memory_exporter.get_finished_spans() + + # Assert that at least one span was created + self.assertGreater(len(spans), 0, "No spans were generated.") + + # Assert that the span name is as expected + expected_span_name = ( + "GET /user/johndoe" # Adjust this based on your implementation + ) + self.assertEqual( + spans[0].name, + expected_span_name, + f"Expected span name to be '{expected_span_name}', but got '{spans[0].name}'", + )