Skip to content

Commit 14a4a63

Browse files
authored
feat!: trace Litestar through its own OpenTelemetryPlugin (#279)
LitestarOpenTelemetryInstrument registers litestar.plugins.opentelemetry's OpenTelemetryPlugin instead of hand-wrapping OpenTelemetryMiddleware in a cached ASGI middleware. A small route middleware renames the server span to the route template, which the plugin cannot see because it runs before routing. Breaking: the litestar-otl extra needs litestar>=2.22; opentelemetry_excluded_urls are searched in the request path rather than the full URL; unmatched paths now produce a method-only span; OTEL_PYTHON_LITESTAR_EXCLUDED_URLS is honored.
1 parent 5b4ee4f commit 14a4a63

5 files changed

Lines changed: 171 additions & 170 deletions

File tree

‎docs/introduction/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Additional parameters:
116116
- `opentelemetry_log_traces` - traces will be logged to stdout.
117117
- `opentelemetry_sampler` - an `opentelemetry.sdk.trace.sampling.Sampler` deciding which traces are recorded. Unset, the SDK's own default applies: `parentbased_always_on`, which records every trace that is not the child of a non-recording remote parent, unless `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` say otherwise.
118118
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`.
119-
- `opentelemetry_excluded_urls` - extra URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically.
119+
- `opentelemetry_excluded_urls` - extra regexes for URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically. Litestar searches them in the request path and also honors `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS`; FastAPI searches them in the full URL.
120120
- `opentelemetry_metrics_endpoint` - will be passed to `OTLPMetricExporter` as endpoint, and turns on the metrics signal. Its own field rather than a flag on `opentelemetry_endpoint`, so upgrading never starts exporting metrics you did not ask for. Under `opentelemetry_exporter_protocol="http"` this is a full URL (e.g. `http://collector:4318/v1/metrics`); `opentelemetry_exporter_protocol` and `opentelemetry_insecure` are shared with traces.
121121

122122
### Metrics

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 41 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import contextlib
21
import dataclasses
32
import pathlib
43
import re
54
import typing
6-
import weakref
75

86
from lite_bootstrap import import_checker
97
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
@@ -49,11 +47,10 @@
4947

5048
if import_checker.is_litestar_opentelemetry_installed:
5149
from litestar.middleware import ASGIMiddleware
50+
from litestar.plugins.opentelemetry import OpenTelemetryConfig as LitestarOpenTelemetryConfig
51+
from litestar.plugins.opentelemetry import OpenTelemetryPlugin
5252
from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send
53-
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
54-
from opentelemetry.metrics import MeterProvider
55-
from opentelemetry.trace import TracerProvider
56-
from opentelemetry.util.http import parse_excluded_urls
53+
from opentelemetry import trace
5754

5855
if import_checker.is_opentelemetry_installed:
5956
from opentelemetry.metrics import get_meter_provider
@@ -75,67 +72,41 @@ def build_span_name(method: str, route: str) -> str:
7572
_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params")
7673
_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",)
7774

78-
# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path.
79-
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
80-
8175
# Litestar.from_config() passes every AppConfig field explicitly, so the default that
8276
# Litestar.__init__ applies never reaches an app built from a config. Pinned to Litestar's
8377
# own default by a guard test. See https://github.com/litestar-org/litestar/issues/4296.
8478
_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000
8579

8680

8781
def build_litestar_route_details_from_scope(
88-
scope: typing.MutableMapping[str, typing.Any],
82+
scope: "Scope",
8983
) -> tuple[str, dict[str, str]]:
90-
path_template: typing.Final = scope.get("path_template")
9184
method: typing.Final = str(scope.get("method", "HTTP")).strip()
92-
if path_template is not None:
93-
path_template_stripped: typing.Final = path_template.strip()
94-
return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped}
95-
96-
path: typing.Final = scope.get("path")
97-
if path is not None:
98-
path_stripped: typing.Final = path.strip()
99-
return build_span_name(method, path_stripped), {"http.route": path_stripped}
100-
return method, {}
85+
path_template: typing.Final = scope.get("path_template")
86+
# Unmatched paths get no `http.route`: a raw path would let scanners explode span cardinality
87+
if path_template is None:
88+
return method, {}
89+
path_template_stripped: typing.Final = path_template.strip()
90+
return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped}
10191

10292

10393
if import_checker.is_litestar_opentelemetry_installed:
10494

105-
class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
106-
def __init__(
107-
self, tracer_provider: "TracerProvider", meter_provider: "MeterProvider", excluded_urls: set[str]
108-
) -> None:
109-
self._tracer_provider = tracer_provider
110-
self._meter_provider = meter_provider
111-
# OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0.
112-
self._excluded_urls = parse_excluded_urls(",".join(excluded_urls))
113-
# WeakKeyDictionary so wrapper apps are evicted when Litestar drops the
114-
# next_app reference (hot reload, plugin add/remove, AppConfig rebuild).
115-
# Apps that don't support weak references are simply not cached.
116-
self._otel_apps: weakref.WeakKeyDictionary[ASGIApp, ASGIApp] = weakref.WeakKeyDictionary()
117-
95+
class LitestarOpenTelemetryRouteMiddleware(ASGIMiddleware):
96+
# OpenTelemetryPlugin opens the server span before routing, so the route template is only known here
11897
async def handle(
11998
self,
12099
scope: "Scope",
121100
receive: "Receive",
122101
send: "Send",
123102
next_app: "ASGIApp",
124103
) -> None:
125-
otel_app: ASGIApp | None = None
126-
with contextlib.suppress(TypeError):
127-
otel_app = self._otel_apps.get(next_app)
128-
if otel_app is None:
129-
otel_app = OpenTelemetryMiddleware( # ty: ignore[invalid-assignment]
130-
app=next_app,
131-
default_span_details=build_litestar_route_details_from_scope,
132-
excluded_urls=self._excluded_urls,
133-
tracer_provider=self._tracer_provider,
134-
meter_provider=self._meter_provider,
135-
)
136-
with contextlib.suppress(TypeError):
137-
self._otel_apps[next_app] = otel_app # ty: ignore[invalid-assignment]
138-
await otel_app(scope, receive, send) # ty: ignore[call-non-callable]
104+
server_span: typing.Final = trace.get_current_span()
105+
if server_span.is_recording():
106+
span_name, attributes = build_litestar_route_details_from_scope(scope)
107+
server_span.update_name(span_name)
108+
server_span.set_attributes(attributes)
109+
await next_app(scope, receive, send)
139110

140111

141112
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@@ -240,32 +211,43 @@ def _configure_structlog_loggers(self) -> None:
240211
@dataclasses.dataclass(kw_only=True)
241212
class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument):
242213
bootstrap_config: LitestarConfig
214+
missing_dependency_message = "opentelemetry-instrumentation-asgi or litestar>=2.22 is not installed"
243215

244-
def _build_excluded_url_patterns(self) -> set[str]:
216+
@staticmethod
217+
def dependencies_installed() -> bool:
218+
return OpenTelemetryInstrument.dependencies_installed() and import_checker.is_litestar_opentelemetry_installed
219+
220+
def _build_excluded_path_patterns(self) -> list[str]:
245221
"""Anchored patterns for the derived paths, plus the caller's own entries verbatim.
246222
247-
Litestar normalizes the trailing slash out of ``scope["path"]``, so a derived path
248-
carrying one never matches. Stripping it alone is not enough: ``ExcludeList`` searches
249-
unanchored, so a bare ``/custom-health`` would also silence ``/custom-healthy``.
250-
Caller-supplied entries stay untouched because OpenTelemetry documents them as regexes.
223+
The trailing slash is stripped and matched optionally, so ``/custom-health/`` excludes the
224+
path with or without it. Anchoring is needed because Litestar searches its ``exclude``
225+
patterns unanchored: a bare ``/custom-health`` would also silence ``/custom-healthy``.
226+
Caller-supplied entries stay untouched because they are regexes.
251227
"""
252228
anchored_patterns: typing.Final = {
253-
rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)"
229+
rf"^{re.escape(normalized_path)}(?:/|$)"
254230
for excluded_path in self._build_infrastructure_excluded_paths()
255231
# A bare "/" would anchor to every URL, so it is dropped along with empty values.
256232
if (normalized_path := excluded_path.rstrip("/"))
257233
}
258-
return anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls)
234+
return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls))
259235

260236
def bootstrap(self) -> None:
261237
super().bootstrap()
262-
self.bootstrap_config.application_config.middleware.append(
263-
LitestarOpenTelemetryInstrumentationMiddleware(
264-
tracer_provider=get_tracer_provider(),
265-
meter_provider=get_meter_provider(),
266-
excluded_urls=self._build_excluded_url_patterns(),
238+
application_config: typing.Final = self.bootstrap_config.application_config
239+
application_config.plugins.append(
240+
OpenTelemetryPlugin(
241+
LitestarOpenTelemetryConfig(
242+
tracer_provider=get_tracer_provider(),
243+
meter_provider=get_meter_provider(),
244+
scope_span_details_extractor=build_litestar_route_details_from_scope,
245+
# An empty list compiles to a pattern that matches, and so skips, every path
246+
exclude=self._build_excluded_path_patterns() or None,
247+
)
267248
)
268249
)
250+
application_config.middleware.append(LitestarOpenTelemetryRouteMiddleware())
269251

270252

271253
@dataclasses.dataclass(kw_only=True)

‎lite_bootstrap/import_checker.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ def _safe_find_spec(module_name: str) -> bool:
3131
"opentelemetry.instrumentation.fastapi"
3232
)
3333
is_litestar_opentelemetry_installed = (
34-
is_opentelemetry_installed and is_litestar_installed and _safe_find_spec("opentelemetry.instrumentation.asgi")
34+
is_opentelemetry_installed
35+
and is_litestar_installed
36+
and _safe_find_spec("opentelemetry.instrumentation.asgi")
37+
and _safe_find_spec("litestar.plugins.opentelemetry")
3538
)
3639
is_otlp_grpc_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")
3740
is_otlp_http_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")

‎pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ fastapi-all = [
123123
]
124124
litestar = [
125125
# >=2.13 for AppConfig.request_max_body_size (LitestarBootstrapper._apply_config reads it);
126-
# >=2.15 for litestar.middleware.ASGIMiddleware (LitestarOpenTelemetryInstrumentationMiddleware
126+
# >=2.15 for litestar.middleware.ASGIMiddleware (LitestarOpenTelemetryRouteMiddleware
127127
# subclasses it); >=2.19 because litestar/concurrency.py imports sniffio and only 2.19 declares
128128
# it -- until then it arrived via anyio, which dropped the dependency in 4.14. The highest wins.
129129
"litestar>=2.19",
@@ -137,6 +137,8 @@ litestar-sentry = [
137137
]
138138
litestar-otl = [
139139
"lite-bootstrap[litestar,otl]",
140+
# >=2.22 for litestar.plugins.opentelemetry (LitestarOpenTelemetryInstrument registers its OpenTelemetryPlugin).
141+
"litestar>=2.22",
140142
# Raised from 0.46b0 with the rest of the train: every instrumentation package pins
141143
# `opentelemetry-instrumentation` to its own exact version, so they move together.
142144
"opentelemetry-instrumentation-asgi>=0.49b0",

0 commit comments

Comments
 (0)