diff --git a/benchmarks/README.md b/benchmarks/README.md index c5c07f9..7e8551e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -210,7 +210,7 @@ structlog 26.1.0. In-process, baseline 17.7 µs per request. The handler does no | OTel 0% + OTel HTTP metrics (`opentelemetry_metrics_endpoint`) | +78.9 | +12.3 over OTel 0% | | OTel 0% + Prometheus | +91.5 | +24.9 over OTel 0% | | OTel 0% + OTel HTTP metrics + Prometheus | +104.9 | +38.3 over OTel 0% | -| Sentry, lite-bootstrap defaults (tracing off) | +59.0 to +59.7 | two runs | +| Sentry, lite-bootstrap 1.x defaults (tracing off) | +59.0 to +59.7 | two runs | | Sentry, `sentry_auto_session_tracking=False` | +51.8 | | | Sentry, `auto_enabling_integrations=False` | +0.1 | the Starlette and FastAPI integrations are auto-enabling, so nothing captures request errors | | health check `/health/` | +22.6 | noisy, 16% spread | @@ -219,6 +219,23 @@ structlog 26.1.0. In-process, baseline 17.7 µs per request. The handler does no | full: OTel + Prometheus + logging + Sentry + health, defaults | +259.6 | | +Since 2.0, lite-bootstrap reports FastAPI request errors itself while Sentry tracing is off, without +`StarletteIntegration` and `FastApiIntegration`. Rerun in-process on the M2 host (macOS, CPython +3.14.7, fastapi 0.162.0, starlette 1.10.0, +`bench.py fastapi bare,sentry,sentry_sampled_trace,sentry_nointegration,sentry_integration,sentry_integration_sampled_trace --repeat 2`): + +| scenario | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| `sentry`: lean capture, the 2.0 default | +6.1 | +5.1 | +| `sentry_sampled_trace`: the same, request carries a sampled `sentry-trace` | +11.5 | +10.9 | +| `sentry_nointegration`: both integrations disabled, no request capture at all | +0.1 | -0.2 | +| `sentry_integration`: `sentry_lean_error_capture=False` | +57.1 | +47.0 | +| `sentry_integration_sampled_trace` | +59.2 | +113.2 | + +The last row on 3.0.0b2 varied by 40% between rounds. The table above was measured before 2.0. Its +`sentry_nosess` row now has the same config as `sentry`, and `sentry_noauto` no longer stops request +capture, because lean capture needs no auto-enabling integration. + FastAPI real-server runs under uvicorn in the container were unstable (bare repeat medians of 8235 and 6341 RPS, over 100% spread on the full stack), so the Linux real-server numbers below are Litestar on Granian only. @@ -398,7 +415,9 @@ Since 2.0, lite-bootstrap reports Litestar request errors itself while Sentry tr | `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 +tracing off. `sentry_nointegration`, which disables `LitestarIntegration` and turns lean capture off, +so nothing captures request errors, measured +0.3 and +0.0 in a later run that put `sentry` at ++6.7 and +6.8. 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 @@ -498,7 +517,7 @@ it FastMCP and the MCP SDK. Noise on this suite is about ±30 µs even with 3 re | OpenTelemetry, 100% | +272 | 3 ASGI spans plus FastMCP's own server span | | OpenTelemetry, 100%, FastMCP's own spans replaced by a no-op tracer | +226 | FastMCP's spans cost 46 | | OpenTelemetry, 10% | +120 | | -| Sentry, defaults | +100 | the Starlette integration | +| Sentry, 1.x defaults | +100 | the Starlette integration | | Sentry, MCPIntegration disabled | +99 | MCPIntegration costs nothing measurable with tracing off | | Sentry, `auto_enabling_integrations=False` | +3 | no Starlette integration, so no request capture | | full: OTel + Prometheus + logging + Sentry + health | +521 | | @@ -506,6 +525,22 @@ it FastMCP and the MCP SDK. Noise on this suite is about ±30 µs even with 3 re The no-op tracer row patches FastMCP to attribute the cost; it is not a configuration. +Since 2.0, lite-bootstrap reports errors in every application `http_app()` builds itself while +Sentry tracing is off, without `StarletteIntegration`. Rerun in-process on the M2 host (macOS, +CPython 3.14.7, fastmcp 4.1.4, starlette 1.10.0, +`bench.py fastmcp bare,sentry,sentry_sampled_trace,sentry_nointegration,sentry_integration,sentry_integration_sampled_trace --repeat 3 --ops 1500 --warmup 300`), +over a baseline of about 620 µs per tool call: + +| scenario | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| `sentry`: lean capture, the 2.0 default | +20.6 | +15.9 | +| `sentry_sampled_trace` | +16.1 | +18.9 | +| `sentry_nointegration`: `StarletteIntegration` disabled, no request capture at all | -0.3 | +1.8 | +| `sentry_integration`: `sentry_lean_error_capture=False` | +82.2 | +65.0 | +| `sentry_integration_sampled_trace` | +82.3 | +216.4 | + +Rounds differed by up to 15%, so the lean rows are not resolved from each other. + ### 8f. Logging, per call The per-call chain is the same on every framework: structlog's stdlib `BoundLogger`, lite-bootstrap's diff --git a/benchmarks/frameworks/common.py b/benchmarks/frameworks/common.py index 92f313f..12a5133 100644 --- a/benchmarks/frameworks/common.py +++ b/benchmarks/frameworks/common.py @@ -167,3 +167,6 @@ def sentry(**extra_params: object) -> dict[str, typing.Any]: LOG: typing.Final = {"logging_enabled": True} CORS: typing.Final = {"cors_allowed_origins": ["https://example.com"], "cors_allowed_methods": ["GET"]} ORIGIN: typing.Final[Headers] = ((b"origin", b"https://example.com"),) +SAMPLED_SENTRY_TRACE: typing.Final[Headers] = ( + (b"sentry-trace", b"771a43a4192642f0b136d5159a501700-1234567890abcdef-1"), +) diff --git a/benchmarks/frameworks/fastapi_suite.py b/benchmarks/frameworks/fastapi_suite.py index a7f6a23..3ff5210 100644 --- a/benchmarks/frameworks/fastapi_suite.py +++ b/benchmarks/frameworks/fastapi_suite.py @@ -10,6 +10,7 @@ OTEL, OTEL_METRICS, PROM, + SAMPLED_SENTRY_TRACE, ASGIApp, Cleanup, Op, @@ -22,6 +23,8 @@ start_lifespan, ) from fastapi import FastAPI +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig @@ -44,6 +47,16 @@ "sentry": Scenario(config=base_config(**sentry())), "sentry_nosess": Scenario(config=base_config(**sentry(), sentry_auto_session_tracking=False)), "sentry_noauto": Scenario(config=base_config(**sentry(auto_enabling_integrations=False))), + "sentry_nointegration": Scenario( + config=base_config( + **sentry(disabled_integrations=[StarletteIntegration, FastApiIntegration]), sentry_lean_error_capture=False + ) + ), + "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 + ), "health": Scenario(config=base_config(**HEALTH), path="/health/"), "health_otel": Scenario(config=base_config(**HEALTH, **OTEL), path="/health/"), "health_otel_nospan": Scenario( diff --git a/benchmarks/frameworks/fastmcp_suite.py b/benchmarks/frameworks/fastmcp_suite.py index ee779aa..3a7ede6 100644 --- a/benchmarks/frameworks/fastmcp_suite.py +++ b/benchmarks/frameworks/fastmcp_suite.py @@ -12,6 +12,7 @@ LOG, OTEL, PROM, + SAMPLED_SENTRY_TRACE, Cleanup, Op, Scenario, @@ -25,6 +26,7 @@ from fastmcp import FastMCP from opentelemetry.trace import NoOpTracer, Tracer from sentry_sdk.integrations.mcp import MCPIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration from lite_bootstrap import FastMcpBootstrapper, FastMcpConfig @@ -49,6 +51,14 @@ def no_fastmcp_spans() -> None: "sentry": Scenario(config=base_config(**sentry())), "sentry_nomcp": Scenario(config=base_config(**sentry(disabled_integrations=[MCPIntegration]))), "sentry_noauto": Scenario(config=base_config(**sentry(auto_enabling_integrations=False))), + "sentry_nointegration": Scenario( + config=base_config(**sentry(disabled_integrations=[StarletteIntegration]), sentry_lean_error_capture=False) + ), + "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 + ), "full": Scenario(config=base_config(**OTEL, **PROM, **LOG, **sentry(), health_checks_enabled=True)), } @@ -75,5 +85,5 @@ def add(a: int, b: int) -> int: application = FastMcpBootstrapper(FastMcpConfig(application=mcp, **scenario.config)).bootstrap() http_app = application.http_app(stateless_http=True, json_response=True) stop = await start_lifespan(http_app) - scope = http_scope("/mcp", method="POST", extra_headers=HEADERS) + scope = http_scope("/mcp", method="POST", extra_headers=(*HEADERS, *scenario.headers)) return asgi_op(http_app, scope, body=CALL_BODY), stop diff --git a/benchmarks/frameworks/litestar_suite.py b/benchmarks/frameworks/litestar_suite.py index c0b90a8..722b4d9 100644 --- a/benchmarks/frameworks/litestar_suite.py +++ b/benchmarks/frameworks/litestar_suite.py @@ -16,6 +16,7 @@ OTEL, OTEL_METRICS, PROM, + SAMPLED_SENTRY_TRACE, ASGIApp, Cleanup, Op, @@ -38,7 +39,6 @@ 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,7 +63,9 @@ "sentry_nosess": Scenario(config=base_config(**sentry(), sentry_auto_session_tracking=False)), "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_nointegration": Scenario( + config=base_config(**sentry(disabled_integrations=[LitestarIntegration]), sentry_lean_error_capture=False) + ), "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( diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index e5a57be..1ef5331 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -47,6 +47,64 @@ application = bootstrapper.bootstrap() Read more about available configuration options [here](../introduction/configuration.md). +## Sentry + +While Sentry tracing is off, lite-bootstrap reports request errors itself instead of through +sentry-sdk's `StarletteIntegration` and `FastApiIntegration`. 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 integrations +still build a transaction for every request and then discard it +([getsentry/sentry-python#7400](https://github.com/getsentry/sentry-python/issues/7400)). + +`sentry_lean_error_capture` picks the path: + +- `None`, the default: lean capture while tracing is off and `sentry_integrations` holds neither + integration, the integrations otherwise. +- `True`: lean capture always. lite-bootstrap warns when tracing is on, since no request starts a + transaction, and when `sentry_integrations` holds either integration, which gets dropped. +- `False`: the integrations always. + +Lean capture passes both integrations in `disabled_integrations` and adds one pure-ASGI middleware. +It gives each HTTP and WebSocket 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 integrations read. Method, URL, query string, headers and cookies are filtered by +their denylists. The event's transaction is the route template, such as `/items/{item_id}`. The +client address and the user that Starlette's `AuthenticationMiddleware` sets are attached only +when `user_info` is on. A body is attached only when `http_bodies` includes `"incoming_request"` +and the handler has read all of it: lean capture copies what the application reads and never +reads the stream itself. JSON and URL-encoded form bodies are attached parsed, a multipart body is +replaced by a placeholder, and any other body is attached as text. On sentry-sdk 2.x the +integrations read the body before the handler runs. + +What is reported matches the integrations. An exception that escapes the application is reported. +An exception that an exception handler answers is reported only when it carries a `status_code` +from 500 to 599, as `HTTPException(503)` does, whatever the handler responds. So a 404 from +`HTTPException` is not reported, and neither is a custom exception your handler turns into a 503 +unless the exception itself has a 5xx `status_code`. To find those handlers, lean capture looks +through the middleware below it for Starlette's `ExceptionMiddleware`. A middleware added before +bootstrap that keeps its inner application anywhere but `.app` hides it, and then only escaping +exceptions are reported. + +uvicorn and Granian log an exception that escapes the application. sentry-sdk's dedupe integration +drops that log event, because it carries the same exception, so each failing request sends one +event either way. + +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 | +6.1 µs | +5.1 µs | +| the same, request carries a sampled `sentry-trace` | +11.5 µs | +10.9 µs | +| `sentry_lean_error_capture=False` | +57.1 µs | +47.0 µs | +| the same, request carries a sampled `sentry-trace` | +59.2 µs | +113.2 µ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. + ## Logging Structlog is configured process-wide, so `structlog.get_logger()` works in any route handler. diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 8aa0539..162f8ad 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -64,6 +64,28 @@ nanoseconds. A message that raises is logged at exception level and the exceptio Set `health_checks_enabled=False` to omit the health route. +## Sentry + +While Sentry tracing is off, lite-bootstrap reports errors in every application +`application.http_app()` builds itself, instead of through sentry-sdk's `StarletteIntegration`. +FastMCP's HTTP application is a Starlette application, so it works as it does +[on FastAPI](fastapi.md#sentry): the same `sentry_lean_error_capture` rule, the same middleware and +the same request data. `MCPIntegration` is left alone. + +A tool that raises does not fail the request: FastMCP answers with an error result and logs the +exception, and the logging integration turns that record into one event with the `/mcp` request +attached, with lean capture on or off. + +Measured in-process per `tools/call` over stateless JSON HTTP, over the same app without Sentry: + +| Sentry setup | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| lean capture, the default | +20.6 µs | +15.9 µs | +| `sentry_lean_error_capture=False` | +82.2 µs | +65.0 µs | +| the same, request carries a sampled `sentry-trace` | +82.3 µs | +216.4 µs | + +A tool call costs about 620 µs before Sentry, and rows differ by up to 15% between runs. + ## Metrics With the `fastmcp-metrics` extra installed, the registry is served at `prometheus_metrics_path`, diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 10658d5..f037a4f 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -22,12 +22,13 @@ 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). +- `sentry_lean_error_capture` - Litestar, FastAPI and FastMCP: report request errors without sentry-sdk's framework integration (default: `None`, meaning on while Sentry tracing is off). See [Litestar](../integrations/litestar.md#sentry), [FastAPI](../integrations/fastapi.md#sentry) and [FastMCP](../integrations/fastmcp.md#sentry). Read more about sentry_sdk params [here](https://docs.sentry.io/platforms/python/configuration/options/). -Sentry is the second most expensive instrument in the stack. Its cost comes almost entirely from the ASGI -integration; see [Performance](performance.md). +Before 2.0, Sentry was the second most expensive instrument in the stack, and its cost came almost +entirely from sentry-sdk's framework integration. Since 2.0, lite-bootstrap replaces that integration on +Litestar, FastAPI and FastMCP while Sentry tracing is off; see [Performance](performance.md). ### Sentry logging integration diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index 27c2afc..f4a2674 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -39,7 +39,10 @@ Four things here are worth knowing before you tune anything: `attach_stacktrace`, `max_breadcrumbs` and dropping the default integrations all measure inside noise. Essentially the entire cost is the ASGI integration, and over half of that is a `Transaction` -built and discarded because `sentry_traces_sample_rate` is unset. +built and discarded because `sentry_traces_sample_rate` is unset. That table configures +sentry-sdk directly. Since 2.0, lite-bootstrap leaves the framework integration out while tracing +is off and reports request errors itself, which costs about 6 µs per request on FastAPI +([FastAPI: Sentry](../integrations/fastapi.md#sentry)). ## If you are RPS-constrained, start here @@ -65,13 +68,15 @@ request, every request then pays the per-record cost measured here: --8<-- "benchmarks/README.md:logging" -lite-bootstrap already applies three savings for you. It turns Sentry Logs off in the logging +lite-bootstrap already applies four savings for you. It turns Sentry Logs off in the logging integration, because it never enables them and on 2.x the handler formats every record before checking whether they are enabled; that one costs nothing. Since 2.0 it also leaves release-health sessions off and starts breadcrumbs at `WARNING`, so an `INFO` record skips the breadcrumb handler. Those two cost you release health and `INFO` breadcrumbs on error events; set `sentry_auto_session_tracking=True` or `sentry_logging_breadcrumb_level=logging.INFO` to get them -back. The numbers on this page were measured before 2.0, against the old defaults. +back. And while Sentry tracing is off it reports request errors without sentry-sdk's framework +integration, which costs you request transactions and bodies the handler never read; set `sentry_lean_error_capture=False` to keep the integration. The numbers on this page were +measured before 2.0, against the old defaults. ## What each saving costs you @@ -92,7 +97,8 @@ The same instruments one at a time, in-process, in the Linux container: --8<-- "benchmarks/README.md:fastapilinux" The ordering matches macOS: OpenTelemetry first, then Sentry, then Prometheus, with logging free -until something logs. +until something logs. Since 2.0, Sentry with tracing off drops from about +59 to about +6 µs per +request, below Prometheus ([FastAPI: Sentry](../integrations/fastapi.md#sentry)). ## Litestar @@ -131,7 +137,8 @@ Per tool call, through the stateless JSON HTTP app: --8<-- "benchmarks/README.md:fastmcp" The baseline is large, so the instruments are a smaller share here than on the HTTP frameworks. -OpenTelemetry is still the largest item. +OpenTelemetry is still the largest item. Since 2.0, Sentry with tracing off drops from about +82 to +about +21 µs per tool call ([FastMCP: Sentry](../integrations/fastmcp.md#sentry)). ## Unsampled OpenTelemetry is not free diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index c55b6c3..99de095 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -19,7 +19,12 @@ from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument -from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument +from lite_bootstrap.instruments.sentry_instrument import ( + SentryInstrument, + SentryLeanCaptureConfig, + disable_sentry_integrations, + warn_about_sentry_integrations_installed_earlier, +) from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument from lite_bootstrap.types import UNSET, UnsetType @@ -32,6 +37,12 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.routing import _merge_lifespan_context +if import_checker.is_fastapi_installed and import_checker.is_sentry_installed: + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.starlette import StarletteIntegration + + from lite_bootstrap.helpers.starlette_sentry import StarletteSentryCaptureMiddleware + if import_checker.is_structlog_installed: import structlog @@ -56,7 +67,7 @@ class FastAPIConfig( OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, - SentryConfig, + SentryLeanCaptureConfig, SwaggerConfig, ): application: "fastapi.FastAPI | UnsetType" = UNSET @@ -67,6 +78,10 @@ class FastAPIConfig( prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) prometheus_expose_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) logging_access_log_enabled: bool = False + _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = { + "starlette": "StarletteIntegration", + "fastapi": "FastApiIntegration", + } # Deprecated names, removed in 2.0. _renamed_fields: typing.ClassVar[Mapping[str, str]] = { "prometheus_instrumentator_params": "prometheus_instrumentator_additional_params", @@ -246,6 +261,22 @@ def bootstrap(self) -> None: ) +@dataclasses.dataclass(kw_only=True, slots=True) +class FastAPISentryInstrument(SentryInstrument[FastAPIConfig]): + def _build_init_params(self) -> dict[str, typing.Any]: + init_params: typing.Final = super(FastAPISentryInstrument, self)._build_init_params() + if self.bootstrap_config.is_sentry_lean_error_capture_on(): + disable_sentry_integrations(init_params, [StarletteIntegration, FastApiIntegration]) + return init_params + + def bootstrap(self) -> None: + super(FastAPISentryInstrument, self).bootstrap() + if not self.bootstrap_config.is_sentry_lean_error_capture_on(): + return + warn_about_sentry_integrations_installed_earlier([StarletteIntegration, FastApiIntegration]) + self.bootstrap_config.app.add_middleware(StarletteSentryCaptureMiddleware) + + @dataclasses.dataclass(kw_only=True) class FastAPISwaggerInstrument(SwaggerInstrument[FastAPIConfig]): def bootstrap(self) -> None: @@ -264,7 +295,7 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]): FastAPICorsInstrument, FastAPIOpenTelemetryInstrument, PyroscopeInstrument, - SentryInstrument, + FastAPISentryInstrument, FastAPIHealthChecksInstrument, FastAPILoggingInstrument, FastAPIPrometheusInstrument, diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index bb26672..98d3448 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -14,7 +14,12 @@ from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument -from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument +from lite_bootstrap.instruments.sentry_instrument import ( + SentryInstrument, + SentryLeanCaptureConfig, + disable_sentry_integrations, + warn_about_sentry_integrations_installed_earlier, +) from lite_bootstrap.types import UNSET, UnsetType @@ -35,6 +40,11 @@ from starlette.routing import BaseRoute, Match, Mount, Route from starlette.types import Scope +if import_checker.is_fastmcp_installed and import_checker.is_sentry_installed: + from sentry_sdk.integrations.starlette import StarletteIntegration + + from lite_bootstrap.helpers.starlette_sentry import StarletteSentryCaptureMiddleware + if import_checker.is_structlog_installed: import structlog @@ -191,13 +201,14 @@ async def on_message( @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FastMcpConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig + HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryLeanCaptureConfig ): application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) logging_access_log_enabled: bool = False fastmcp_prometheus_tool_metrics_enabled: bool = True prometheus_instrumentator_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = {"starlette": "StarletteIntegration"} # Deprecated names, removed in 2.0. _renamed_fields: typing.ClassVar[Mapping[str, str]] = { "fastmcp_logging_middleware_enabled": "logging_access_log_enabled", @@ -333,6 +344,41 @@ def teardown(self) -> None: self._restore_http_app = None +@dataclasses.dataclass(kw_only=True) +class FastMcpSentryInstrument(SentryInstrument[FastMcpConfig]): + _restore_http_app: typing.Callable[[], None] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) + + def _build_init_params(self) -> dict[str, typing.Any]: + init_params: typing.Final = super()._build_init_params() + if self.bootstrap_config.is_sentry_lean_error_capture_on(): + disable_sentry_integrations(init_params, [StarletteIntegration]) + return init_params + + @staticmethod + def _capture_errors_of_http_app(http_application: "StarletteWithLifespan") -> "StarletteWithLifespan": + http_application.add_middleware(StarletteSentryCaptureMiddleware) + return http_application + + def bootstrap(self) -> None: + super().bootstrap() + if not self.bootstrap_config.is_sentry_lean_error_capture_on(): + return + warn_about_sentry_integrations_installed_earlier([StarletteIntegration]) + self._restore_http_app = _postprocess_http_apps( + self.bootstrap_config.application, self._capture_errors_of_http_app + ) + + def teardown(self) -> None: + try: + super().teardown() + finally: + if self._restore_http_app is not None: + self._restore_http_app() + self._restore_http_app = None + + @dataclasses.dataclass(kw_only=True) class FastMcpLoggingInstrument(LoggingInstrument[FastMcpConfig]): def bootstrap(self) -> None: @@ -348,7 +394,7 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]): instruments_types: typing.ClassVar = [ FastMcpOpenTelemetryInstrument, PyroscopeInstrument, - SentryInstrument, + FastMcpSentryInstrument, FastMcpHealthChecksInstrument, FastMcpLoggingInstrument, FastMcpPrometheusInstrument, diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 2f58793..8bd0115 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -23,7 +23,15 @@ PrometheusInstrument, ) from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument -from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument +from lite_bootstrap.instruments.sentry_instrument import ( + SENTRY_FAILED_REQUEST_STATUS_CODES, + SentryInstrument, + SentryLeanCaptureConfig, + capture_request_exception, + disable_sentry_integrations, + read_sentry_trace_headers, + warn_about_sentry_integrations_installed_earlier, +) from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument from lite_bootstrap.types import UNSET, UnsetType @@ -70,9 +78,7 @@ 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 + from sentry_sdk.utils import transaction_from_function # 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) @@ -134,9 +140,7 @@ 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) @@ -183,11 +187,12 @@ def enrich_sentry_event_from_litestar_scope(event: "sentry_types.Event", scope: 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 + capture_request_exception( + exception, + mechanism_type=LitestarIntegration.identifier, + handled=False, + enrich=lambda event: enrich_sentry_event_from_litestar_scope(event, scope), ) - 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: @@ -201,7 +206,7 @@ def capture_litestar_exception_after_handling(exception: Exception, scope: "Scop if pending is not None: pending.exception = exception return - if isinstance(exception, HTTPException) and exception.status_code not in _SENTRY_FAILED_REQUEST_STATUS_CODES: + if isinstance(exception, HTTPException) and exception.status_code not in SENTRY_FAILED_REQUEST_STATUS_CODES: return _capture_litestar_exception(exception, scope) @@ -225,7 +230,7 @@ async def handle( 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: + if exception is not None and message["status"] in SENTRY_FAILED_REQUEST_STATUS_CODES: _capture_litestar_exception(exception, scope) await send(message) @@ -234,13 +239,7 @@ async def send_and_capture(message: "Message") -> None: 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.generate_propagation_context(read_sentry_trace_headers(scope["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) @@ -258,7 +257,7 @@ class LitestarConfig( OpenTelemetryConfig, PrometheusBootstrapperConfig, PyroscopeConfig, - SentryConfig, + SentryLeanCaptureConfig, SwaggerConfig, ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 @@ -268,9 +267,9 @@ 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. + _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = {"litestar": "LitestarIntegration"} _renamed_fields: typing.ClassVar[Mapping[str, str]] = { "litestar_logging_middleware_enabled": "logging_access_log_enabled", "swagger_extra_params": "swagger_additional_params", @@ -283,34 +282,12 @@ 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]): @@ -464,18 +441,14 @@ 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] + disable_sentry_integrations(init_params, [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." - ) + warn_about_sentry_integrations_installed_earlier([LitestarIntegration]) # 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 diff --git a/lite_bootstrap/helpers/starlette_sentry.py b/lite_bootstrap/helpers/starlette_sentry.py new file mode 100644 index 0000000..5cd16f7 --- /dev/null +++ b/lite_bootstrap/helpers/starlette_sentry.py @@ -0,0 +1,160 @@ +import contextlib +import dataclasses +import inspect +import json +import typing +import urllib.parse + +from lite_bootstrap import import_checker +from lite_bootstrap.instruments.sentry_instrument import ( + SENTRY_FAILED_REQUEST_STATUS_CODES, + capture_request_exception, + read_sentry_trace_headers, +) + + +if typing.TYPE_CHECKING: + from sentry_sdk import _types as sentry_types + from starlette.types import ASGIApp, Message, Receive, Scope, Send + +if import_checker.is_starlette_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._asgi_common import _get_request_data, _RootPathInPath + from sentry_sdk.integrations.starlette import StarletteIntegration + from sentry_sdk.utils import AnnotatedValue + from starlette.concurrency import run_in_threadpool + from starlette.datastructures import Headers + from starlette.middleware.exceptions import ExceptionMiddleware + from starlette.requests import HTTPConnection + + +_CAPTURED_SCOPE_TYPES: typing.Final = frozenset({"http", "websocket"}) +_USER_ATTRIBUTES: typing.Final = ("username", "id", "email") + + +@dataclasses.dataclass(slots=True) +class _ReceivedBody: + chunks: list[bytes] = dataclasses.field(default_factory=list) + complete: bool = False + + def recording(self, receive: "Receive") -> "Receive": + async def receive_and_record() -> "Message": + message: typing.Final = await receive() + if message["type"] == "http.request": + self.chunks.append(message.get("body", b"")) + self.complete = not message.get("more_body", False) + return message + + return receive_and_record + + def parse(self, scope: "Scope") -> typing.Any: # noqa: ANN401 + """Return the body the application read in full, parsed as its content type says.""" + body: typing.Final = b"".join(self.chunks) + if not (self.complete and body): + return None + media_type: typing.Final = Headers(scope=scope).get("content-type", "").partition(";")[0].strip().lower() + if media_type == "application/json" or media_type.endswith("+json"): + with contextlib.suppress(ValueError): + return json.loads(body) + if media_type == "application/x-www-form-urlencoded": + return dict(urllib.parse.parse_qsl(body.decode("latin-1"), keep_blank_values=True)) + if media_type.startswith("multipart/"): + return AnnotatedValue.removed_because_raw_data() + return body.decode("utf-8", errors="replace") + + +def _read_user(scope: "Scope") -> dict[str, typing.Any]: + user: typing.Final = scope.get("user") + return {name: value for name in _USER_ATTRIBUTES if (value := getattr(user, name, None))} + + +def enrich_sentry_event_from_starlette_scope( + event: "sentry_types.Event", scope: "Scope", received_body: _ReceivedBody | None +) -> "sentry_types.Event": + data_collection: typing.Final = sentry_sdk.get_client().options["data_collection"] + request_data: typing.Final = _get_request_data(scope, root_path_in_path=_RootPathInPath.EITHER) + if cookies := _apply_key_value_collection_filtering( + items=HTTPConnection(scope).cookies, behaviour=data_collection["cookies"] + ): + request_data["cookies"] = cookies + if received_body is not None and (body := received_body.parse(scope)) is not None: + request_data["data"] = body + 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 + + +def _is_async_callable(handler: typing.Callable[..., typing.Any]) -> bool: + return inspect.iscoroutinefunction(handler) or ( + callable(handler) and inspect.iscoroutinefunction(type(handler).__call__) + ) + + +def _reporting_server_errors(handler: typing.Callable[..., typing.Any]) -> typing.Callable[..., typing.Any]: + is_async: typing.Final = _is_async_callable(handler) + + async def report_then_handle(connection: "HTTPConnection", exception: Exception) -> typing.Any: # noqa: ANN401 + status_code: typing.Final = getattr(exception, "status_code", None) + if isinstance(status_code, int) and status_code in SENTRY_FAILED_REQUEST_STATUS_CODES: + capture_request_exception(exception, mechanism_type=StarletteIntegration.identifier, handled=True) + if is_async: + return await handler(connection, exception) + return await run_in_threadpool(handler, connection, exception) + + return report_then_handle + + +def _report_handled_server_errors(application: "ASGIApp") -> None: + """Report exceptions that ExceptionMiddleware turns into a 5xx response, as ``StarletteIntegration`` does. + + Starlette builds its middleware stack inside out on the first request, so the ``ExceptionMiddleware`` + below this one already holds every handler the application registered, including those added after + bootstrap. + """ + inner: ASGIApp | None = application + while inner is not None and not isinstance(inner, ExceptionMiddleware): + inner = getattr(inner, "app", None) + if inner is None: + return + handlers: typing.Final = inner._exception_handlers # noqa: SLF001 + for exception_class, handler in handlers.items(): + handlers[exception_class] = _reporting_server_errors(handler) + + +class StarletteSentryCaptureMiddleware: + """Report request errors to Sentry without ``StarletteIntegration``, pure ASGI.""" + + def __init__(self, app: "ASGIApp") -> None: + self.app = app + _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 + + received_body: _ReceivedBody | None = None + if "incoming_request" in sentry_sdk.get_client().options["data_collection"]["http_bodies"]: + received_body = _ReceivedBody() + receive = received_body.recording(receive) + + 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(read_sentry_trace_headers(scope["headers"])) + sentry_scope.add_event_processor( + lambda event, _: enrich_sentry_event_from_starlette_scope(event, scope, received_body) + ) + try: + await self.app(scope, receive, send) + except Exception as exception: + capture_request_exception(exception, mechanism_type=StarletteIntegration.identifier, handled=False) + raise diff --git a/lite_bootstrap/import_checker.py b/lite_bootstrap/import_checker.py index fcf299e..477e9c8 100644 --- a/lite_bootstrap/import_checker.py +++ b/lite_bootstrap/import_checker.py @@ -24,6 +24,7 @@ def _safe_find_spec(module_name: str) -> bool: is_structlog_installed = find_spec("structlog") is not None is_prometheus_client_installed = find_spec("prometheus_client") is not None is_fastapi_installed = find_spec("fastapi") is not None +is_starlette_installed = find_spec("starlette") is not None is_litestar_installed = find_spec("litestar") is not None is_faststream_installed = find_spec("faststream") is not None is_prometheus_fastapi_instrumentator_installed = find_spec("prometheus_fastapi_instrumentator") is not None diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 9f8bc22..d9e4557 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -1,6 +1,7 @@ import dataclasses import logging import typing +from collections.abc import Mapping from lite_bootstrap import import_checker from lite_bootstrap.helpers.warn import deprecated_module_getattr, warn_at_caller @@ -16,6 +17,7 @@ if import_checker.is_sentry_installed: import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration + from sentry_sdk.utils import event_from_exception IS_SENTRY_SDK_2: typing.Final = sentry_sdk.VERSION.startswith("2.") @@ -65,6 +67,78 @@ def is_sentry_tracing_requested(self) -> bool: ) +@dataclasses.dataclass(kw_only=True, frozen=True) +class SentryLeanCaptureConfig(SentryConfig): + """A framework whose request errors lite-bootstrap can report without sentry-sdk's framework integration.""" + + sentry_lean_error_capture: bool | None = None + _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = {} + + def __post_init__(self) -> None: + super().__post_init__() + if not self.sentry_lean_error_capture: + return + 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 supplied := self._supplied_sentry_lean_replaced_integrations(): + warn_at_caller( + f"sentry_lean_error_capture disables Sentry's {' and '.join(supplied)}, so the one in " + "sentry_integrations is dropped." + ) + + def _supplied_sentry_lean_replaced_integrations(self) -> list[str]: + return [ + self._sentry_lean_replaced_integrations[integration.identifier] + for integration in self.sentry_integrations + if integration.identifier in self._sentry_lean_replaced_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._supplied_sentry_lean_replaced_integrations() + + +SENTRY_FAILED_REQUEST_STATUS_CODES: typing.Final = range(500, 600) +_SENTRY_TRACE_HEADERS: typing.Final = frozenset({b"sentry-trace", b"baggage"}) + + +def read_sentry_trace_headers(headers: typing.Iterable[tuple[bytes, bytes]]) -> dict[str, str]: + return {name.decode("latin-1"): value.decode("latin-1") for name, value in headers if name in _SENTRY_TRACE_HEADERS} + + +def capture_request_exception( + exception: BaseException, + *, + mechanism_type: str, + handled: bool, + enrich: typing.Callable[["sentry_types.Event"], "sentry_types.Event"] = lambda event: event, +) -> 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": mechanism_type, "handled": handled}, **client_options + ) + sentry_sdk.capture_event(enrich(event), hint=hint) + + +def disable_sentry_integrations( + init_params: dict[str, typing.Any], integrations: typing.Sequence[type["Integration"]] +) -> None: + init_params["disabled_integrations"] = [*init_params.get("disabled_integrations", []), *integrations] + + +def warn_about_sentry_integrations_installed_earlier(integrations: typing.Sequence[type["Integration"]]) -> None: + client: typing.Final = sentry_sdk.get_client() + if installed := [integration.__name__ for integration in integrations if client.get_integration(integration)]: + warn_at_caller( + f"Sentry's {' and '.join(installed)} 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." + ) + + def enrich_sentry_event_from_structlog_log( event: "sentry_types.Event", _: "sentry_types.Hint" ) -> typing.Optional["sentry_types.Event"]: diff --git a/scripts/floor_smoke.py b/scripts/floor_smoke.py index e592d8e..f6241d3 100644 --- a/scripts/floor_smoke.py +++ b/scripts/floor_smoke.py @@ -17,6 +17,7 @@ """ import asyncio +import contextlib import sys import typing import warnings @@ -41,6 +42,13 @@ if import_checker.is_opentelemetry_installed: from opentelemetry import trace +if import_checker.is_fastapi_installed: + import fastapi + +if import_checker.is_fastmcp_installed: + from starlette.requests import Request + from starlette.responses import Response + if import_checker.is_litestar_installed: import litestar from litestar.config.app import AppConfig @@ -93,6 +101,37 @@ async def send(message: dict) -> None: await application({"type": "lifespan", "state": {}}, receive, send) +async def _request_status(application: typing.Any, path: str) -> int: # noqa: ANN401 + """Send one GET through an ASGI application without a test client, which would need httpx.""" + statuses: list[int] = [] + + async def receive() -> dict: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict) -> None: + if message["type"] == "http.response.start": + statuses.append(message["status"]) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "root_path": "", + "query_string": b"", + "headers": [(b"host", b"floor-smoke")], + "client": ("127.0.0.1", 1), + "server": ("floor-smoke", 80), + "state": {}, + } + with contextlib.suppress(RuntimeError): + await application(scope, receive, send) + return statuses[0] + + def _free() -> None: bootstrapper = FreeBootstrapper( bootstrap_config=FreeConfig( @@ -118,7 +157,33 @@ def _free() -> None: bootstrapper.teardown() +def _fastapi_lean_sentry_capture() -> None: + """Send one failing request through lean error capture, whose scope and session calls have floors too.""" + application = fastapi.FastAPI() + + @application.get("/floor-error") + async def floor_error() -> None: + message = "floor smoke" + raise RuntimeError(message) + + bootstrapper = FastAPIBootstrapper( + bootstrap_config=FastAPIConfig( + service_name="floor-smoke", + service_version="1.0.0", + logging_buffer_capacity=0, + sentry_dsn=SENTRY_DSN, + sentry_additional_params=SENTRY_PARAMS, + application=application, + ) + ) + expected_status = 500 + assert asyncio.run(_request_status(bootstrapper.bootstrap(), "/floor-error")) == expected_status + bootstrapper.teardown() + + def _fastapi() -> None: + if import_checker.is_sentry_installed: + _fastapi_lean_sentry_capture() bootstrapper = FastAPIBootstrapper( bootstrap_config=FastAPIConfig( service_name="floor-smoke", @@ -219,7 +284,32 @@ def _faststream() -> None: bootstrapper.teardown() +def _fastmcp_lean_sentry_capture() -> None: + """Send one failing request through lean error capture, whose scope and session calls have floors too.""" + bootstrapper = FastMcpBootstrapper( + bootstrap_config=FastMcpConfig( + service_name="floor-smoke", + service_version="1.0.0", + logging_buffer_capacity=0, + sentry_dsn=SENTRY_DSN, + sentry_additional_params=SENTRY_PARAMS, + ) + ) + application = bootstrapper.bootstrap() + + async def floor_error(_: "Request") -> "Response": + message = "floor smoke" + raise RuntimeError(message) + + application.custom_route("/floor-error", methods=["GET"])(floor_error) + expected_status = 500 + assert asyncio.run(_request_status(application.http_app(), "/floor-error")) == expected_status + bootstrapper.teardown() + + def _fastmcp() -> None: + if import_checker.is_sentry_installed: + _fastmcp_lean_sentry_capture() bootstrapper = FastMcpBootstrapper( bootstrap_config=FastMcpConfig( service_name="floor-smoke", diff --git a/tests/conftest.py b/tests/conftest.py index 989b116..11dcc4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,13 @@ import sentry_sdk from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.util._once import Once +from sentry_sdk import integrations as sentry_integrations +from sentry_sdk import scope as sentry_scope from sentry_sdk.envelope import Envelope +from sentry_sdk.integrations import Integration +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.litestar import LitestarIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration from structlog.typing import EventDict, WrappedLogger from lite_bootstrap import import_checker @@ -97,6 +103,59 @@ def _reset_otel_providers() -> typing.Iterator[None]: opentelemetry.metrics._internal._METER_PROVIDER_SET_ONCE = Once() # noqa: SLF001 +_LEAN_CAPTURE_REPLACED_INTEGRATIONS: typing.Final[tuple[type[Integration], ...]] = ( + LitestarIntegration, + StarletteIntegration, + FastApiIntegration, +) +_integrations_set_up: set[str] = set() + + +@pytest.fixture(autouse=True) +def _lean_capture_replaced_integrations_not_yet_installed(monkeypatch: pytest.MonkeyPatch) -> typing.Iterator[None]: + """Start each test as a fresh process would, with sentry-sdk's framework integrations 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 a framework + importable installs its integration, because they are all auto-enabling. + + An integration's patches cannot be undone, and ``setup_once`` on these integrations wraps the framework + again on every call, which stacked over a hundred layers on ``Starlette.__call__`` across the suite. So + once it has run it is replaced by a no-op: the registries say "not installed" while the patches from + the first run stay in place. + """ + identifiers: typing.Final = {integration.identifier for integration in _LEAN_CAPTURE_REPLACED_INTEGRATIONS} + registries: typing.Final = ( + sentry_integrations._processed_integrations, # noqa: SLF001 + sentry_integrations._installed_integrations, # noqa: SLF001 + ) + _integrations_set_up.update(identifiers & sentry_integrations._installed_integrations) # noqa: SLF001 + for integration in _LEAN_CAPTURE_REPLACED_INTEGRATIONS: + if integration.identifier in _integrations_set_up: + monkeypatch.setattr(integration, "setup_once", staticmethod(lambda: None)) + saved: typing.Final = [registry & identifiers for registry in registries] + for registry in registries: + registry.difference_update(identifiers) + yield + _integrations_set_up.update(identifiers & sentry_integrations._installed_integrations) # noqa: SLF001 + for registry, was_there in zip(registries, saved, strict=True): + registry.difference_update(identifiers) + registry.update(was_there) + + +@pytest.fixture(autouse=True) +def _fresh_sentry_scopes() -> typing.Iterator[None]: + """Give each test the current and isolation scopes a fresh process has. + + Tests run in one context, so a transaction name an integration left on a scope reaches every later event. + """ + current_token: typing.Final = sentry_scope._current_scope.set(None) # noqa: SLF001 + isolation_token: typing.Final = sentry_scope._isolation_scope.set(None) # noqa: SLF001 + yield + sentry_scope._isolation_scope.reset(isolation_token) # noqa: SLF001 + sentry_scope._current_scope.reset(current_token) # noqa: SLF001 + + @pytest.fixture def sentry_mock() -> SentryTestTransport: return SentryTestTransport() diff --git a/tests/instruments/test_logging_record_filters.py b/tests/instruments/test_logging_record_filters.py index 64e7a11..7acd109 100644 --- a/tests/instruments/test_logging_record_filters.py +++ b/tests/instruments/test_logging_record_filters.py @@ -292,12 +292,18 @@ def test_teardown_removes_filters_even_when_a_handler_close_fails() -> None: target_logger = logging.getLogger("teardown_despite_error.target") class ExplodingHandler(logging.Handler): + exploded = False + def emit(self, record: logging.LogRecord) -> None: """Never called: this handler exists only to fail on close.""" def close(self) -> None: - msg = "boom" - raise RuntimeError(msg) + """Fail the first close only: a later logging.shutdown still reaches this handler until it is collected.""" + super().close() + if not self.exploded: + self.exploded = True + msg = "boom" + raise RuntimeError(msg) instrument = LoggingInstrument( bootstrap_config=LoggingConfig( @@ -309,8 +315,11 @@ def close(self) -> None: exploding_handler = ExplodingHandler() logging.getLogger().addHandler(exploding_handler) - with pytest.raises(TeardownError): - instrument.teardown() + try: + with pytest.raises(TeardownError): + instrument.teardown() + finally: + logging.getLogger().removeHandler(exploding_handler) assert target_logger.filters == [] diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index ebf75a6..2b25267 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -31,6 +31,7 @@ SentryTestTransport, capture_probe_warnings, emulate_package_missing, + emulate_package_missing_with_module_reload, failed_probe_names, warning_source_files, ) @@ -162,6 +163,17 @@ def test_fastapi_bootstrapper_with_missing_instrument_dependency( FastAPIBootstrapper(bootstrap_config=fastapi_config) +def test_fastapi_bootstrapper_runs_without_sentry_sdk(fastapi_config: FastAPIConfig) -> None: + with emulate_package_missing_with_module_reload( + "sentry_sdk", + ["lite_bootstrap.instruments.sentry_instrument", "lite_bootstrap.bootstrappers.fastapi_bootstrapper"], + ): + with pytest.warns(UserWarning, match="sentry_sdk"): + bootstrapper = fastapi_bootstrapper.FastAPIBootstrapper(bootstrap_config=fastapi_config) + bootstrapper.bootstrap() + bootstrapper.teardown() + + def test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks(fastapi_config: FastAPIConfig) -> None: application = fastapi.FastAPI() config_a = dataclasses.replace(fastapi_config, application=application) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 573f452..32293f9 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -297,6 +297,19 @@ def test_fastmcp_bootstrapper_with_missing_instrument_dependency( FastMcpBootstrapper(bootstrap_config=FastMcpConfig(**extra_config)) +def test_fastmcp_bootstrapper_runs_without_sentry_sdk() -> None: + with emulate_package_missing_with_module_reload( + "sentry_sdk", + ["lite_bootstrap.instruments.sentry_instrument", "lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], + ): + with pytest.warns(UserWarning, match="sentry_sdk"): + bootstrapper = fastmcp_bootstrapper.FastMcpBootstrapper( + bootstrap_config=fastmcp_bootstrapper.FastMcpConfig(sentry_dsn="https://testdsn@localhost/1") + ) + bootstrapper.bootstrap() + bootstrapper.teardown() + + def test_fastmcp_bootstrap_without_prometheus_client() -> None: # Regression guard mirroring the FastStream prometheus-missing test: ensures # FastMcpPrometheusInstrument.dependencies_installed() prevents construction-time diff --git a/tests/test_litestar_sentry_capture.py b/tests/test_litestar_sentry_capture.py index 76cd62c..69a7eb0 100644 --- a/tests/test_litestar_sentry_capture.py +++ b/tests/test_litestar_sentry_capture.py @@ -1,7 +1,7 @@ import logging import typing import warnings -from collections.abc import Iterator, Mapping +from collections.abc import Mapping from unittest.mock import AsyncMock import litestar @@ -29,27 +29,6 @@ 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 diff --git a/tests/test_starlette_sentry_capture.py b/tests/test_starlette_sentry_capture.py new file mode 100644 index 0000000..aca8b8c --- /dev/null +++ b/tests/test_starlette_sentry_capture.py @@ -0,0 +1,695 @@ +import contextlib +import functools +import logging +import socket +import threading +import typing +import warnings +from collections.abc import Generator, Mapping +from unittest.mock import AsyncMock + +import fastapi +import httpx +import pytest +import sentry_sdk +import uvicorn +from fastmcp import FastMCP +from sentry_sdk import integrations as sentry_integrations +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.httpx import HttpxIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration +from starlette import status +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.testclient import TestClient +from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.websockets import WebSocket + +from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, FastMcpBootstrapper, FastMcpConfig +from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPISentryInstrument +from lite_bootstrap.helpers.starlette_sentry import _reporting_server_errors +from lite_bootstrap.instruments.sentry_instrument import DEFAULT_DATA_COLLECTION +from tests.conftest import SentryTestTransport + + +std_logger = logging.getLogger(__name__) +_REPLACED_IDENTIFIERS: typing.Final = (StarletteIntegration.identifier, FastApiIntegration.identifier) + + +class MappedToUnavailableError(Exception): + pass + + +class CarriesUnavailableStatusError(Exception): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + +class RegisteredAfterBootstrapError(Exception): + status_code = status.HTTP_502_BAD_GATEWAY + + +class _AliceBackend(AuthenticationBackend): + async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, SimpleUser]: # noqa: ARG002 + return AuthCredentials(["authenticated"]), SimpleUser("alice") + + +class _HidesItsInnerApp: + def __init__(self, app: ASGIApp) -> None: + self._inner_app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self._inner_app(scope, receive, send) + + +router = fastapi.APIRouter() + + +@router.get("/boom") +async def boom() -> None: + message = "boom" + raise RuntimeError(message) + + +@router.get("/items/{item_id}") +async def item(item_id: int) -> None: + raise RuntimeError(str(item_id)) + + +@router.post("/read-json-then-boom") +async def read_json_then_boom(payload: typing.Annotated[dict[str, str], fastapi.Body()]) -> None: + raise RuntimeError(payload["message"]) + + +@router.post("/read-form-then-boom") +async def read_form_then_boom(message: typing.Annotated[str, fastapi.Form()]) -> None: + raise RuntimeError(message) + + +@router.post("/read-raw-body-then-boom") +async def read_raw_body_then_boom(request: Request) -> None: + raise RuntimeError((await request.body()).decode()) + + +@router.post("/boom-without-reading-body") +async def boom_without_reading_body() -> None: + message = "boom" + raise RuntimeError(message) + + +@router.get("/http-404") +async def http_404() -> None: + raise fastapi.HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +@router.get("/http-503") +async def http_503() -> None: + raise fastapi.HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + +@router.get("/mapped-503") +async def mapped_503() -> None: + raise MappedToUnavailableError + + +@router.get("/carries-503") +async def carries_503() -> None: + raise CarriesUnavailableStatusError + + +@router.get("/logs-then-ok") +async def logs_then_ok() -> str: + std_logger.warning("breadcrumb from an earlier request") + return "ok" + + +@router.get("/integrations") +async def integrations() -> list[str]: + return sorted(sentry_sdk.get_client().integrations) + + +@router.websocket("/ws") +async def websocket_boom(websocket: WebSocket) -> None: + await websocket.accept() + message = "websocket boom" + raise RuntimeError(message) + + +async def _respond_503(_: Request, __: Exception) -> Response: + return Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + +def _build_config(transport: SentryTestTransport, **overrides: typing.Any) -> FastAPIConfig: # noqa: ANN401 + sentry_additional_params = {"transport": transport, **overrides.pop("sentry_additional_params", {})} + application = fastapi.FastAPI( + exception_handlers={MappedToUnavailableError: _respond_503, CarriesUnavailableStatusError: _respond_503} + ) + application.include_router(router) + if overrides.pop("hide_inner_app", False): + application.add_middleware(_HidesItsInnerApp) + if overrides.pop("authenticated", False): + application.add_middleware(AuthenticationMiddleware, backend=_AliceBackend()) + return FastAPIConfig( + service_environment="test", + sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params=sentry_additional_params, + logging_buffer_capacity=0, + application=application, + **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 _bootstrap(transport: SentryTestTransport, **overrides: typing.Any) -> fastapi.FastAPI: # noqa: ANN401 + return FastAPIBootstrapper(bootstrap_config=_build_config(transport, **overrides)).bootstrap() + + +def _request(transport: SentryTestTransport, *paths: str, **overrides: typing.Any) -> list[int]: # noqa: ANN401 + with TestClient(app=_bootstrap(transport, **overrides), 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 + with TestClient(app=_bootstrap(transport, **overrides)) as test_client: + return test_client.get("/integrations").json() + + +def _sentry_init_kwargs(config: FastAPIConfig) -> Mapping[str, typing.Any]: + return FastAPISentryInstrument(bootstrap_config=config)._build_init_params() # noqa: SLF001 + + +def test_lean_capture_is_the_default_on_fastapi_while_tracing_is_off(sentry_mock: SentryTestTransport) -> None: + application = fastapi.FastAPI() + application.include_router(router) + bootstrapped = FastAPIBootstrapper( + bootstrap_config=FastAPIConfig( + sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": sentry_mock}, + logging_buffer_capacity=0, + application=application, + ) + ).bootstrap() + with TestClient(app=bootstrapped, raise_server_exceptions=False) as test_client: + active = test_client.get("/integrations").json() + test_client.get("/boom") + + assert not set(_REPLACED_IDENTIFIERS) & set(active) + [event] = _error_events(sentry_mock) + assert event["request"]["url"] == "http://testserver/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_starlette_integrations_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 set(_REPLACED_IDENTIFIERS) <= set(active) + + +def test_lean_capture_turned_off_keeps_the_starlette_integrations(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 set(_REPLACED_IDENTIFIERS) <= set(active) + + +def test_lean_capture_reports_unhandled_exception_with_request_data(sentry_mock: SentryTestTransport) -> None: + with TestClient(app=_bootstrap(sentry_mock), raise_server_exceptions=False) as test_client: + response = test_client.get("/items/7?page=2", headers={"x-real-ip": "10.0.0.1", "user-agent": "tests"}) + + assert response.status_code == status.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"]) == ("starlette", False) + assert event["request"]["method"] == "GET" + assert event["request"]["url"] == "http://testserver/items/7" + 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"] == "/items/{item_id}" + assert event["transaction_info"] == {"source": "route"} + + +def test_lean_capture_filters_cookies_through_data_collection(sentry_mock: SentryTestTransport) -> None: + with TestClient( + app=_bootstrap(sentry_mock), 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"}} + application = _bootstrap(sentry_mock, sentry_additional_params={"data_collection": data_collection}) + 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, {"username": "alice"}, id="user-info-on"), + ], +) +def test_lean_capture_attaches_the_authenticated_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}, authenticated=True) + + [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-json-then-boom", {"json": {"message": "boom"}}, None, id="bodies-off"), + pytest.param( + ["incoming_request"], + "/read-json-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"], + "/read-raw-body-then-boom", + {"content": b"{", "headers": {"content-type": "application/json"}}, + "{", + id="invalid-json-read-by-handler", + ), + pytest.param( + ["incoming_request"], + "/read-raw-body-then-boom", + {"files": {"upload": ("boom.txt", b"boom")}}, + "", + id="multipart-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} + application = _bootstrap(sentry_mock, sentry_additional_params={"data_collection": data_collection}) + 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 = _bootstrap(sentry_mock) + 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}) + + with contextlib.suppress(RuntimeError): + await application(scope, receive, AsyncMock()) + + [event] = _error_events(sentry_mock) + assert event["contexts"]["trace"]["trace_id"] == trace_id + + +def test_lean_capture_skips_client_http_exceptions(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/http-404", "/no-such-route") == [ + status.HTTP_404_NOT_FOUND, + status.HTTP_404_NOT_FOUND, + ] + assert _error_events(sentry_mock) == [] + + +def test_lean_capture_reports_server_http_exceptions_as_handled(sentry_mock: SentryTestTransport) -> None: + assert _request(sentry_mock, "/http-503") == [status.HTTP_503_SERVICE_UNAVAILABLE] + + [event] = _error_events(sentry_mock) + exception = event["exception"]["values"][-1] + assert exception["type"] == "HTTPException" + assert (exception["mechanism"]["type"], exception["mechanism"]["handled"]) == ("starlette", True) + assert event["request"]["url"] == "http://testserver/http-503" + + +def test_lean_capture_reports_a_handled_exception_by_its_status_code_not_the_response( + sentry_mock: SentryTestTransport, +) -> None: + assert _request(sentry_mock, "/mapped-503", "/carries-503") == [ + status.HTTP_503_SERVICE_UNAVAILABLE, + status.HTTP_503_SERVICE_UNAVAILABLE, + ] + assert _exception_types(sentry_mock) == ["CarriesUnavailableStatusError"] + + +def test_lean_capture_reports_handled_exceptions_of_a_handler_added_after_bootstrap( + sentry_mock: SentryTestTransport, +) -> None: + application = _bootstrap(sentry_mock) + application.add_exception_handler(RegisteredAfterBootstrapError, _respond_503) + + @application.get("/registered-after-bootstrap") + async def registered_after_bootstrap() -> None: + raise RegisteredAfterBootstrapError + + with TestClient(app=application, raise_server_exceptions=False) as test_client: + assert test_client.get("/registered-after-bootstrap").status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + assert _exception_types(sentry_mock) == ["RegisteredAfterBootstrapError"] + + +def test_lean_capture_reports_only_escaping_exceptions_when_an_inner_middleware_hides_its_app( + sentry_mock: SentryTestTransport, +) -> None: + assert _request(sentry_mock, "/http-503", "/boom", hide_inner_app=True) == [ + status.HTTP_503_SERVICE_UNAVAILABLE, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ] + assert _exception_types(sentry_mock) == ["RuntimeError"] + + +def test_lean_capture_reports_a_websocket_exception(sentry_mock: SentryTestTransport) -> None: + with ( + TestClient(app=_bootstrap(sentry_mock)) as test_client, + contextlib.ExitStack() as stack, + pytest.raises(RuntimeError, match="websocket boom"), + ): + stack.enter_context(test_client.websocket_connect("/ws")) + + [event] = _error_events(sentry_mock) + assert event["exception"]["values"][-1]["value"] == "websocket boom" + assert event["request"]["url"] == "ws://testserver/ws" + + +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 + + +def _free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +@contextlib.contextmanager +def _served_by_uvicorn(application: fastapi.FastAPI) -> Generator[str]: + port = _free_port() + server = uvicorn.Server(uvicorn.Config(application, host="127.0.0.1", port=port, lifespan="off")) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + try: + while not server.started: + thread.join(0.01) + yield f"http://127.0.0.1:{port}" + finally: + server.should_exit = True + thread.join() + + +@pytest.mark.parametrize("lean_error_capture", [True, False]) +def test_one_event_per_failing_request_when_uvicorn_logs_the_exception( + sentry_mock: SentryTestTransport, lean_error_capture: bool +) -> None: + application = _bootstrap(sentry_mock, sentry_lean_error_capture=lean_error_capture) + with _served_by_uvicorn(application) as base_url: + response = httpx.get(f"{base_url}/boom") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert _exception_types(sentry_mock) == ["RuntimeError"] + + +def test_lean_capture_replaces_the_starlette_integrations(sentry_mock: SentryTestTransport) -> None: + active = _active_integrations(sentry_mock, sentry_lean_error_capture=True) + + assert not set(_REPLACED_IDENTIFIERS) & set(active) + assert "logging" in active + + +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, + StarletteIntegration, + FastApiIntegration, + ] + + +def test_lean_capture_warns_when_an_earlier_init_installed_the_starlette_integration( + sentry_mock: SentryTestTransport, +) -> None: + sentry_integrations._installed_integrations.add(StarletteIntegration.identifier) # noqa: SLF001 + bootstrapper = FastAPIBootstrapper(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"] == [StarletteIntegration, FastApiIntegration] + + +@pytest.mark.parametrize("integration", [StarletteIntegration, FastApiIntegration]) +def test_default_keeps_a_supplied_starlette_integration( + sentry_mock: SentryTestTransport, integration: type[StarletteIntegration] +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + active = _active_integrations(sentry_mock, sentry_integrations=[integration()]) + + assert integration.identifier in active + + +def test_forced_lean_capture_warns_that_a_supplied_starlette_integration_is_dropped( + sentry_mock: SentryTestTransport, +) -> None: + with pytest.warns(UserWarning, match="FastApiIntegration"): + _build_config(sentry_mock, sentry_lean_error_capture=True, sentry_integrations=[FastApiIntegration()]) + + +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=[StarletteIntegration()], + ) + + +def _build_fastmcp_config(transport: SentryTestTransport, **overrides: typing.Any) -> FastMcpConfig: # noqa: ANN401 + application: FastMCP[typing.Any] = FastMCP() + + @application.custom_route("/boom", methods=["GET"]) + async def boom(_: Request) -> JSONResponse: + message = "boom" + raise RuntimeError(message) + + @application.tool + def add(a: int, b: int) -> int: + message = f"tool boom {a + b}" + raise RuntimeError(message) + + @application.custom_route("/integrations", methods=["GET"]) + async def integrations(_: Request) -> JSONResponse: + return JSONResponse(sorted(sentry_sdk.get_client().integrations)) + + return FastMcpConfig( + service_environment="test", + sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": transport}, + logging_buffer_capacity=0, + prometheus_metrics_path="", + application=application, + **overrides, + ) + + +def test_lean_capture_is_the_default_on_fastmcp_while_tracing_is_off(sentry_mock: SentryTestTransport) -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=_build_fastmcp_config(sentry_mock)) + application = bootstrapper.bootstrap() + with TestClient(application.http_app(), raise_server_exceptions=False) as test_client: + active = test_client.get("/integrations").json() + response = test_client.get("/boom?page=2") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert StarletteIntegration.identifier not in active + [event] = _error_events(sentry_mock) + assert event["request"]["url"] == "http://testserver/boom" + assert event["request"]["query_string"] == "page=2" + assert event["transaction"] == "/boom" + + +def test_fastmcp_lean_capture_teardown_restores_http_app(sentry_mock: SentryTestTransport) -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=_build_fastmcp_config(sentry_mock)) + application = bootstrapper.bootstrap() + assert "http_app" in vars(application) + + bootstrapper.teardown() + + assert "http_app" not in vars(application) + + +def test_fastmcp_lean_capture_turned_off_leaves_http_app_alone(sentry_mock: SentryTestTransport) -> None: + bootstrapper = FastMcpBootstrapper( + bootstrap_config=_build_fastmcp_config(sentry_mock, sentry_lean_error_capture=False) + ) + application = bootstrapper.bootstrap() + with TestClient(application.http_app()) as test_client: + active = test_client.get("/integrations").json() + bootstrapper.teardown() + + assert "http_app" not in vars(application) + assert StarletteIntegration.identifier in active + + +@pytest.mark.parametrize("lean_error_capture", [True, False]) +def test_one_event_per_failing_fastmcp_tool_call(sentry_mock: SentryTestTransport, lean_error_capture: bool) -> None: + bootstrapper = FastMcpBootstrapper( + bootstrap_config=_build_fastmcp_config(sentry_mock, sentry_lean_error_capture=lean_error_capture) + ) + application = bootstrapper.bootstrap() + with TestClient(application.http_app(stateless_http=True, json_response=True)) as test_client: + response = test_client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 1, "b": 2}}, + }, + headers={"accept": "application/json, text/event-stream"}, + ) + bootstrapper.teardown() + + assert response.json()["result"]["isError"] is True + [event] = _error_events(sentry_mock) + assert event["exception"]["values"][-1]["value"] == "tool boom 3" + assert event["request"]["url"] == "http://testserver/mcp" + + +async def _respond_with(status_code: int, _: Request, __: Exception) -> Response: + return Response(status_code=status_code) + + +def test_lean_capture_reports_through_a_partial_exception_handler(sentry_mock: SentryTestTransport) -> None: + config = _build_fastmcp_config(sentry_mock) + + @config.application.custom_route("/carries-503", methods=["GET"]) + async def carries_503(_: Request) -> JSONResponse: + raise CarriesUnavailableStatusError + + bootstrapper = FastMcpBootstrapper(bootstrap_config=config) + http_application = bootstrapper.bootstrap().http_app() + http_application.add_exception_handler( + CarriesUnavailableStatusError, functools.partial(_respond_with, status.HTTP_503_SERVICE_UNAVAILABLE) + ) + with TestClient(http_application) as test_client: + response = test_client.get("/carries-503") + bootstrapper.teardown() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert _exception_types(sentry_mock) == ["CarriesUnavailableStatusError"] + + +async def test_lean_capture_runs_a_sync_exception_handler_in_a_worker_thread(sentry_mock: SentryTestTransport) -> None: + FastAPIBootstrapper(bootstrap_config=_build_config(sentry_mock)).bootstrap() + handler_threads: list[threading.Thread] = [] + + def respond(_: Request, __: Exception) -> Response: + handler_threads.append(threading.current_thread()) + return Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + response = await _reporting_server_errors(respond)(AsyncMock(), CarriesUnavailableStatusError()) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert handler_threads != [threading.current_thread()] + assert _exception_types(sentry_mock) == ["CarriesUnavailableStatusError"]