Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
20 changes: 17 additions & 3 deletions lite_bootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
BootstrapperNotReadyError,
ConfigurationError,
InstrumentDependencyMissingWarning,
TeardownErrorCollector,
collect_teardown_errors,
)
from lite_bootstrap.instruments.base import BaseInstrument, ConfigT
Expand Down Expand Up @@ -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 BaseException 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:
Expand Down
2 changes: 1 addition & 1 deletion lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 36 additions & 7 deletions lite_bootstrap/helpers/starlette_sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -28,6 +29,7 @@
from starlette.datastructures import Headers
from starlette.middleware.exceptions import ExceptionMiddleware
from starlette.requests import HTTPConnection
from starlette.routing import Host, Match, Mount, Route, WebSocketRoute


_CAPTURED_SCOPE_TYPES: typing.Final = frozenset({"http", "websocket"})
Expand Down Expand Up @@ -84,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


Expand Down Expand Up @@ -127,18 +126,36 @@ 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, 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") -> None:
def __init__(self, app: "ASGIApp", routes: "typing.Sequence[BaseRoute] | None" = None) -> None:
self.app = app
self.routes = routes
_report_handled_server_errors(app)

async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
if scope["type"] not in _CAPTURED_SCOPE_TYPES:
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()
Expand All @@ -150,9 +167,21 @@ 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:
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)
try:
await self.app(scope, receive, send)
except Exception as exception:
Expand Down
2 changes: 1 addition & 1 deletion tests/instruments/test_swagger_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_free_bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import types
import typing
import warnings
from unittest.mock import MagicMock

Expand All @@ -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
Expand Down Expand Up @@ -348,3 +350,84 @@ def test_two_free_bootstrappers_both_bootstrap(free_bootstrapper_config: FreeCon
finally:
second.teardown()
first.teardown()


@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 = failure_type("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(failure_type, match="bootstrap failed") 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])
@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 = failure_type("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(failure_type, 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()
115 changes: 115 additions & 0 deletions tests/test_starlette_sentry_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 BaseRoute, Host, Match, Mount, Route
from starlette.testclient import TestClient
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.websockets import WebSocket
Expand Down Expand Up @@ -741,3 +743,116 @@ 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(("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()

async def fail(request: Request) -> Response:
raise RuntimeError(request.path_params["item_id"])

http_app = application.http_app()
if mounted:
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:
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"}


@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()

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()
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"}
Loading