From 846f314b285d86c61eee64102f310b5244229284 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 28 Oct 2026 20:19:16 +0300 Subject: [PATCH] feat: capture Litestar errors without LitestarIntegration while Sentry tracing is off LitestarConfig.sentry_lean_error_capture defaults to None, which turns lean capture on while no Sentry tracing option is set. lite-bootstrap then disables LitestarIntegration and reports request errors through its own middleware and after_exception hook, with request data gated by data_collection. SentryInstrument is generic over its config and builds init kwargs in _build_init_params, so framework instruments can extend them. --- benchmarks/README.md | 21 +- benchmarks/frameworks/litestar_suite.py | 6 + docs/integrations/litestar.md | 47 ++ docs/introduction/configuration.md | 1 + docs/introduction/performance.md | 10 +- .../bootstrappers/litestar_bootstrapper.py | 190 ++++++- .../instruments/sentry_instrument.py | 26 +- scripts/floor_smoke.py | 31 ++ tests/test_litestar_bootstrap.py | 11 + tests/test_litestar_sentry_capture.py | 504 ++++++++++++++++++ 10 files changed, 833 insertions(+), 14 deletions(-) create mode 100644 tests/test_litestar_sentry_capture.py diff --git a/benchmarks/README.md b/benchmarks/README.md index a217d11..c5c07f9 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -368,7 +368,7 @@ prometheus-client 0.26.0, structlog 26.1.0. In-process, baseline 12.1 µs per re | OTel 0% + OTel HTTP metrics | +73.6 | +12.9 over OTel 0%, second run | | OTel 0% + Prometheus | +76.5 | +15.8 over OTel 0%, second run | | OTel 0% + OTel HTTP metrics + Prometheus | +90.2 | +29.5 over OTel 0%, second run | -| Sentry, lite-bootstrap defaults (tracing off) | +70.9 | LitestarIntegration | +| Sentry, lite-bootstrap 1.x defaults (tracing off) | +70.9 | LitestarIntegration | | Sentry, `sentry_auto_session_tracking=False` | +63.7 | sessions cost 7.2 | | Sentry, `auto_enabling_integrations=False` | -0.1 | LitestarIntegration is auto-enabling, so nothing captures request errors | | Sentry, `sentry_default_integrations=False` | -0.2 | same reason | @@ -386,6 +386,21 @@ prometheus-client 0.26.0, structlog 26.1.0. In-process, baseline 12.1 µs per re The "second run" rows come from a noisier re-check (spread between rounds up to 67% per row). The first run's OTel 0% row is +56.8, the re-check's +60.7, and the deltas in the notes are against the re-check. +Since 2.0, lite-bootstrap reports Litestar request errors itself while Sentry tracing is off, without +`LitestarIntegration`. Rerun in-process on the M2 host (macOS, CPython 3.14.7, litestar 2.25.0, +`bench.py litestar bare,sentry,sentry_sampled_trace,sentry_integration,sentry_integration_sampled_trace --repeat 2`): + +| scenario | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| `sentry`: lean capture, the 2.0 default | +7.3 | +8.3 | +| `sentry_sampled_trace`: the same, request carries a sampled `sentry-trace` | +9.7 | +10.1 | +| `sentry_integration`: `sentry_lean_error_capture=False` | +70.7 | +59.5 | +| `sentry_integration_sampled_trace` | +74.4 | +147.4 | + +On 3.0 an incoming sampled `sentry-trace` makes `LitestarIntegration` record a real span even with +tracing off. The table above was measured before 2.0. Its `sentry_nosess` scenario now has the same +config as `sentry`, because sessions are off by default since 2.0. + lite-bootstrap's Litestar defaults with no endpoint configured (Prometheus, health, logging and Swagger on) cost +11.7 µs on `/ping`, all of it Prometheus. The access-log middleware writes each record twice because Litestar's own stdlib logging config is applied at startup next to lite-bootstrap's. @@ -407,8 +422,8 @@ prometheus-client 0.26.0, structlog 26.1.0. Baseline 33350 RPS, 30.0 µs per req | OpenTelemetry, 10%, exclude receive/send | 11258 | +58.8 | | Prometheus | 18823 | +23.1 | | Litestar access log on | 6879 | +115.4 | -| Sentry, defaults | 8119 | +93.2 | -| Sentry, sessions off | 9184 | +78.9 | +| Sentry, 1.x defaults | 8119 | +93.2 | +| Sentry, 1.x defaults, sessions off | 9184 | +78.9 | | CORS (ab sends no Origin) | 30942 | +2.3 | | health check | 28337 | +5.3 | | health check with OpenTelemetry spans | 4600 | +187.4 | diff --git a/benchmarks/frameworks/litestar_suite.py b/benchmarks/frameworks/litestar_suite.py index 12d58ff..c0b90a8 100644 --- a/benchmarks/frameworks/litestar_suite.py +++ b/benchmarks/frameworks/litestar_suite.py @@ -38,6 +38,7 @@ HEALTH: typing.Final = {"health_checks_enabled": True} LITESTAR_DEFAULTS: typing.Final = {**PROM, **HEALTH, **LOG, "swagger_path": "/docs"} FULL: typing.Final = {**OTEL, **PROM, **LOG, **sentry(), **HEALTH} +SAMPLED_SENTRY_TRACE: typing.Final = ((b"sentry-trace", b"771a43a4192642f0b136d5159a501700-1234567890abcdef-1"),) SCENARIOS: typing.Final = { "bare": Scenario(config=base_config()), @@ -63,6 +64,11 @@ "sentry_noauto": Scenario(config=base_config(**sentry(auto_enabling_integrations=False))), "sentry_nodefault": Scenario(config=base_config(**sentry(), sentry_default_integrations=False)), "sentry_nointegration": Scenario(config=base_config(**sentry(disabled_integrations=[LitestarIntegration]))), + "sentry_integration": Scenario(config=base_config(**sentry(), sentry_lean_error_capture=False)), + "sentry_sampled_trace": Scenario(config=base_config(**sentry()), headers=SAMPLED_SENTRY_TRACE), + "sentry_integration_sampled_trace": Scenario( + config=base_config(**sentry(), sentry_lean_error_capture=False), headers=SAMPLED_SENTRY_TRACE + ), "cors_noorigin": Scenario(config=base_config(**CORS)), "cors": Scenario(config=base_config(**CORS), headers=ORIGIN), "bare_origin": Scenario(config=base_config(), headers=ORIGIN), diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 3e4a983..a2d4715 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -106,6 +106,53 @@ A bare `LoggingMiddlewareConfig()` restores all of Litestar's own defaults, including full request/response body logging, so pass explicit `request_log_fields` / `response_log_fields`. +## Sentry + +While Sentry tracing is off, lite-bootstrap reports request errors itself instead of through +sentry-sdk's `LitestarIntegration`. Tracing counts as off when `sentry_traces_sample_rate` is unset +and `sentry_additional_params` sets none of `traces_sample_rate`, `traces_sampler` and +`enable_tracing`. With tracing off, the integration still builds a transaction and a span per +middleware for every request and then discards them +([getsentry/sentry-python#7400](https://github.com/getsentry/sentry-python/issues/7400)). On +sentry-sdk 3.0, an incoming `sentry-trace` header marked as sampled makes it record a real span. + +`sentry_lean_error_capture` picks the path: + +- `None`, the default: lean capture while tracing is off and `sentry_integrations` holds no + `LitestarIntegration`, the integration otherwise. +- `True`: lean capture always. lite-bootstrap warns when tracing is on, since no request starts a + transaction, and when `sentry_integrations` holds a `LitestarIntegration`, which gets dropped. +- `False`: the integration always. + +Lean capture passes `LitestarIntegration` in `disabled_integrations` and adds a middleware and an +`after_exception` hook. The middleware gives each request its own Sentry scope, and its own +release-health session when `sentry_auto_session_tracking` is on. It continues an incoming +`sentry-trace` and `baggage`, and attaches the request to every event sent while the request runs. + +The request data follows the same [`data_collection`](../introduction/configuration.md#sentry-data-collection) +settings the integration reads. Method, URL, query string, headers and cookies are filtered by +their denylists. The client address and `scope["user"]` are attached only when `user_info` is on. +A body is attached only when `http_bodies` includes `"incoming_request"` and the handler has +already read it, because lean capture never reads the stream itself. The integration reads every +non-GET body before the handler runs. + +An exception is reported only when its response is a 5xx, so one that your `exception_handlers` +answer with a 404 is not reported. The integration reports every exception except an +`HTTPException` below 500. Litestar logs the exceptions it handles, and lean capture keeps the +`litestar` logger from turning them into a second event, as the integration does. + +Measured in-process on a do-nothing handler, over the same app without Sentry: + +| Sentry setup | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| lean capture, the default | +7.3 µs | +8.3 µs | +| `sentry_lean_error_capture=False` | +70.7 µs | +59.5 µs | +| the same, request carries a sampled `sentry-trace` | +74.4 µs | +147.4 µs | + +sentry-sdk records installed integrations for the whole process, and `disabled_integrations` +cannot remove one that an earlier `sentry_sdk.init` installed. If that happened, lite-bootstrap +warns that errors may be reported twice. + ## Prometheus `prometheus_group_path` defaults to `True`, so the `path` metric label uses the diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 9129ec5..10658d5 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -22,6 +22,7 @@ Additional parameters can also be supplied through the settings object: - `sentry_additional_params` - additional params, which will be passed to `sentry_sdk.init`, overriding any of the above that map to the same keyword - `sentry_default_integrations` - whether to use sentry's default integrations (default: `True`) - `sentry_before_send` - optional callback chained after the built-in structlog enricher, passed to `sentry_sdk.init(before_send=...)` +- `sentry_lean_error_capture` - Litestar only: report request errors without Sentry's `LitestarIntegration` (default: `None`, meaning on while Sentry tracing is off). See [Litestar](../integrations/litestar.md#sentry). Read more about sentry_sdk params [here](https://docs.sentry.io/platforms/python/configuration/options/). diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index beb4d57..27c2afc 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -104,10 +104,12 @@ The same app on Granian, over real sockets: --8<-- "benchmarks/README.md:litestargranian" -**Sentry costs more on Litestar than on FastAPI**, and all of it is `LitestarIntegration`: turning -off auto-enabling integrations removes the cost and, with it, request error capture. The access log -([`logging_access_log_enabled`](../integrations/litestar.md#logging)) is the other large opt-in -item, at about 70 µs per request in-process. +**Before 2.0, Sentry cost more on Litestar than on FastAPI**, and all of it was `LitestarIntegration`. +Since 2.0, lite-bootstrap reports Litestar request errors itself while Sentry tracing is off, which +brings Sentry from about +71 to about +7 µs per request in-process +([Litestar: Sentry](../integrations/litestar.md#sentry)). The access log +([`logging_access_log_enabled`](../integrations/litestar.md#logging)) is the largest opt-in item +left, at about 70 µs per request in-process. ## FastStream diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index bb71f42..2f58793 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -28,19 +28,28 @@ from lite_bootstrap.types import UNSET, UnsetType +if typing.TYPE_CHECKING: + from sentry_sdk import _types as sentry_types + if import_checker.is_litestar_installed: import litestar from litestar import status_codes from litestar.config.app import AppConfig from litestar.config.cors import CORSConfig + from litestar.connection import ASGIConnection + from litestar.enums import ScopeType + from litestar.exceptions import HTTPException from litestar.logging.config import LoggingConfig as LitestarStandardLoggingConfig from litestar.logging.config import StructLoggingConfig + from litestar.middleware import ASGIMiddleware from litestar.middleware.logging import LoggingMiddlewareConfig from litestar.openapi import OpenAPIConfig from litestar.openapi.plugins import SwaggerRenderPlugin from litestar.plugins.structlog import StructlogConfig, StructlogPlugin from litestar.static_files import create_static_files_router from litestar.types import Empty + from litestar.types.asgi_types import ASGIApp, Message, Receive, Scope, Send + from litestar.utils.scope.state import ScopeState if import_checker.is_litestar_installed and import_checker.is_prometheus_client_installed: # litestar.plugins.prometheus imports prometheus_client, which the `litestar` @@ -50,12 +59,24 @@ from litestar.plugins.prometheus import PrometheusConfig, PrometheusController if import_checker.is_litestar_opentelemetry_installed: - from litestar.middleware import ASGIMiddleware from litestar.plugins.opentelemetry import OpenTelemetryConfig as LitestarOpenTelemetryConfig from litestar.plugins.opentelemetry import OpenTelemetryPlugin - from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send from opentelemetry import trace +if import_checker.is_litestar_installed and import_checker.is_sentry_installed: + import sentry_sdk + from sentry_sdk import sessions + from sentry_sdk.data_collection import _apply_key_value_collection_filtering + from sentry_sdk.integrations import logging as sentry_logging + from sentry_sdk.integrations._asgi_common import _get_request_data, _RootPathInPath + from sentry_sdk.integrations.litestar import LitestarIntegration, retrieve_user_from_scope + from sentry_sdk.utils import event_from_exception, transaction_from_function + + from lite_bootstrap.instruments.sentry_instrument import IS_SENTRY_SDK_2 + + # 3.x narrowed ignore_logger to Sentry Logs and moved its 2.x meaning to ignore_logger_for_events. + ignore_logger_for_events = getattr(sentry_logging, "ignore_logger_for_events", sentry_logging.ignore_logger) + if import_checker.is_opentelemetry_installed: from opentelemetry.metrics import get_meter_provider from opentelemetry.trace import get_tracer_provider @@ -113,6 +134,122 @@ async def handle( await next_app(scope, receive, send) +_SENTRY_FAILED_REQUEST_STATUS_CODES: typing.Final = range(500, 600) +_SENTRY_PENDING_EXCEPTION_KEY: typing.Final = "_lite_bootstrap_sentry_pending_exception" +_SENTRY_TRACE_HEADERS: typing.Final = frozenset({b"sentry-trace", b"baggage"}) + + +@dataclasses.dataclass(slots=True) +class _PendingSentryException: + exception: Exception | None = None + + +def _read_parsed_request_body(scope: "Scope") -> typing.Any: # noqa: ANN401 + """Return the body a handler already read, without reading the stream.""" + state: typing.Final = ScopeState.from_scope(scope) + if state.json is not Empty: + return state.json + if state.form is not Empty: + return state.form + if state.body is not Empty: + return state.body.decode("utf-8", errors="replace") + return None + + +def _build_sentry_request_data(scope: "Scope") -> dict[str, typing.Any]: + request_data: typing.Final = _get_request_data(scope, root_path_in_path=_RootPathInPath.EXCLUDED) + data_collection: typing.Final = sentry_sdk.get_client().options["data_collection"] + if cookies := _apply_key_value_collection_filtering( + items=ASGIConnection(scope).cookies, behaviour=data_collection["cookies"] + ): + request_data["cookies"] = cookies + if "incoming_request" in data_collection["http_bodies"] and (body := _read_parsed_request_body(scope)) is not None: + request_data["data"] = body + return request_data + + +def enrich_sentry_event_from_litestar_scope(event: "sentry_types.Event", scope: "Scope") -> "sentry_types.Event": + event["request"] = {**event.get("request", {}), **_build_sentry_request_data(scope)} + if sentry_sdk.get_client().options["data_collection"]["user_info"] and isinstance( + user := retrieve_user_from_scope(scope), dict + ): + event.setdefault("user", user) + route_handler: typing.Final = scope.get("route_handler") + if route_handler is not None and "transaction" not in event: + handler_function: typing.Final = getattr(route_handler.fn, "value", route_handler.fn) + event["transaction"] = route_handler.name or transaction_from_function(handler_function) or scope["path"] + event["transaction_info"] = {"source": "component"} + return event + + +def _capture_litestar_exception(exception: Exception, scope: "Scope") -> None: + client_options: typing.Final = {"client_options": sentry_sdk.get_client().options} if IS_SENTRY_SDK_2 else {} + event, hint = event_from_exception( + exception, mechanism={"type": LitestarIntegration.identifier, "handled": False}, **client_options + ) + sentry_sdk.capture_event(enrich_sentry_event_from_litestar_scope(event, scope), hint=hint) + + +def capture_litestar_exception_after_handling(exception: Exception, scope: "Scope") -> None: + """Report an exception once its response status is known, or now if no capture middleware will see it. + + Litestar calls ``after_exception`` hooks before the exception handler builds the response, so inside + ``LitestarSentryCaptureMiddleware`` the exception waits for the status the handler chose. Outside it + (routing errors, middleware placed before it) only an ``HTTPException`` carries a status to go by. + """ + pending: typing.Final = typing.cast("dict[str, typing.Any]", scope).get(_SENTRY_PENDING_EXCEPTION_KEY) + if pending is not None: + pending.exception = exception + return + if isinstance(exception, HTTPException) and exception.status_code not in _SENTRY_FAILED_REQUEST_STATUS_CODES: + return + _capture_litestar_exception(exception, scope) + + +if import_checker.is_litestar_installed and import_checker.is_sentry_installed: + + class LitestarSentryCaptureMiddleware(ASGIMiddleware): + scopes = (ScopeType.HTTP,) + + async def handle( + self, + scope: "Scope", + receive: "Receive", + send: "Send", + next_app: "ASGIApp", + ) -> None: + pending: typing.Final = _PendingSentryException() + mutable_scope: typing.Final = typing.cast("dict[str, typing.Any]", scope) + mutable_scope[_SENTRY_PENDING_EXCEPTION_KEY] = pending + + async def send_and_capture(message: "Message") -> None: + if message["type"] == "http.response.start": + exception, pending.exception = pending.exception, None + if exception is not None and message["status"] in _SENTRY_FAILED_REQUEST_STATUS_CODES: + _capture_litestar_exception(exception, scope) + await send(message) + + with ( + sentry_sdk.isolation_scope() as sentry_scope, + sessions.track_session(sentry_scope, session_mode="request"), + ): + sentry_scope.clear_breadcrumbs() + sentry_scope.generate_propagation_context( + { + name.decode("latin-1"): value.decode("latin-1") + for name, value in scope["headers"] + if name in _SENTRY_TRACE_HEADERS + } + ) + sentry_scope.add_event_processor(lambda event, _: enrich_sentry_event_from_litestar_scope(event, scope)) + try: + await next_app(scope, receive, send_and_capture) + finally: + del mutable_scope[_SENTRY_PENDING_EXCEPTION_KEY] + if pending.exception is not None: + _capture_litestar_exception(pending.exception, scope) + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class LitestarConfig( CorsConfig, @@ -131,6 +268,7 @@ class LitestarConfig( prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) # Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891. prometheus_group_path: bool = True + sentry_lean_error_capture: bool | None = None swagger_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) # Deprecated names, removed in 2.0. _renamed_fields: typing.ClassVar[Mapping[str, str]] = { @@ -145,12 +283,34 @@ class LitestarConfig( def __post_init__(self) -> None: # @dataclass(slots=True) replaces the class object, breaking bare super(). super(LitestarConfig, self).__post_init__() + if self.sentry_lean_error_capture: + self._warn_about_forced_lean_sentry_capture() if self.litestar_logging_middleware_config is not None and not self.logging_access_log_enabled: warn_at_caller( "litestar_logging_middleware_config is ignored while logging_access_log_enabled is False; " "set logging_access_log_enabled=True to turn access logging on." ) + def _supplies_sentry_litestar_integration(self) -> bool: + return any(integration.identifier == "litestar" for integration in self.sentry_integrations) + + def is_sentry_lean_error_capture_on(self) -> bool: + if self.sentry_lean_error_capture is not None: + return self.sentry_lean_error_capture + return not self.is_sentry_tracing_requested() and not self._supplies_sentry_litestar_integration() + + def _warn_about_forced_lean_sentry_capture(self) -> None: + if self.is_sentry_tracing_requested(): + warn_at_caller( + "sentry_lean_error_capture starts no transaction per request, so Sentry tracing " + "only samples transactions your code starts itself." + ) + if self._supplies_sentry_litestar_integration(): + warn_at_caller( + "sentry_lean_error_capture disables Sentry's LitestarIntegration, so the one in " + "sentry_integrations is dropped." + ) + @dataclasses.dataclass(kw_only=True, slots=True) class LitestarCorsInstrument(CorsInstrument[LitestarConfig]): @@ -299,6 +459,30 @@ class LitestarPrometheusController(PrometheusController): config.application_config.middleware.append(litestar_prometheus_config.middleware) +@dataclasses.dataclass(kw_only=True, slots=True) +class LitestarSentryInstrument(SentryInstrument[LitestarConfig]): + def _build_init_params(self) -> dict[str, typing.Any]: + init_params: typing.Final = super(LitestarSentryInstrument, self)._build_init_params() + if self.bootstrap_config.is_sentry_lean_error_capture_on(): + init_params["disabled_integrations"] = [*init_params.get("disabled_integrations", []), LitestarIntegration] + return init_params + + def bootstrap(self) -> None: + super(LitestarSentryInstrument, self).bootstrap() + if not self.bootstrap_config.is_sentry_lean_error_capture_on(): + return + if sentry_sdk.get_client().get_integration(LitestarIntegration) is not None: + warn_at_caller( + "Sentry's LitestarIntegration was installed by an earlier sentry_sdk.init in this process and " + "sentry_lean_error_capture cannot disable it, so request errors may be reported twice." + ) + # LitestarIntegration ignores this logger too: Litestar logs every exception it handles. + ignore_logger_for_events("litestar") + application_config: typing.Final = self.bootstrap_config.application_config + application_config.middleware.insert(0, LitestarSentryCaptureMiddleware()) + application_config.after_exception.append(capture_litestar_exception_after_handling) + + @dataclasses.dataclass(kw_only=True) class LitestarSwaggerInstrument(SwaggerInstrument[LitestarConfig]): not_configured_reason = "swagger_path is empty or not valid" @@ -342,7 +526,7 @@ class LitestarBootstrapper(BaseBootstrapper["litestar.Litestar"]): LitestarCorsInstrument, LitestarOpenTelemetryInstrument, PyroscopeInstrument, - SentryInstrument, + LitestarSentryInstrument, LitestarHealthChecksInstrument, LitestarLoggingInstrument, LitestarPrometheusInstrument, diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 96357c0..9f8bc22 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -36,6 +36,9 @@ } +_TRACING_OPTIONS: typing.Final = ("traces_sample_rate", "traces_sampler", "enable_tracing") + + @dataclasses.dataclass(kw_only=True, frozen=True) class SentryConfig(BaseConfig): sentry_dsn: str | None = None @@ -52,6 +55,15 @@ class SentryConfig(BaseConfig): sentry_default_integrations: bool = True sentry_before_send: "sentry_types.EventProcessor | None" = None + def is_sentry_tracing_requested(self) -> bool: + return any( + option is not None + for option in ( + self.sentry_traces_sample_rate, + *(self.sentry_additional_params.get(name) for name in _TRACING_OPTIONS), + ) + ) + def enrich_sentry_event_from_structlog_log( event: "sentry_types.Event", _: "sentry_types.Hint" @@ -98,8 +110,11 @@ def run_before_send( return run_before_send +SentryConfigT = typing.TypeVar("SentryConfigT", bound=SentryConfig) + + @dataclasses.dataclass(kw_only=True, slots=True) -class SentryInstrument(BaseInstrument[SentryConfig]): +class SentryInstrument(BaseInstrument[SentryConfigT]): not_configured_reason = "sentry_dsn is empty" missing_dependency_message = "sentry_sdk is not installed" @@ -131,7 +146,7 @@ def _build_integrations(self) -> list["Integration"]: ) return [*config.sentry_integrations, LoggingIntegration(**logging_integration_kwargs)] - def bootstrap(self) -> None: + def _build_init_params(self) -> dict[str, typing.Any]: config = self.bootstrap_config init_params: dict[str, typing.Any] = { "dsn": config.sentry_dsn, @@ -150,8 +165,11 @@ def bootstrap(self) -> None: ), } init_params.update(config.sentry_additional_params) - sentry_sdk.init(**init_params) - tags: dict[str, str] = config.sentry_tags or {} + return init_params + + def bootstrap(self) -> None: + sentry_sdk.init(**self._build_init_params()) + tags: dict[str, str] = self.bootstrap_config.sentry_tags or {} sentry_sdk.set_tags(tags) def teardown(self) -> None: diff --git a/scripts/floor_smoke.py b/scripts/floor_smoke.py index e40a519..e592d8e 100644 --- a/scripts/floor_smoke.py +++ b/scripts/floor_smoke.py @@ -41,6 +41,11 @@ if import_checker.is_opentelemetry_installed: from opentelemetry import trace +if import_checker.is_litestar_installed: + import litestar + from litestar.config.app import AppConfig + from litestar.testing import TestClient + if import_checker.is_sentry_installed: import sentry_sdk @@ -142,7 +147,33 @@ def _fastapi() -> None: asyncio.run(_drive_lifespan(application)) +def _litestar_lean_sentry_capture() -> None: + """Send one failing request through lean error capture, whose scope and session calls have floors too.""" + + @litestar.get("/floor-error") + async def floor_error() -> None: + message = "floor smoke" + raise RuntimeError(message) + + bootstrapper = LitestarBootstrapper( + bootstrap_config=LitestarConfig( + service_name="floor-smoke", + service_version="1.0.0", + service_debug=False, + logging_buffer_capacity=0, + sentry_dsn=SENTRY_DSN, + sentry_additional_params=SENTRY_PARAMS, + application_config=AppConfig(route_handlers=[floor_error]), + ) + ) + with TestClient(app=bootstrapper.bootstrap(), raise_server_exceptions=False) as test_client: + expected_status = 500 + assert test_client.get("/floor-error").status_code == expected_status + + def _litestar() -> None: + if import_checker.is_sentry_installed: + _litestar_lean_sentry_capture() bootstrapper = LitestarBootstrapper( bootstrap_config=LitestarConfig( service_name="floor-smoke", diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 341e15d..0ac38eb 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -262,6 +262,17 @@ def test_litestar_bootstrap_without_prometheus_client() -> None: sys.modules.update(saved) +def test_litestar_bootstrapper_runs_without_sentry_sdk(litestar_config: LitestarConfig) -> None: + with emulate_package_missing_with_module_reload( + "sentry_sdk", + ["lite_bootstrap.instruments.sentry_instrument", "lite_bootstrap.bootstrappers.litestar_bootstrapper"], + ): + with pytest.warns(UserWarning, match="sentry_sdk"): + bootstrapper = litestar_bootstrapper.LitestarBootstrapper(bootstrap_config=litestar_config) + bootstrapper.bootstrap() + bootstrapper.teardown() + + class _RecordingHandler(logging.Handler): def __init__(self) -> None: super().__init__() diff --git a/tests/test_litestar_sentry_capture.py b/tests/test_litestar_sentry_capture.py new file mode 100644 index 0000000..76cd62c --- /dev/null +++ b/tests/test_litestar_sentry_capture.py @@ -0,0 +1,504 @@ +import logging +import typing +import warnings +from collections.abc import Iterator, Mapping +from unittest.mock import AsyncMock + +import litestar +import pytest +import sentry_sdk +from litestar import status_codes +from litestar.config.app import AppConfig +from litestar.enums import RequestEncodingType +from litestar.exceptions import InternalServerException, NotFoundException +from litestar.middleware import ASGIMiddleware +from litestar.params import Body +from litestar.testing import TestClient +from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send +from sentry_sdk import integrations as sentry_integrations +from sentry_sdk.integrations.httpx import HttpxIntegration +from sentry_sdk.integrations.litestar import LitestarIntegration +from sentry_sdk.utils import transaction_from_function + +from lite_bootstrap import LitestarBootstrapper, LitestarConfig +from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarSentryInstrument +from lite_bootstrap.instruments.sentry_instrument import DEFAULT_DATA_COLLECTION +from tests.conftest import SentryTestTransport + + +std_logger = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def _litestar_integration_not_yet_installed() -> Iterator[None]: + """Start each test as a fresh process would, with Sentry's LitestarIntegration not installed. + + sentry-sdk records installed integrations process-wide, and ``disabled_integrations`` cannot remove one + that an earlier ``sentry_sdk.init`` installed. Any test that bootstraps Sentry with Litestar importable + installs it, because it is one of sentry-sdk's auto-enabling integrations. + """ + identifier: typing.Final = LitestarIntegration.identifier + registries: typing.Final = ( + sentry_integrations._processed_integrations, # noqa: SLF001 + sentry_integrations._installed_integrations, # noqa: SLF001 + ) + was_in: typing.Final = [identifier in registry for registry in registries] + for registry in registries: + registry.discard(identifier) + yield + for registry, was_there in zip(registries, was_in, strict=True): + (registry.add if was_there else registry.discard)(identifier) + + +class MappedToNotFoundError(Exception): + pass + + +class MappedToUnavailableError(Exception): + pass + + +class BrokenHandlerError(Exception): + pass + + +class SetUserMiddleware(ASGIMiddleware): + async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: + scope["user"] = {"id": "42", "email": "user@example.com"} + await next_app(scope, receive, send) + + +@litestar.get("/boom") +async def boom() -> None: + message = "boom" + raise RuntimeError(message) + + +@litestar.post("/read-body-then-boom") +async def read_body_then_boom(data: typing.Annotated[dict[str, str], Body()]) -> None: + raise RuntimeError(data["message"]) + + +@litestar.post("/read-form-then-boom") +async def read_form_then_boom( + data: typing.Annotated[dict[str, str], Body(media_type=RequestEncodingType.URL_ENCODED)], +) -> None: + raise RuntimeError(data["message"]) + + +@litestar.post("/read-raw-body-then-boom") +async def read_raw_body_then_boom(request: litestar.Request) -> None: + raise RuntimeError((await request.body()).decode()) + + +@litestar.post("/boom-without-reading-body") +async def boom_without_reading_body() -> None: + message = "boom" + raise RuntimeError(message) + + +@litestar.get("/mapped-404") +async def mapped_404() -> None: + raise MappedToNotFoundError + + +@litestar.get("/mapped-503") +async def mapped_503() -> None: + raise MappedToUnavailableError + + +@litestar.get("/http-404") +async def http_404() -> None: + raise NotFoundException + + +@litestar.get("/http-500") +async def http_500() -> None: + raise InternalServerException + + +@litestar.get("/broken-handler") +async def broken_handler() -> None: + raise BrokenHandlerError + + +@litestar.get("/logs-then-ok") +async def logs_then_ok() -> str: + std_logger.warning("breadcrumb from an earlier request") + return "ok" + + +@litestar.get("/integrations") +async def integrations() -> list[str]: + return sorted(sentry_sdk.get_client().integrations) + + +def _respond_404(_: litestar.Request, __: Exception) -> litestar.Response[None]: + return litestar.Response(None, status_code=status_codes.HTTP_404_NOT_FOUND) + + +def _respond_503(_: litestar.Request, __: Exception) -> litestar.Response[None]: + return litestar.Response(None, status_code=status_codes.HTTP_503_SERVICE_UNAVAILABLE) + + +def _fail_to_respond(_: litestar.Request, __: Exception) -> litestar.Response[None]: + message = "the exception handler failed" + raise RuntimeError(message) + + +def _build_config(transport: SentryTestTransport, **overrides: typing.Any) -> LitestarConfig: # noqa: ANN401 + sentry_additional_params = {"transport": transport, **overrides.pop("sentry_additional_params", {})} + middleware = overrides.pop("middleware", []) + return LitestarConfig( + service_environment="test", + sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params=sentry_additional_params, + logging_buffer_capacity=0, + application_config=AppConfig( + route_handlers=[ + boom, + read_body_then_boom, + read_form_then_boom, + read_raw_body_then_boom, + boom_without_reading_body, + mapped_404, + mapped_503, + http_404, + http_500, + broken_handler, + logs_then_ok, + integrations, + ], + exception_handlers={ + MappedToNotFoundError: _respond_404, + MappedToUnavailableError: _respond_503, + BrokenHandlerError: _fail_to_respond, + }, + middleware=middleware, + ), + **overrides, + ) + + +def _error_events(transport: SentryTestTransport) -> list[dict[str, typing.Any]]: + events = ( + typing.cast("dict[str, typing.Any] | None", envelope.get_event()) for envelope in transport.mock_envelopes + ) + return [event for event in events if event is not None and "exception" in event] + + +def _exception_types(transport: SentryTestTransport) -> list[str]: + return [event["exception"]["values"][-1]["type"] for event in _error_events(transport)] + + +def _request(transport: SentryTestTransport, *paths: str, **overrides: typing.Any) -> list[int]: # noqa: ANN401 + application = LitestarBootstrapper(bootstrap_config=_build_config(transport, **overrides)).bootstrap() + with TestClient(app=application, raise_server_exceptions=False) as test_client: + return [test_client.get(path).status_code for path in paths] + + +def _active_integrations(transport: SentryTestTransport, **overrides: typing.Any) -> list[str]: # noqa: ANN401 + application = LitestarBootstrapper(bootstrap_config=_build_config(transport, **overrides)).bootstrap() + with TestClient(app=application) as test_client: + return test_client.get("/integrations").json() + + +def test_lean_capture_is_the_default_while_tracing_is_off(sentry_mock: SentryTestTransport) -> None: + application = LitestarBootstrapper( + bootstrap_config=LitestarConfig( + sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": sentry_mock}, + logging_buffer_capacity=0, + application_config=AppConfig(route_handlers=[boom, integrations]), + ) + ).bootstrap() + with TestClient(app=application, raise_server_exceptions=False) as test_client: + active = test_client.get("/integrations").json() + test_client.get("/boom") + + assert LitestarIntegration.identifier not in active + [event] = _error_events(sentry_mock) + assert event["request"]["url"] == "http://testserver.local/boom" + + +@pytest.mark.parametrize( + "tracing_overrides", + [ + pytest.param({"sentry_traces_sample_rate": 0.0}, id="sentry_traces_sample_rate"), + pytest.param({"sentry_additional_params": {"traces_sample_rate": 0.5}}, id="traces_sample_rate"), + pytest.param({"sentry_additional_params": {"traces_sampler": lambda _: 0.5}}, id="traces_sampler"), + ], +) +def test_default_keeps_the_litestar_integration_while_tracing_is_on( + sentry_mock: SentryTestTransport, tracing_overrides: dict[str, typing.Any] +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + active = _active_integrations(sentry_mock, **tracing_overrides) + + assert LitestarIntegration.identifier in active + + +def test_lean_capture_turned_off_keeps_the_litestar_integration(sentry_mock: SentryTestTransport) -> None: + config = _build_config(sentry_mock, sentry_lean_error_capture=False) + active = _active_integrations(sentry_mock, sentry_lean_error_capture=False) + + assert "disabled_integrations" not in _sentry_init_kwargs(config) + assert LitestarIntegration.identifier in active + + +def test_lean_capture_reports_unhandled_exception_with_request_data(sentry_mock: SentryTestTransport) -> None: + application = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)).bootstrap() + with TestClient(app=application, raise_server_exceptions=False) as test_client: + response = test_client.get("/boom?page=2", headers={"x-real-ip": "10.0.0.1", "user-agent": "tests"}) + + assert response.status_code == status_codes.HTTP_500_INTERNAL_SERVER_ERROR + [event] = _error_events(sentry_mock) + exception = event["exception"]["values"][-1] + assert exception["type"] == "RuntimeError" + assert (exception["mechanism"]["type"], exception["mechanism"]["handled"]) == ("litestar", False) + assert event["request"]["method"] == "GET" + assert event["request"]["url"] == "http://testserver.local/boom" + assert event["request"]["query_string"] == "page=2" + assert event["request"]["headers"]["x-real-ip"] == "[Filtered]" + assert event["request"]["headers"]["user-agent"] == "tests" + assert "env" not in event["request"] + assert event["transaction"] == transaction_from_function(boom.fn) + assert event["transaction_info"] == {"source": "component"} + + +def test_lean_capture_filters_cookies_through_data_collection(sentry_mock: SentryTestTransport) -> None: + application = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)).bootstrap() + with TestClient( + app=application, raise_server_exceptions=False, cookies={"theme": "dark", "session": "s3cr3t"} + ) as test_client: + test_client.get("/boom") + + [event] = _error_events(sentry_mock) + assert event["request"]["cookies"] == {"theme": "dark", "session": "[Filtered]"} + assert event["request"]["headers"]["cookie"] == "[Filtered]" + + +def test_lean_capture_sends_no_cookies_when_data_collection_turns_them_off(sentry_mock: SentryTestTransport) -> None: + data_collection = {**DEFAULT_DATA_COLLECTION, "cookies": {"mode": "off"}} + config = _build_config(sentry_mock, sentry_additional_params={"data_collection": data_collection}) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + with TestClient(app=application, raise_server_exceptions=False, cookies={"theme": "dark"}) as test_client: + test_client.get("/boom") + + [event] = _error_events(sentry_mock) + assert "cookies" not in event["request"] + + +@pytest.mark.parametrize( + ("user_info", "expected_user"), + [ + pytest.param(False, None, id="user-info-off"), + pytest.param(True, {"id": "42", "email": "user@example.com"}, id="user-info-on"), + ], +) +def test_lean_capture_attaches_the_scope_user_only_when_data_collection_allows_it( + sentry_mock: SentryTestTransport, user_info: bool, expected_user: dict[str, str] | None +) -> None: + data_collection = {**DEFAULT_DATA_COLLECTION, "user_info": user_info} + _request( + sentry_mock, + "/boom", + sentry_additional_params={"data_collection": data_collection}, + middleware=[SetUserMiddleware()], + ) + + [event] = _error_events(sentry_mock) + assert event.get("user") == expected_user + + +def test_lean_capture_attaches_the_client_address_only_when_data_collection_allows_it( + sentry_mock: SentryTestTransport, +) -> None: + data_collection = {**DEFAULT_DATA_COLLECTION, "user_info": True} + _request(sentry_mock, "/boom", sentry_additional_params={"data_collection": data_collection}) + + [event] = _error_events(sentry_mock) + assert event["request"]["env"] == {"REMOTE_ADDR": "testclient"} + + +@pytest.mark.parametrize( + ("http_bodies", "path", "request_kwargs", "expected_data"), + [ + pytest.param([], "/read-body-then-boom", {"json": {"message": "boom"}}, None, id="bodies-off"), + pytest.param( + ["incoming_request"], + "/read-body-then-boom", + {"json": {"message": "boom"}}, + {"message": "boom"}, + id="json-read-by-handler", + ), + pytest.param( + ["incoming_request"], + "/read-form-then-boom", + {"data": {"message": "boom"}}, + {"message": "boom"}, + id="form-read-by-handler", + ), + pytest.param( + ["incoming_request"], "/read-raw-body-then-boom", {"content": b"boom"}, "boom", id="raw-read-by-handler" + ), + pytest.param( + ["incoming_request"], + "/boom-without-reading-body", + {"json": {"message": "boom"}}, + None, + id="not-read-by-handler", + ), + ], +) +def test_lean_capture_attaches_only_a_body_the_handler_already_read( + sentry_mock: SentryTestTransport, + http_bodies: list[str], + path: str, + request_kwargs: dict[str, typing.Any], + expected_data: dict[str, str] | str | None, +) -> None: + data_collection = {**DEFAULT_DATA_COLLECTION, "http_bodies": http_bodies} + config = _build_config(sentry_mock, sentry_additional_params={"data_collection": data_collection}) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + with TestClient(app=application, raise_server_exceptions=False) as test_client: + test_client.post(path, **request_kwargs) + + [event] = _error_events(sentry_mock) + assert event["request"].get("data") == expected_data + + +async def test_lean_capture_continues_an_incoming_sentry_trace(sentry_mock: SentryTestTransport) -> None: + # Called without TestClient: sentry-sdk's httpx integration would overwrite the outgoing sentry-trace. + trace_id = "771a43a4192642f0b136d5159a501700" + application = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)).bootstrap() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": "/boom", + "raw_path": b"/boom", + "root_path": "", + "query_string": b"", + "headers": [(b"host", b"testserver"), (b"sentry-trace", f"{trace_id}-1234567890abcdef-1".encode())], + "client": ("127.0.0.1", 1), + "server": ("testserver", 80), + "state": {}, + } + receive = AsyncMock(return_value={"type": "http.request", "body": b"", "more_body": False}) + + await application(scope, receive, AsyncMock()) # ty: ignore[invalid-argument-type] + + [event] = _error_events(sentry_mock) + assert event["contexts"]["trace"]["trace_id"] == trace_id + + +def test_lean_capture_skips_exception_mapped_to_4xx(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/mapped-404") == [status_codes.HTTP_404_NOT_FOUND] + assert _error_events(sentry_mock) == [] + + +def test_lean_capture_reports_exception_mapped_to_5xx(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/mapped-503") == [status_codes.HTTP_503_SERVICE_UNAVAILABLE] + assert _exception_types(sentry_mock) == ["MappedToUnavailableError"] + + +def test_lean_capture_skips_client_http_exceptions(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/http-404", "/no-such-route") == [ + status_codes.HTTP_404_NOT_FOUND, + status_codes.HTTP_404_NOT_FOUND, + ] + assert _error_events(sentry_mock) == [] + + +def test_lean_capture_reports_server_http_exceptions(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/http-500") == [status_codes.HTTP_500_INTERNAL_SERVER_ERROR] + assert _exception_types(sentry_mock) == ["InternalServerException"] + + +def test_lean_capture_reports_both_exceptions_when_the_handler_fails(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/broken-handler") == [status_codes.HTTP_500_INTERNAL_SERVER_ERROR] + assert _exception_types(sentry_mock) == ["BrokenHandlerError", "RuntimeError"] + + +def test_lean_capture_keeps_breadcrumbs_to_their_own_request(sentry_mock: SentryTestTransport) -> None: + _request(sentry_mock, "/logs-then-ok", "/boom") + + [event] = _error_events(sentry_mock) + messages = [crumb.get("message") for crumb in event.get("breadcrumbs", {}).get("values", [])] + assert "breadcrumb from an earlier request" not in messages + + +@pytest.mark.parametrize("service_debug", [False, True]) +def test_lean_capture_reports_once_when_litestar_logs_the_exception( + sentry_mock: SentryTestTransport, service_debug: bool +) -> None: + assert _request(sentry_mock, "/boom", service_debug=service_debug) == [status_codes.HTTP_500_INTERNAL_SERVER_ERROR] + assert _exception_types(sentry_mock) == ["RuntimeError"] + + +def test_lean_capture_replaces_the_litestar_integration(sentry_mock: SentryTestTransport) -> None: + active = _active_integrations(sentry_mock, sentry_lean_error_capture=True) + + assert LitestarIntegration.identifier not in active + assert "logging" in active + + +def _sentry_init_kwargs(config: LitestarConfig) -> Mapping[str, typing.Any]: + return LitestarSentryInstrument(bootstrap_config=config)._build_init_params() # noqa: SLF001 + + +def test_lean_capture_keeps_user_disabled_integrations(sentry_mock: SentryTestTransport) -> None: + httpx_integration = HttpxIntegration() + config = _build_config(sentry_mock, sentry_additional_params={"disabled_integrations": [httpx_integration]}) + + assert _sentry_init_kwargs(config)["disabled_integrations"] == [httpx_integration, LitestarIntegration] + + +def test_lean_capture_warns_when_an_earlier_init_installed_the_litestar_integration( + sentry_mock: SentryTestTransport, +) -> None: + sentry_integrations._installed_integrations.add(LitestarIntegration.identifier) # noqa: SLF001 + bootstrapper = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)) + + with pytest.warns(UserWarning, match="installed by an earlier sentry_sdk.init"): + bootstrapper.bootstrap() + bootstrapper.teardown() + + +def test_forced_lean_capture_warns_that_traces_sample_rate_gets_no_request_transactions( + sentry_mock: SentryTestTransport, +) -> None: + with pytest.warns(UserWarning, match="Sentry tracing"): + config = _build_config(sentry_mock, sentry_lean_error_capture=True, sentry_traces_sample_rate=1.0) + + assert _sentry_init_kwargs(config)["disabled_integrations"] == [LitestarIntegration] + + +def test_default_keeps_a_supplied_litestar_integration(sentry_mock: SentryTestTransport) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + active = _active_integrations(sentry_mock, sentry_integrations=[LitestarIntegration()]) + + assert LitestarIntegration.identifier in active + + +def test_forced_lean_capture_warns_that_a_supplied_litestar_integration_is_dropped( + sentry_mock: SentryTestTransport, +) -> None: + with pytest.warns(UserWarning, match="LitestarIntegration"): + _build_config(sentry_mock, sentry_lean_error_capture=True, sentry_integrations=[LitestarIntegration()]) + + +def test_integration_capture_does_not_warn_about_lean_settings(sentry_mock: SentryTestTransport) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + _build_config( + sentry_mock, + sentry_lean_error_capture=False, + sentry_traces_sample_rate=1.0, + sentry_integrations=[LitestarIntegration()], + )