Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 @@ -13,6 +13,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))

## Version 1.27.0/0.48b0 ()

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 = True,
):
"""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 @@ -1839,3 +1839,50 @@ def test_custom_header_not_present_in_non_recording_span(self):
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 0)


class TestFastAPIRenderPathParameters(TestBaseFastAPI):

def test_render_path_parameters(self):
"""Test that path parameters are rendered correctly in spans."""

# Create a FastAPI app with a path parameter
app = fastapi.FastAPI()

@app.get("/user/{username}")
async def read_user(username: str):
return {"username": username}

# Instrument the app
otel_fastapi.FastAPIInstrumentor().instrument_app(
app, render_path_parameters=True)
client = TestClient(app)

# Make a request to the endpoint with a path parameter
response = client.get("/user/johndoe")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.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")

def tearDown(self):
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
Loading