Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@


class TestWrappedApplication(TestBase):

def setUp(self):
super().setUp()

Expand All @@ -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__)

Expand Down Expand Up @@ -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}'",
)
Loading