From 830b7ee6663deff33a11e2c6ee43ca0bb2400060 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 2 Nov 2026 14:34:35 +0300 Subject: [PATCH 1/4] Roll back started instruments when bootstrap fails (#382) --- CONTEXT.md | 3 +- README.md | 1 + lite_bootstrap/bootstrappers/base.py | 20 +++++-- tests/test_free_bootstrap.py | 79 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5f22f6e..02cae3a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,7 +15,8 @@ The user-facing entry point, one class per framework. It owns exactly one applic of the process; it is not a builder you can run twice. A second bootstrapper on the same application (or, for Litestar, the same `AppConfig`) warns at construction and raises `ConfigurationError` from `bootstrap()`. Once torn down, a bootstrapper cannot bootstrap again: `bootstrap()` after -`teardown()` raises `ConfigurationError`. +`teardown()` raises `ConfigurationError`. A failed bootstrapper cannot be reused either; it +cleans up instruments that started successfully, but cannot guarantee an unchanged application. **Instrument**: One observability or middleware concern: logging, tracing, metrics, profiling, CORS, Swagger, or diff --git a/README.md b/README.md index 23bcd26..4a692bb 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ A few constraints that aren't obvious from the API: - **One bootstrapper per application instance.** Constructing two `FastAPIBootstrapper`s around the same `fastapi.FastAPI` (or two `FastMcpBootstrapper`s around the same `FastMCP`) is not supported. The second bootstrapper warns at construction, and its `bootstrap()` raises `ConfigurationError`. - **One `OpenTelemetryInstrument` per process.** `bootstrap()` calls `opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as set-once: later calls log a warning and have no effect. `teardown()` flushes spans and closes exporters but can't reset the process-global pointer. - **`teardown()` is idempotent.** `BaseBootstrapper.teardown()` short-circuits if not bootstrapped; per-instrument teardown methods are safe to call multiple times. +- **Failed bootstrap is terminal.** If an instrument or application preparation raises, successfully started instruments are torn down in reverse order. Cleanup failures are attached as notes to the original exception. The bootstrapper cannot be reused, and a later `teardown()` does nothing. Rollback does not undo arbitrary changes made by the instrument that raised. - **Partial teardown failures are aggregated.** If an instrument's teardown raises, the bootstrapper continues with the rest of the instruments and raises `TeardownError` at the end with all collected failures. --- diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 72b1b3b..2608cb0 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -7,6 +7,7 @@ BootstrapperNotReadyError, ConfigurationError, InstrumentDependencyMissingWarning, + TeardownErrorCollector, collect_teardown_errors, ) from lite_bootstrap.instruments.base import BaseInstrument, ConfigT @@ -141,10 +142,23 @@ def bootstrap(self) -> ApplicationT: raise ConfigurationError(msg) if self.is_bootstrapped: return self._application + started_instruments: list[BaseInstrument] = [] + try: + for one_instrument in self.instruments: + one_instrument.bootstrap() + started_instruments.append(one_instrument) + application = self._prepare_application() + except Exception as error: + self._torn_down = True + teardown_errors = TeardownErrorCollector(logger) + for one_instrument in reversed(started_instruments): + with teardown_errors.capture(type(one_instrument).__name__): + one_instrument.teardown() + for name, teardown_error in teardown_errors.errors: + error.add_note(f"Error rolling back {name}: {teardown_error}") + raise + self._application = application self.is_bootstrapped = True - for one_instrument in self.instruments: - one_instrument.bootstrap() - self._application = self._prepare_application() return self._application def teardown(self) -> None: diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 94c1980..1adf2c1 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -1,5 +1,6 @@ import logging import types +import typing import warnings from unittest.mock import MagicMock @@ -15,6 +16,7 @@ TeardownError, ) from lite_bootstrap.bootstrappers.base import BaseBootstrapper +from lite_bootstrap.instruments.base import BaseInstrument from lite_bootstrap.instruments.logging_instrument import LoggingInstrument from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -348,3 +350,80 @@ def test_two_free_bootstrappers_both_bootstrap(free_bootstrapper_config: FreeCon finally: second.teardown() first.teardown() + + +def test_failed_bootstrap_rolls_back_only_started_instruments() -> None: + events: list[str] = [] + failure = RuntimeError("bootstrap failed") + + class First(BaseInstrument): + def bootstrap(self) -> None: + events.append(f"start {type(self).__name__.lower()}") + + def teardown(self) -> None: + events.append(f"stop {type(self).__name__.lower()}") + + class Second(First): ... + + class Boom(First): + def bootstrap(self) -> None: + raise failure + + class After(First): ... + + class Bootstrapper(FreeBootstrapper): + instruments_types: typing.ClassVar = [First, Second, Boom, After] + + bootstrapper = Bootstrapper(FreeConfig()) + with pytest.raises(RuntimeError) as excinfo: + bootstrapper.bootstrap() + + assert excinfo.value is failure + assert events == ["start first", "start second", "stop second", "stop first"] + assert not bootstrapper.is_bootstrapped + bootstrapper.teardown() + assert events == ["start first", "start second", "stop second", "stop first"] + with pytest.raises(ConfigurationError, match="cannot bootstrap again"): + bootstrapper.bootstrap() + + +@pytest.mark.parametrize("fail_preparation", [False, True]) +def test_failed_bootstrap_preserves_error_when_rollback_fails(*, fail_preparation: bool) -> None: + stopped: list[str] = [] + failure = ValueError("original startup failure") + + class First(BaseInstrument): + def teardown(self) -> None: + stopped.append("first") + msg = "first cleanup failed" + raise RuntimeError(msg) + + class Second(BaseInstrument): + def teardown(self) -> None: + stopped.append("second") + msg = "second cleanup failed" + raise RuntimeError(msg) + + class Boom(BaseInstrument): + def bootstrap(self) -> None: + raise failure + + class Bootstrapper(FreeBootstrapper): + instruments_types = [First, Second] if fail_preparation else [First, Second, Boom] + + def _prepare_application(self) -> None: + raise failure + + bootstrapper = Bootstrapper(FreeConfig()) + with pytest.raises(ValueError, match="original startup failure") as excinfo: + bootstrapper.bootstrap() + + assert excinfo.value is failure + assert stopped == ["second", "first"] + assert any("Second" in note and "second cleanup failed" in note for note in failure.__notes__) + assert any("First" in note and "first cleanup failed" in note for note in failure.__notes__) + assert not bootstrapper.is_bootstrapped + bootstrapper.teardown() + assert stopped == ["second", "first"] + with pytest.raises(ConfigurationError, match="cannot bootstrap again"): + bootstrapper.bootstrap() From 20fc5cddb0311a3069cd704ea62c6a3af466eef1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 2 Nov 2026 14:40:44 +0300 Subject: [PATCH 2/4] Specialize SwaggerInstrument config in typing test --- tests/instruments/test_swagger_instrument.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/instruments/test_swagger_instrument.py b/tests/instruments/test_swagger_instrument.py index f4b34fe..bc40e5a 100644 --- a/tests/instruments/test_swagger_instrument.py +++ b/tests/instruments/test_swagger_instrument.py @@ -3,7 +3,7 @@ def test_swagger_instrument_configured_by_default() -> None: config = SwaggerConfig() - assert SwaggerInstrument.is_configured(config) + assert SwaggerInstrument[SwaggerConfig].is_configured(config) def test_swagger_config_defaults() -> None: From c99179622799c6b540cdff5b584e6548376c0678 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 2 Nov 2026 14:40:44 +0300 Subject: [PATCH 3/4] Resolve FastMCP Sentry transaction names from route templates --- .../bootstrappers/fastmcp_bootstrapper.py | 2 +- lite_bootstrap/helpers/starlette_sentry.py | 34 +++++++-- tests/test_starlette_sentry_capture.py | 70 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index a67a421..00350af 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -351,7 +351,7 @@ def _build_init_params(self) -> dict[str, typing.Any]: @staticmethod def _capture_errors_of_http_app(http_application: "StarletteWithLifespan") -> "StarletteWithLifespan": - http_application.add_middleware(StarletteSentryCaptureMiddleware) + http_application.add_middleware(StarletteSentryCaptureMiddleware, routes=http_application.routes) return http_application def bootstrap(self) -> None: diff --git a/lite_bootstrap/helpers/starlette_sentry.py b/lite_bootstrap/helpers/starlette_sentry.py index 5cd16f7..7037c97 100644 --- a/lite_bootstrap/helpers/starlette_sentry.py +++ b/lite_bootstrap/helpers/starlette_sentry.py @@ -15,6 +15,7 @@ if typing.TYPE_CHECKING: from sentry_sdk import _types as sentry_types + from starlette.routing import BaseRoute from starlette.types import ASGIApp, Message, Receive, Scope, Send if import_checker.is_starlette_installed and import_checker.is_sentry_installed: @@ -28,6 +29,7 @@ from starlette.datastructures import Headers from starlette.middleware.exceptions import ExceptionMiddleware from starlette.requests import HTTPConnection + from starlette.routing import Match, Mount, Route, WebSocketRoute _CAPTURED_SCOPE_TYPES: typing.Final = frozenset({"http", "websocket"}) @@ -127,11 +129,25 @@ def _report_handled_server_errors(application: "ASGIApp") -> None: handlers[exception_class] = _reporting_server_errors(handler) +def _find_route_path(scope: "Scope", routes: "typing.Sequence[BaseRoute]") -> str | None: + for route in routes: + match, child_scope = route.matches(scope) + if match != Match.FULL: + continue + if isinstance(route, Mount): + child_path = _find_route_path({**scope, **child_scope}, route.routes) + return None if child_path is None else route.path + child_path + if isinstance(route, (Route, WebSocketRoute)): + return route.path + return None + + class StarletteSentryCaptureMiddleware: """Report request errors to Sentry without ``StarletteIntegration``, pure ASGI.""" - def __init__(self, app: "ASGIApp") -> None: + def __init__(self, app: "ASGIApp", routes: "typing.Sequence[BaseRoute]" = ()) -> None: self.app = app + self.routes = routes _report_handled_server_errors(app) async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None: @@ -139,6 +155,7 @@ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> No await self.app(scope, receive, send) return + routing_scope = dict(scope) received_body: _ReceivedBody | None = None if "incoming_request" in sentry_sdk.get_client().options["data_collection"]["http_bodies"]: received_body = _ReceivedBody() @@ -150,9 +167,18 @@ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> No ): sentry_scope.clear_breadcrumbs() sentry_scope.generate_propagation_context(read_sentry_trace_headers(scope["headers"])) - sentry_scope.add_event_processor( - lambda event, _: enrich_sentry_event_from_starlette_scope(event, scope, received_body) - ) + + def enrich(event: "sentry_types.Event", _: "sentry_types.Hint") -> "sentry_types.Event": + event = enrich_sentry_event_from_starlette_scope(event, scope, received_body) + if ( + "transaction" not in event + and (route_path := _find_route_path(routing_scope, self.routes)) is not None + ): + event["transaction"] = route_path + event["transaction_info"] = {"source": "route"} + return event + + sentry_scope.add_event_processor(enrich) try: await self.app(scope, receive, send) except Exception as exception: diff --git a/tests/test_starlette_sentry_capture.py b/tests/test_starlette_sentry_capture.py index e3280f6..d6e013b 100644 --- a/tests/test_starlette_sentry_capture.py +++ b/tests/test_starlette_sentry_capture.py @@ -19,10 +19,12 @@ from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from starlette import status +from starlette.applications import Starlette from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import HTTPConnection, Request from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route from starlette.testclient import TestClient from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocket @@ -741,3 +743,71 @@ def respond(_: Request, __: Exception) -> Response: assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert handler_threads != [threading.current_thread()] assert _exception_types(sentry_mock) == ["CarriesUnavailableStatusError"] + + +@pytest.mark.parametrize("mounted", [False, True]) +def test_fastmcp_lean_capture_uses_route_templates(sentry_mock: SentryTestTransport, *, mounted: bool) -> None: + config = _build_fastmcp_config(sentry_mock) + bootstrapper = FastMcpBootstrapper(config) + application = bootstrapper.bootstrap() + + async def fail(request: Request) -> Response: + raise RuntimeError(request.path_params["item_id"]) + + http_app = application.http_app() + if mounted: + http_app.router.routes.append( + Mount("/shops/{shop_id}", app=Starlette(routes=[Route("/items/{item_id}", fail)])) + ) + path = "/shops/12/items/7" + expected_transaction = "/shops/{shop_id}/items/{item_id}" + else: + http_app.router.routes.append(Route("/items/{item_id}", fail)) + path = "/items/7" + expected_transaction = "/items/{item_id}" + + with TestClient(http_app, raise_server_exceptions=False) as client: + response = client.get(path) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + [event] = _error_events(sentry_mock) + assert event["transaction"] == expected_transaction + assert event["transaction_info"] == {"source": "route"} + + +def test_fastmcp_lean_capture_preserves_existing_transaction(sentry_mock: SentryTestTransport) -> None: + config = _build_fastmcp_config(sentry_mock) + + @config.application.custom_route("/named", methods=["GET"]) + async def named(_: Request) -> Response: + sentry_sdk.get_isolation_scope().set_transaction_name("user transaction", source="custom") + message = "boom" + raise RuntimeError(message) + + bootstrapper = FastMcpBootstrapper(config) + application = bootstrapper.bootstrap() + with TestClient(application.http_app(), raise_server_exceptions=False) as client: + client.get("/named") + + [event] = _error_events(sentry_mock) + assert event["transaction"] == "user transaction" + assert event["transaction_info"] == {"source": "custom"} + + +def test_fastmcp_lean_capture_does_not_name_opaque_mounts_from_raw_paths(sentry_mock: SentryTestTransport) -> None: + config = _build_fastmcp_config(sentry_mock) + bootstrapper = FastMcpBootstrapper(config) + application = bootstrapper.bootstrap() + + async def fail(_scope: Scope, _receive: Receive, _send: Send) -> None: + message = "boom" + raise RuntimeError(message) + + http_app = application.http_app() + http_app.router.routes.append(Mount("/opaque", app=fail)) + with TestClient(http_app, raise_server_exceptions=False) as client: + response = client.get("/opaque/private-id") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + [event] = _error_events(sentry_mock) + assert "transaction" not in event From ed97a02fe432754ca332f4cfb38691354970f6a9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 2 Nov 2026 14:58:00 +0300 Subject: [PATCH 4/4] Preserve route selection and roll back interrupted startup --- lite_bootstrap/bootstrappers/base.py | 2 +- lite_bootstrap/helpers/starlette_sentry.py | 25 +++++---- tests/test_free_bootstrap.py | 16 +++--- tests/test_starlette_sentry_capture.py | 61 +++++++++++++++++++--- 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 2608cb0..d15e3a5 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -148,7 +148,7 @@ def bootstrap(self) -> ApplicationT: one_instrument.bootstrap() started_instruments.append(one_instrument) application = self._prepare_application() - except Exception as error: + except BaseException as error: self._torn_down = True teardown_errors = TeardownErrorCollector(logger) for one_instrument in reversed(started_instruments): diff --git a/lite_bootstrap/helpers/starlette_sentry.py b/lite_bootstrap/helpers/starlette_sentry.py index 7037c97..a82c73f 100644 --- a/lite_bootstrap/helpers/starlette_sentry.py +++ b/lite_bootstrap/helpers/starlette_sentry.py @@ -29,7 +29,7 @@ from starlette.datastructures import Headers from starlette.middleware.exceptions import ExceptionMiddleware from starlette.requests import HTTPConnection - from starlette.routing import Match, Mount, Route, WebSocketRoute + from starlette.routing import Host, Match, Mount, Route, WebSocketRoute _CAPTURED_SCOPE_TYPES: typing.Final = frozenset({"http", "websocket"}) @@ -86,9 +86,6 @@ def enrich_sentry_event_from_starlette_scope( event["request"] = {**event.get("request", {}), **request_data} if data_collection["user_info"] and (user := _read_user(scope)): event.setdefault("user", user) - if "transaction" not in event and (route_path := getattr(scope.get("route"), "path", None)) is not None: - event["transaction"] = route_path - event["transaction_info"] = {"source": "route"} return event @@ -134,18 +131,21 @@ def _find_route_path(scope: "Scope", routes: "typing.Sequence[BaseRoute]") -> st match, child_scope = route.matches(scope) if match != Match.FULL: continue + if isinstance(route, Host): + return _find_route_path({**scope, **child_scope}, route.routes) if isinstance(route, Mount): child_path = _find_route_path({**scope, **child_scope}, route.routes) return None if child_path is None else route.path + child_path if isinstance(route, (Route, WebSocketRoute)): return route.path + return None return None class StarletteSentryCaptureMiddleware: """Report request errors to Sentry without ``StarletteIntegration``, pure ASGI.""" - def __init__(self, app: "ASGIApp", routes: "typing.Sequence[BaseRoute]" = ()) -> None: + def __init__(self, app: "ASGIApp", routes: "typing.Sequence[BaseRoute] | None" = None) -> None: self.app = app self.routes = routes _report_handled_server_errors(app) @@ -170,12 +170,15 @@ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> No def enrich(event: "sentry_types.Event", _: "sentry_types.Hint") -> "sentry_types.Event": event = enrich_sentry_event_from_starlette_scope(event, scope, received_body) - if ( - "transaction" not in event - and (route_path := _find_route_path(routing_scope, self.routes)) is not None - ): - event["transaction"] = route_path - event["transaction_info"] = {"source": "route"} + if "transaction" not in event: + route_path = ( + _find_route_path(routing_scope, self.routes) + if self.routes is not None + else getattr(scope.get("route"), "path", None) + ) + if route_path is not None: + event["transaction"] = route_path + event["transaction_info"] = {"source": "route"} return event sentry_scope.add_event_processor(enrich) diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 1adf2c1..7f11f13 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -352,9 +352,10 @@ def test_two_free_bootstrappers_both_bootstrap(free_bootstrapper_config: FreeCon first.teardown() -def test_failed_bootstrap_rolls_back_only_started_instruments() -> None: +@pytest.mark.parametrize("failure_type", [RuntimeError, KeyboardInterrupt, SystemExit]) +def test_failed_bootstrap_rolls_back_only_started_instruments(failure_type: type[BaseException]) -> None: events: list[str] = [] - failure = RuntimeError("bootstrap failed") + failure = failure_type("bootstrap failed") class First(BaseInstrument): def bootstrap(self) -> None: @@ -375,7 +376,7 @@ class Bootstrapper(FreeBootstrapper): instruments_types: typing.ClassVar = [First, Second, Boom, After] bootstrapper = Bootstrapper(FreeConfig()) - with pytest.raises(RuntimeError) as excinfo: + with pytest.raises(failure_type, match="bootstrap failed") as excinfo: bootstrapper.bootstrap() assert excinfo.value is failure @@ -388,9 +389,12 @@ class Bootstrapper(FreeBootstrapper): @pytest.mark.parametrize("fail_preparation", [False, True]) -def test_failed_bootstrap_preserves_error_when_rollback_fails(*, fail_preparation: bool) -> None: +@pytest.mark.parametrize("failure_type", [ValueError, KeyboardInterrupt, SystemExit]) +def test_failed_bootstrap_preserves_error_when_rollback_fails( + failure_type: type[BaseException], *, fail_preparation: bool +) -> None: stopped: list[str] = [] - failure = ValueError("original startup failure") + failure = failure_type("original startup failure") class First(BaseInstrument): def teardown(self) -> None: @@ -415,7 +419,7 @@ def _prepare_application(self) -> None: raise failure bootstrapper = Bootstrapper(FreeConfig()) - with pytest.raises(ValueError, match="original startup failure") as excinfo: + with pytest.raises(failure_type, match="original startup failure") as excinfo: bootstrapper.bootstrap() assert excinfo.value is failure diff --git a/tests/test_starlette_sentry_capture.py b/tests/test_starlette_sentry_capture.py index d6e013b..4ce0ad3 100644 --- a/tests/test_starlette_sentry_capture.py +++ b/tests/test_starlette_sentry_capture.py @@ -24,7 +24,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import HTTPConnection, Request from starlette.responses import JSONResponse, Response -from starlette.routing import Mount, Route +from starlette.routing import BaseRoute, Host, Match, Mount, Route from starlette.testclient import TestClient from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocket @@ -745,8 +745,10 @@ def respond(_: Request, __: Exception) -> Response: assert _exception_types(sentry_mock) == ["CarriesUnavailableStatusError"] -@pytest.mark.parametrize("mounted", [False, True]) -def test_fastmcp_lean_capture_uses_route_templates(sentry_mock: SentryTestTransport, *, mounted: bool) -> None: +@pytest.mark.parametrize(("child_framework", "mounted"), [("starlette", False), ("starlette", True), ("fastapi", True)]) +def test_fastmcp_lean_capture_uses_route_templates( + sentry_mock: SentryTestTransport, child_framework: str, *, mounted: bool +) -> None: config = _build_fastmcp_config(sentry_mock) bootstrapper = FastMcpBootstrapper(config) application = bootstrapper.bootstrap() @@ -756,9 +758,12 @@ async def fail(request: Request) -> Response: http_app = application.http_app() if mounted: - http_app.router.routes.append( - Mount("/shops/{shop_id}", app=Starlette(routes=[Route("/items/{item_id}", fail)])) - ) + if child_framework == "fastapi": + child_app = fastapi.FastAPI() + child_app.get("/items/{item_id}")(fail) + else: + child_app = Starlette(routes=[Route("/items/{item_id}", fail)]) + http_app.router.routes.append(Mount("/shops/{shop_id}", app=child_app)) path = "/shops/12/items/7" expected_transaction = "/shops/{shop_id}/items/{item_id}" else: @@ -794,7 +799,10 @@ async def named(_: Request) -> Response: assert event["transaction_info"] == {"source": "custom"} -def test_fastmcp_lean_capture_does_not_name_opaque_mounts_from_raw_paths(sentry_mock: SentryTestTransport) -> None: +@pytest.mark.parametrize("container", ["mount", "host", "custom"]) +def test_fastmcp_lean_capture_does_not_name_opaque_routes_from_siblings( + sentry_mock: SentryTestTransport, container: str +) -> None: config = _build_fastmcp_config(sentry_mock) bootstrapper = FastMcpBootstrapper(config) application = bootstrapper.bootstrap() @@ -803,11 +811,48 @@ async def fail(_scope: Scope, _receive: Receive, _send: Send) -> None: message = "boom" raise RuntimeError(message) + class OpaqueRoute(BaseRoute): + def matches(self, scope: Scope) -> tuple[Match, Scope]: + return Route("/opaque/{path:path}", fail).matches(scope) + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + await fail(scope, receive, send) + http_app = application.http_app() - http_app.router.routes.append(Mount("/opaque", app=fail)) + if container == "mount": + opaque_route = Mount("/opaque", app=fail) + elif container == "host": + opaque_route = Host("testserver", app=fail) + else: + opaque_route = OpaqueRoute() + http_app.router.routes.extend([opaque_route, Route("/opaque/private-id", fail)]) with TestClient(http_app, raise_server_exceptions=False) as client: response = client.get("/opaque/private-id") assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR [event] = _error_events(sentry_mock) assert "transaction" not in event + + +def test_fastmcp_lean_capture_resolves_host_before_sibling_route(sentry_mock: SentryTestTransport) -> None: + config = _build_fastmcp_config(sentry_mock) + bootstrapper = FastMcpBootstrapper(config) + application = bootstrapper.bootstrap() + + async def fail(request: Request) -> Response: + raise RuntimeError(request.path_params["item_id"]) + + http_app = application.http_app() + http_app.router.routes.extend( + [ + Host("tenant.example", app=Starlette(routes=[Route("/items/{item_id}", fail)])), + Route("/items/7", fail), + ] + ) + with TestClient(http_app, base_url="http://tenant.example", raise_server_exceptions=False) as client: + response = client.get("/items/7") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + [event] = _error_events(sentry_mock) + assert event["transaction"] == "/items/{item_id}" + assert event["transaction_info"] == {"source": "route"}