Skip to content

Commit 609c420

Browse files
xgemxegraif
andauthored
switch opentelemetry span names for litestar from `/some/resource/123… (#130)
* switch opentelemetry span names for litestar from `/some/resource/123` to `/some/resource/{resource_id}` * fix mypy * add tests * small fix of behavior --------- Co-authored-by: GADEEV Evgeny <Evgeny.GADEEV@raiffeisen.ru>
1 parent e25705b commit 609c420

3 files changed

Lines changed: 260 additions & 7 deletions

File tree

microbootstrap/bootstrappers/litestar.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,44 @@
77
import typing_extensions
88
from litestar import openapi
99
from litestar.config.cors import CORSConfig as LitestarCorsConfig
10-
from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
10+
from litestar.contrib.opentelemetry.config import (
11+
OpenTelemetryConfig as LitestarOpentelemetryConfig,
12+
)
13+
from litestar.contrib.opentelemetry.middleware import (
14+
OpenTelemetryInstrumentationMiddleware,
15+
)
1116
from litestar.contrib.prometheus import PrometheusConfig, PrometheusController
1217
from litestar.openapi.plugins import SwaggerRenderPlugin
1318
from litestar_offline_docs import generate_static_files_config
19+
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
20+
from opentelemetry.util.http import get_excluded_urls
1421
from sentry_sdk.integrations.litestar import LitestarIntegration
1522

1623
from microbootstrap.bootstrappers.base import ApplicationBootstrapper
1724
from microbootstrap.config.litestar import LitestarConfig
1825
from microbootstrap.instruments.cors_instrument import CorsInstrument
19-
from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict
26+
from microbootstrap.instruments.health_checks_instrument import (
27+
HealthChecksInstrument,
28+
HealthCheckTypedDict,
29+
)
2030
from microbootstrap.instruments.logging_instrument import LoggingInstrument
2131
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
22-
from microbootstrap.instruments.prometheus_instrument import LitestarPrometheusConfig, PrometheusInstrument
32+
from microbootstrap.instruments.prometheus_instrument import (
33+
LitestarPrometheusConfig,
34+
PrometheusInstrument,
35+
)
2336
from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
2437
from microbootstrap.instruments.sentry_instrument import SentryInstrument
2538
from microbootstrap.instruments.swagger_instrument import SwaggerInstrument
2639
from microbootstrap.middlewares.litestar import build_litestar_logging_middleware
2740
from microbootstrap.settings import LitestarSettings
2841

2942

43+
if typing.TYPE_CHECKING:
44+
from litestar.contrib.opentelemetry import OpenTelemetryConfig
45+
from litestar.types import ASGIApp, Scope
46+
47+
3048
class LitestarBootstrapper(
3149
ApplicationBootstrapper[LitestarSettings, litestar.Litestar, LitestarConfig],
3250
):
@@ -106,16 +124,66 @@ def bootstrap_before(self) -> dict[str, typing.Any]:
106124
LitestarBootstrapper.use_instrument()(PyroscopeInstrument)
107125

108126

127+
def build_span_name(method: str, route: str) -> str:
128+
if not route:
129+
return method
130+
return f"{method} {route}"
131+
132+
133+
def build_litestar_route_details_from_scope(
134+
scope: Scope,
135+
) -> tuple[str, dict[str, str]]:
136+
"""Retrieve the span name and attributes from the ASGI scope for Litestar routes.
137+
138+
Args:
139+
scope: The ASGI scope instance.
140+
141+
Returns:
142+
A tuple of the span name and a dict of attrs.
143+
144+
"""
145+
path_template: typing.Final = scope.get("path_template")
146+
method: typing.Final = str(scope.get("method", "HTTP")).strip()
147+
if path_template is not None:
148+
path_template_stripped: typing.Final = path_template.strip()
149+
return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped}
150+
151+
path: typing.Final = scope.get("path")
152+
if path is not None:
153+
path_stripped: typing.Final = path.strip()
154+
return build_span_name(method, path_stripped), {"http.route": path_stripped}
155+
return method, {}
156+
157+
158+
class LitestarOpenTelemetryInstrumentationMiddleware(OpenTelemetryInstrumentationMiddleware):
159+
def __init__(self, app: ASGIApp, config: OpenTelemetryConfig) -> None:
160+
super().__init__(
161+
app=app,
162+
config=config,
163+
)
164+
self.open_telemetry_middleware = OpenTelemetryMiddleware(
165+
app=app,
166+
client_request_hook=config.client_request_hook_handler, # type: ignore[arg-type]
167+
client_response_hook=config.client_response_hook_handler, # type: ignore[arg-type]
168+
default_span_details=build_litestar_route_details_from_scope,
169+
excluded_urls=get_excluded_urls(config.exclude_urls_env_key),
170+
meter=config.meter,
171+
meter_provider=config.meter_provider,
172+
server_request_hook=config.server_request_hook_handler,
173+
tracer_provider=config.tracer_provider,
174+
)
175+
176+
109177
@LitestarBootstrapper.use_instrument()
110178
class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
111179
def bootstrap_before(self) -> dict[str, typing.Any]:
112180
return {
113181
"middleware": [
114182
LitestarOpentelemetryConfig(
115183
tracer_provider=self.tracer_provider,
116-
exclude=self.define_exclude_urls(),
184+
middleware_class=LitestarOpenTelemetryInstrumentationMiddleware,
117185
).middleware,
118-
],
186+
]
119187
}
120188

121189

@@ -141,7 +209,10 @@ class LitestarPrometheusController(PrometheusController):
141209
**self.instrument_config.prometheus_additional_params,
142210
)
143211

144-
return {"route_handlers": [LitestarPrometheusController], "middleware": [litestar_prometheus_config.middleware]}
212+
return {
213+
"route_handlers": [LitestarPrometheusController],
214+
"middleware": [litestar_prometheus_config.middleware],
215+
}
145216

146217
@classmethod
147218
def get_config_type(cls) -> type[LitestarPrometheusConfig]:
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import typing
2+
from unittest.mock import Mock, patch
3+
4+
import litestar
5+
import pytest
6+
from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
7+
from litestar.status_codes import HTTP_200_OK
8+
from litestar.testing import TestClient
9+
10+
from microbootstrap import LitestarSettings
11+
from microbootstrap.bootstrappers.litestar import (
12+
LitestarBootstrapper,
13+
LitestarOpentelemetryInstrument,
14+
LitestarOpenTelemetryInstrumentationMiddleware,
15+
build_litestar_route_details_from_scope,
16+
)
17+
from microbootstrap.config.litestar import LitestarConfig
18+
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
19+
20+
21+
@pytest.mark.parametrize(
22+
("scope", "expected_span_name", "expected_attributes"),
23+
[
24+
(
25+
{
26+
"path": "/users/123",
27+
"path_template": "/users/{user_id}",
28+
"method": "GET",
29+
},
30+
"GET /users/{user_id}",
31+
{"http.route": "/users/{user_id}"},
32+
),
33+
(
34+
{
35+
"path": "/users/123",
36+
"method": "POST",
37+
},
38+
"POST /users/123",
39+
{"http.route": "/users/123"},
40+
),
41+
(
42+
{
43+
"path": "/test",
44+
},
45+
"HTTP /test",
46+
{"http.route": "/test"},
47+
),
48+
(
49+
{
50+
"path": "",
51+
},
52+
"HTTP",
53+
{"http.route": ""},
54+
),
55+
(
56+
{
57+
"path": " ",
58+
},
59+
"HTTP",
60+
{"http.route": ""},
61+
),
62+
(
63+
{
64+
"path_template": "",
65+
},
66+
"HTTP",
67+
{"http.route": ""},
68+
),
69+
(
70+
{
71+
"path_template": " ",
72+
},
73+
"HTTP",
74+
{"http.route": ""},
75+
),
76+
(
77+
{},
78+
"HTTP",
79+
{},
80+
),
81+
(
82+
{"method": "GET"},
83+
"GET",
84+
{},
85+
),
86+
(
87+
{
88+
"path": "/users/123",
89+
"path_template": "/users/{user_id}",
90+
},
91+
"HTTP /users/{user_id}",
92+
{"http.route": "/users/{user_id}"},
93+
),
94+
],
95+
)
96+
def test_build_litestar_route_details_from_scope(
97+
scope: dict[str, str],
98+
expected_span_name: str,
99+
expected_attributes: dict[str, str],
100+
) -> None:
101+
span_name, attributes = build_litestar_route_details_from_scope(scope) # type: ignore[arg-type]
102+
103+
assert span_name == expected_span_name
104+
assert attributes == expected_attributes
105+
106+
107+
def test_litestar_opentelemetry_instrument_uses_custom_middleware(
108+
minimal_opentelemetry_config: OpentelemetryConfig,
109+
) -> None:
110+
opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
111+
opentelemetry_instrument.bootstrap()
112+
113+
bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before()
114+
115+
assert "middleware" in bootstrap_result
116+
assert len(bootstrap_result["middleware"]) == 1
117+
118+
middleware_config: typing.Final = bootstrap_result["middleware"][0]
119+
assert middleware_config.middleware == LitestarOpenTelemetryInstrumentationMiddleware
120+
121+
122+
@pytest.mark.parametrize(
123+
("path", "expected_span_name"),
124+
[
125+
("/users/123", "GET /users/{user_id}"),
126+
("/users/", "GET /users/"),
127+
("/", "GET /"),
128+
],
129+
)
130+
def test_litestar_opentelemetry_integration_with_path_templates(
131+
path: str,
132+
expected_span_name: str,
133+
minimal_opentelemetry_config: OpentelemetryConfig,
134+
) -> None:
135+
@litestar.get("/users/{user_id:int}")
136+
async def get_user(user_id: int) -> dict[str, int]:
137+
return {"user_id": user_id}
138+
139+
@litestar.get("/users/")
140+
async def list_users() -> dict[str, str]:
141+
return {"message": "list of users"}
142+
143+
@litestar.get("/")
144+
async def root() -> dict[str, str]:
145+
return {"message": "root"}
146+
147+
with patch("microbootstrap.bootstrappers.litestar.build_litestar_route_details_from_scope") as mock_function:
148+
mock_function.return_value = (expected_span_name, {"http.route": path})
149+
150+
application: typing.Final = (
151+
LitestarBootstrapper(LitestarSettings())
152+
.configure_instrument(minimal_opentelemetry_config)
153+
.configure_application(LitestarConfig(route_handlers=[get_user, list_users, root]))
154+
.bootstrap()
155+
)
156+
157+
with TestClient(app=application) as client:
158+
response: typing.Final = client.get(path)
159+
assert response.status_code == HTTP_200_OK
160+
assert mock_function.called
161+
162+
163+
def test_litestar_opentelemetry_middleware_initialization() -> None:
164+
mock_app: typing.Final = Mock()
165+
166+
mock_config: typing.Final = Mock(spec=LitestarOpentelemetryConfig)
167+
mock_config.scopes = ["http"]
168+
mock_config.exclude = []
169+
mock_config.exclude_opt_key = None
170+
mock_config.client_request_hook_handler = None
171+
mock_config.client_response_hook_handler = None
172+
mock_config.exclude_urls_env_key = None
173+
mock_config.meter = None
174+
mock_config.meter_provider = None
175+
mock_config.server_request_hook_handler = None
176+
mock_config.tracer_provider = None
177+
178+
middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware(app=mock_app, config=mock_config)
179+
180+
assert middleware.app == mock_app
181+
assert hasattr(middleware, "open_telemetry_middleware")
182+
assert middleware.open_telemetry_middleware is not None

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def minimal_health_checks_config() -> HealthChecksConfig:
9999
@pytest.fixture
100100
def minimal_opentelemetry_config() -> OpentelemetryConfig:
101101
return OpentelemetryConfig(
102-
opentelemetry_endpoint="/my-engdpoint",
102+
opentelemetry_endpoint="/my-endpoint",
103103
opentelemetry_namespace="namespace",
104104
opentelemetry_container_name="container-name",
105105
opentelemetry_generate_health_check_spans=False,

0 commit comments

Comments
 (0)