Skip to content

Commit 8d04d4b

Browse files
authored
Merge pull request #70 from modern-python/litestar-otel-span-naming
use route template for Litestar OTel span naming
2 parents 3d3b8b8 + 8ff2e01 commit 8d04d4b

2 files changed

Lines changed: 101 additions & 4 deletions

File tree

lite_bootstrap/bootstrappers/litestar_bootstrapper.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,59 @@
3333
from litestar.static_files import create_static_files_router
3434

3535
if import_checker.is_litestar_opentelemetry_installed:
36-
from litestar.contrib.opentelemetry import OpenTelemetryConfig
36+
from litestar.middleware import ASGIMiddleware
37+
from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send
38+
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
39+
from opentelemetry.trace import TracerProvider
3740

3841
if import_checker.is_opentelemetry_installed:
3942
from opentelemetry.trace import get_tracer_provider
4043

4144

45+
def build_span_name(method: str, route: str) -> str:
46+
if not route:
47+
return method
48+
return f"{method} {route}"
49+
50+
51+
def build_litestar_route_details_from_scope(
52+
scope: typing.MutableMapping[str, typing.Any],
53+
) -> tuple[str, dict[str, str]]:
54+
path_template: typing.Final = scope.get("path_template")
55+
method: typing.Final = str(scope.get("method", "HTTP")).strip()
56+
if path_template is not None:
57+
path_template_stripped: typing.Final = path_template.strip()
58+
return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped}
59+
60+
path: typing.Final = scope.get("path")
61+
if path is not None:
62+
path_stripped: typing.Final = path.strip()
63+
return build_span_name(method, path_stripped), {"http.route": path_stripped}
64+
return method, {}
65+
66+
67+
if import_checker.is_litestar_opentelemetry_installed:
68+
69+
class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
70+
def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None:
71+
self._tracer_provider = tracer_provider
72+
self._excluded_urls = ",".join(excluded_urls)
73+
74+
async def handle(
75+
self,
76+
scope: "Scope",
77+
receive: "Receive",
78+
send: "Send",
79+
next_app: "ASGIApp",
80+
) -> None:
81+
await OpenTelemetryMiddleware(
82+
app=next_app,
83+
default_span_details=build_litestar_route_details_from_scope,
84+
excluded_urls=self._excluded_urls,
85+
tracer_provider=self._tracer_provider,
86+
)(scope, receive, send) # ty: ignore
87+
88+
4289
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
4390
class LitestarConfig(
4491
CorsConfig,
@@ -111,10 +158,10 @@ def _build_excluded_urls(self) -> set[str]:
111158
def bootstrap(self) -> None:
112159
super().bootstrap()
113160
self.bootstrap_config.application_config.middleware.append(
114-
OpenTelemetryConfig(
161+
LitestarOpenTelemetryInstrumentationMiddleware(
115162
tracer_provider=get_tracer_provider(),
116-
exclude=list(self._build_excluded_urls()),
117-
).middleware,
163+
excluded_urls=self._build_excluded_urls(),
164+
)
118165
)
119166

120167

tests/test_litestar_bootstrap.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
import dataclasses
2+
3+
import litestar
14
import pytest
25
import structlog
36
from litestar import status_codes
7+
from litestar.config.app import AppConfig
48
from litestar.testing import TestClient
9+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
10+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
11+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
12+
from opentelemetry.trace import get_tracer_provider
513

614
from lite_bootstrap import LitestarBootstrapper, LitestarConfig
15+
from lite_bootstrap.bootstrappers.litestar_bootstrapper import build_litestar_route_details_from_scope, build_span_name
716
from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing
817

918

@@ -78,3 +87,44 @@ def test_litestar_bootstrapper_with_missing_instrument_dependency(
7887
) -> None:
7988
with emulate_package_missing(package_name), pytest.warns(UserWarning, match=package_name):
8089
LitestarBootstrapper(bootstrap_config=litestar_config)
90+
91+
92+
def test_litestar_otel_span_naming(litestar_config: LitestarConfig) -> None:
93+
@litestar.get("/items/{item_id:int}")
94+
async def get_item(item_id: int) -> dict[str, int]:
95+
return {"item_id": item_id}
96+
97+
config = dataclasses.replace(litestar_config, application_config=AppConfig(route_handlers=[get_item]))
98+
bootstrapper = LitestarBootstrapper(bootstrap_config=config)
99+
application = bootstrapper.bootstrap()
100+
101+
tracer_provider = get_tracer_provider()
102+
assert isinstance(tracer_provider, SDKTracerProvider)
103+
exporter = InMemorySpanExporter()
104+
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
105+
106+
with TestClient(app=application) as client:
107+
response = client.get("/items/42")
108+
assert response.status_code == status_codes.HTTP_200_OK
109+
110+
spans = exporter.get_finished_spans()
111+
span_names = [s.name for s in spans]
112+
assert any("GET /items/{item_id}" in name for name in span_names)
113+
114+
115+
def test_build_span_name_no_route() -> None:
116+
assert build_span_name("GET", "") == "GET"
117+
118+
119+
def test_build_litestar_route_details_from_scope_path_fallback() -> None:
120+
scope = {"method": "POST", "path": "/fallback/path"}
121+
name, attrs = build_litestar_route_details_from_scope(scope)
122+
assert name == "POST /fallback/path"
123+
assert attrs == {"http.route": "/fallback/path"}
124+
125+
126+
def test_build_litestar_route_details_from_scope_no_path() -> None:
127+
scope = {"type": "lifespan"}
128+
name, attrs = build_litestar_route_details_from_scope(scope)
129+
assert name == "HTTP"
130+
assert attrs == {}

0 commit comments

Comments
 (0)