Skip to content

Commit e4c844e

Browse files
authored
fix: anchor litestar trace-exclusion patterns to the normalized path (#250)
Litestar strips the trailing slash out of scope["path"], so an excluded entry carrying one never matched the URL OpenTelemetryMiddleware builds. The default health_checks_path is "/health/", so opentelemetry_generate_health_check_spans=False silently did nothing. Closes #248
1 parent 0e37785 commit e4c844e

3 files changed

Lines changed: 89 additions & 8 deletions

File tree

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ def build_span_name(method: str, route: str) -> str:
7373
_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params")
7474
_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",)
7575

76+
# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path.
77+
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
78+
7679
# Litestar.from_config() passes every AppConfig field explicitly, so the default that
7780
# Litestar.__init__ applies never reaches an app built from a config. Pinned to Litestar's
7881
# own default by a guard test. See https://github.com/litestar-org/litestar/issues/4296.
@@ -232,12 +235,28 @@ def _configure_structlog_loggers(self) -> None:
232235
class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument):
233236
bootstrap_config: LitestarConfig
234237

238+
def _build_excluded_url_patterns(self) -> set[str]:
239+
"""Anchored patterns for the derived paths, plus the caller's own entries verbatim.
240+
241+
Litestar normalizes the trailing slash out of ``scope["path"]``, so a derived path
242+
carrying one never matches. Stripping it alone is not enough: ``ExcludeList`` searches
243+
unanchored, so a bare ``/custom-health`` would also silence ``/custom-healthy``.
244+
Caller-supplied entries stay untouched because OpenTelemetry documents them as regexes.
245+
"""
246+
anchored_patterns: typing.Final = {
247+
rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)"
248+
for excluded_path in self._build_infrastructure_excluded_paths()
249+
# A bare "/" would anchor to every URL, so it is dropped along with empty values.
250+
if (normalized_path := excluded_path.rstrip("/"))
251+
}
252+
return anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls)
253+
235254
def bootstrap(self) -> None:
236255
super().bootstrap()
237256
self.bootstrap_config.application_config.middleware.append(
238257
LitestarOpenTelemetryInstrumentationMiddleware(
239258
tracer_provider=get_tracer_provider(),
240-
excluded_urls=self._build_excluded_urls(),
259+
excluded_urls=self._build_excluded_url_patterns(),
241260
)
242261
)
243262

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,21 @@ def dependencies_installed() -> bool:
163163
# the sdk (opentelemetry.sdk.*), so it needs both distributions present.
164164
return import_checker.is_opentelemetry_installed and import_checker.is_opentelemetry_sdk_installed
165165

166-
def _build_excluded_urls(self) -> set[str]:
166+
def _build_infrastructure_excluded_paths(self) -> set[str]:
167+
"""Paths this policy derives itself, without the caller-supplied entries."""
167168
config = self.bootstrap_config
168-
excluded_urls: set[str] = set(config.opentelemetry_excluded_urls)
169+
excluded_paths: set[str] = set()
169170
prometheus_path = getattr(config, "prometheus_metrics_path", None)
170171
if prometheus_path:
171-
excluded_urls.add(prometheus_path)
172+
excluded_paths.add(prometheus_path)
172173
if not config.opentelemetry_generate_health_check_spans:
173174
health_path = getattr(config, "health_checks_path", None)
174175
if health_path:
175-
excluded_urls.add(health_path)
176-
return excluded_urls
176+
excluded_paths.add(health_path)
177+
return excluded_paths
178+
179+
def _build_excluded_urls(self) -> set[str]:
180+
return set(self.bootstrap_config.opentelemetry_excluded_urls) | self._build_infrastructure_excluded_paths()
177181

178182
def _silence_otel_loggers(self) -> None:
179183
for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"):

‎tests/test_litestar_bootstrap.py‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,65 @@ def test_litestar_otel_middleware_hands_the_instrumentor_a_parsed_exclude_list()
549549

550550
excluded_urls = middleware._excluded_urls # noqa: SLF001
551551
assert not isinstance(excluded_urls, str)
552-
# Matched against the URL the middleware builds from `scope["path"]`, which Litestar has
553-
# already normalized; see #248 for the trailing-slash entries that therefore never match.
554552
assert excluded_urls.url_disabled("http://test/custom-metrics")
555553
assert not excluded_urls.url_disabled("http://test/items/1")
554+
555+
556+
def test_litestar_otel_excludes_infrastructure_paths_normalized_by_litestar(
557+
litestar_config: LitestarConfig,
558+
) -> None:
559+
"""REGRESSION #248: Litestar strips the trailing slash before the middleware sees the path.
560+
561+
`health_checks_path="/custom-health/"` reaches `OpenTelemetryMiddleware` as
562+
`http://host/custom-health`, so an exclude entry carrying the slash never matched and the
563+
health check was traced anyway. The lookalike route guards the obvious over-correction:
564+
`ExcludeList` regex-searches unanchored, so a bare `/custom-health` prefix would also
565+
silence `/custom-healthy`.
566+
"""
567+
568+
@litestar.get("/custom-healthy")
569+
async def lookalike_handler() -> dict[str, str]:
570+
return {"status": "ok"}
571+
572+
config = dataclasses.replace(litestar_config, application_config=AppConfig(route_handlers=[lookalike_handler]))
573+
application = LitestarBootstrapper(bootstrap_config=config).bootstrap()
574+
575+
tracer_provider = get_tracer_provider()
576+
assert isinstance(tracer_provider, SDKTracerProvider)
577+
exporter = InMemorySpanExporter()
578+
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
579+
580+
with TestClient(app=application) as client:
581+
assert client.get(config.health_checks_path).status_code == status_codes.HTTP_200_OK
582+
assert client.get(config.prometheus_metrics_path).status_code == status_codes.HTTP_200_OK
583+
assert client.get("/custom-healthy").status_code == status_codes.HTTP_200_OK
584+
585+
span_names = [span.name for span in exporter.get_finished_spans()]
586+
assert "GET /custom-health" not in span_names
587+
assert "GET /custom-metrics" not in span_names
588+
assert "GET /custom-healthy" in span_names
589+
590+
591+
def test_litestar_otel_keeps_caller_supplied_excluded_urls_as_regexes(litestar_config: LitestarConfig) -> None:
592+
"""`opentelemetry_excluded_urls` entries are OpenTelemetry regexes, so anchoring must not touch them."""
593+
594+
@litestar.get("/items/{item_id:int}")
595+
async def get_item(item_id: int) -> dict[str, int]:
596+
return {"item_id": item_id}
597+
598+
config = dataclasses.replace(
599+
litestar_config,
600+
application_config=AppConfig(route_handlers=[get_item]),
601+
opentelemetry_excluded_urls=[r"/items/\d+$"],
602+
)
603+
application = LitestarBootstrapper(bootstrap_config=config).bootstrap()
604+
605+
tracer_provider = get_tracer_provider()
606+
assert isinstance(tracer_provider, SDKTracerProvider)
607+
exporter = InMemorySpanExporter()
608+
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
609+
610+
with TestClient(app=application) as client:
611+
assert client.get("/items/42").status_code == status_codes.HTTP_200_OK
612+
613+
assert exporter.get_finished_spans() == ()

0 commit comments

Comments
 (0)