From f2a9a22aabd484e902ff38ce1bcaf09cab2a062a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 28 Oct 2026 23:37:54 +0300 Subject: [PATCH] Leave sentry-sdk's auto-enabling integrations off while Sentry tracing is off Adds sentry_auto_enabling_integrations, passed as auto_enabling_integrations. None follows tracing on SentryConfig, and follows lean error capture on the Litestar, FastAPI and FastMCP configs, whose framework integrations are auto-enabling themselves. Closes #313. --- benchmarks/README.md | 16 +++- benchmarks/frameworks/faststream_suite.py | 1 + docs/integrations/fastapi.md | 6 +- docs/integrations/fastmcp.md | 7 +- docs/integrations/litestar.md | 6 +- docs/introduction/configuration.md | 26 ++++++ docs/introduction/performance.md | 9 +- .../instruments/sentry_instrument.py | 12 +++ tests/instruments/test_sentry_instrument.py | 82 +++++++++++++++++++ tests/test_litestar_sentry_capture.py | 36 +++++++- tests/test_starlette_sentry_capture.py | 53 +++++++++++- 11 files changed, 242 insertions(+), 12 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 7e8551e..637e40b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -495,10 +495,24 @@ lite-bootstrap logging on. The message triggers no work, so read the µs column: | Prometheus | +11.6 | | | OpenTelemetry, 100% | +73.0 | producer span plus context injection | | OpenTelemetry, 10% | +30.6 | | -| Sentry | +28.8 | RedisIntegration (auto-enabling) on the redis client | +| Sentry, 1.x defaults | +28.8 | RedisIntegration (auto-enabling) on the redis client | | Sentry, `auto_enabling_integrations=False` | +0.0 | | +Since 2.0, lite-bootstrap leaves sentry-sdk's auto-enabling integrations off while Sentry tracing is +off. Rerun per publish on the M2 host (macOS, CPython 3.14.7, faststream 0.7.8, redis 8.1.0, Redis 8 in a local +container, `bench.py faststream log@pub,sentry@pub,sentry_auto@pub,sentry_noauto@pub --repeat 3`), +over a baseline of about 136 µs: + +| scenario | sentry-sdk 2.73.0 | 3.0.0b2 | +|---|---:|---:| +| `sentry`: the 2.0 default | -3.0 | -1.7 | +| `sentry_auto`: `sentry_auto_enabling_integrations=True` | +27.1 | +9.5 | +| `sentry_noauto`: `auto_enabling_integrations=False` in `sentry_additional_params` | -1.6 | -1.4 | + +The first round of the baseline ran 11% slower than the other two, so the negative rows are noise. +`sentry_noauto` now has the same config as `sentry`. + ### 8e. FastMCP diff --git a/benchmarks/frameworks/faststream_suite.py b/benchmarks/frameworks/faststream_suite.py index 95adb9f..dfff675 100644 --- a/benchmarks/frameworks/faststream_suite.py +++ b/benchmarks/frameworks/faststream_suite.py @@ -50,6 +50,7 @@ "sentry_log_info": Scenario(config=base_config(**LOG, **sentry(), **INFO)), "sentry_log_handler3": Scenario(config=base_config(**LOG, **sentry()), kind="structlog"), "sentry_noauto": Scenario(config=base_config(**LOG, **sentry(auto_enabling_integrations=False))), + "sentry_auto": Scenario(config=base_config(**LOG, **sentry(), sentry_auto_enabling_integrations=True)), "full": Scenario(config=base_config(**OTEL_MW, **PROM_MW, **LOG, **sentry())), } SCENARIOS: typing.Final = {**_BASE, **{f"{name}@pub": scenario for name, scenario in _BASE.items()}} diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index 1ef5331..243fa95 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -102,8 +102,10 @@ Measured in-process on a do-nothing handler, over the same app without Sentry: | 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. +cannot remove one that an earlier `sentry_sdk.init` installed. That matters only with +[`sentry_auto_enabling_integrations`](../introduction/configuration.md#sentry-auto-enabling-integrations) +on, since lean capture otherwise leaves auto-enabling off and sentry-sdk never enables the framework +integration. With it on, lite-bootstrap warns that errors may be reported twice. ## Logging diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 162f8ad..c2ba6a6 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -70,11 +70,14 @@ 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. +the same request data. Lean capture also leaves +[auto-enabling integrations](../introduction/configuration.md#sentry-auto-enabling-integrations) off, +which drops `MCPIntegration` too. With tracing off it measured no cost per tool call, and the only +difference it made to a tool error's event was a `parent_span_id` from its unsampled span. 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. +attached, with lean capture on or off and with `MCPIntegration` on or off. Measured in-process per `tools/call` over stateless JSON HTTP, over the same app without Sentry: diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index a2d4715..6bee667 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -150,8 +150,10 @@ Measured in-process on a do-nothing handler, over the same app without Sentry: | 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. +cannot remove one that an earlier `sentry_sdk.init` installed. That matters only with +[`sentry_auto_enabling_integrations`](../introduction/configuration.md#sentry-auto-enabling-integrations) +on, since lean capture otherwise leaves auto-enabling off and sentry-sdk never enables the framework +integration. With it on, lite-bootstrap warns that errors may be reported twice. ## Prometheus diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index f037a4f..20e1541 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -21,6 +21,7 @@ Additional parameters can also be supplied through the settings object: - `sentry_tags` - key/value string pairs that are both indexed and searchable - `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_auto_enabling_integrations` - whether sentry-sdk enables an integration for every installed package it knows, such as SQLAlchemy, asyncpg, redis and httpx (default: `None`, meaning off while Sentry tracing is off). See [Sentry auto-enabling integrations](#sentry-auto-enabling-integrations). - `sentry_before_send` - optional callback chained after the built-in structlog enricher, passed to `sentry_sdk.init(before_send=...)` - `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). @@ -58,6 +59,31 @@ Two ways to opt out of the appended integration: supply your own `LoggingIntegra `sentry_default_integrations=False`, which suppresses it along with every other default integration. Under either, `sentry_logging_breadcrumb_level` is ignored and lite-bootstrap warns. +### Sentry auto-enabling integrations + +sentry-sdk enables an integration for every installed package it has one for: the web frameworks, +SQLAlchemy, asyncpg, redis, httpx, aiohttp, MCP and others. lite-bootstrap passes +`auto_enabling_integrations` to `sentry_sdk.init`, and `sentry_auto_enabling_integrations` picks it: + +- `None`, the default: off while Sentry tracing is off, on while it is on. On Litestar, FastAPI and + FastMCP it follows [`sentry_lean_error_capture`](#sentry) instead: off with lean capture, on when + sentry-sdk's framework integration reports request errors, because that integration is + auto-enabling too. +- `True` or `False`: that value always. An `auto_enabling_integrations` in `sentry_additional_params` + still wins over both. + +With tracing off, those integrations still open a span and record a breadcrumb for every SQL +statement, Redis command or outgoing HTTP request. `SqlalchemyIntegration` measured about 64 µs per +statement on sentry-sdk 2.70, and `RedisIntegration` +27.1 µs per FastStream publish on 2.73.0 and ++9.5 µs on 3.0.0b2 ([FastStream](https://github.com/modern-python/lite-bootstrap/blob/main/benchmarks/README.md#8d-faststream)). The default integrations stay: +dedupe, excepthook, threading, stdlib, atexit, modules, argv and lite-bootstrap's own +`LoggingIntegration`. + +Off, error events lose what those integrations add: SQL, Redis and outgoing-HTTP breadcrumbs, and +anything else an auto-enabled integration attaches. To keep them, set +`sentry_auto_enabling_integrations=True`, or list just the integrations you want in +`sentry_integrations`, for example `[SqlalchemyIntegration()]`. + ### Sentry data collection lite-bootstrap passes an explicit `data_collection` to `sentry_sdk.init`, close to what diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index f4a2674..2e96b6a 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -68,15 +68,18 @@ request, every request then pays the per-record cost measured here: --8<-- "benchmarks/README.md:logging" -lite-bootstrap already applies four savings for you. It turns Sentry Logs off in the logging +lite-bootstrap already applies five 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. 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. +integration, which costs you request transactions and bodies the handler never read; set `sentry_lean_error_capture=False` to keep the integration. With tracing off it also turns +sentry-sdk's [auto-enabling integrations](configuration.md#sentry-auto-enabling-integrations) off, +which costs you their SQL, Redis and outgoing-HTTP breadcrumbs; set +`sentry_auto_enabling_integrations=True` to keep them. The numbers on this page were measured +before 2.0, against the old defaults. ## What each saving costs you diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index d9e4557..248f953 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -55,8 +55,14 @@ class SentryConfig(BaseConfig): sentry_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) sentry_tags: dict[str, str] | None = None sentry_default_integrations: bool = True + sentry_auto_enabling_integrations: bool | None = None sentry_before_send: "sentry_types.EventProcessor | None" = None + def is_sentry_auto_enabling_integrations_on(self) -> bool: + if self.sentry_auto_enabling_integrations is not None: + return self.sentry_auto_enabling_integrations + return self.is_sentry_tracing_requested() + def is_sentry_tracing_requested(self) -> bool: return any( option is not None @@ -101,6 +107,11 @@ def is_sentry_lean_error_capture_on(self) -> bool: return self.sentry_lean_error_capture return not self.is_sentry_tracing_requested() and not self._supplied_sentry_lean_replaced_integrations() + def is_sentry_auto_enabling_integrations_on(self) -> bool: + if self.sentry_auto_enabling_integrations is not None: + return self.sentry_auto_enabling_integrations + return not self.is_sentry_lean_error_capture_on() + SENTRY_FAILED_REQUEST_STATUS_CODES: typing.Final = range(500, 600) _SENTRY_TRACE_HEADERS: typing.Final = frozenset({b"sentry-trace", b"baggage"}) @@ -233,6 +244,7 @@ def _build_init_params(self) -> dict[str, typing.Any]: "auto_session_tracking": config.sentry_auto_session_tracking, "integrations": self._build_integrations(), "default_integrations": config.sentry_default_integrations, + "auto_enabling_integrations": config.is_sentry_auto_enabling_integrations_on(), "data_collection": DEFAULT_DATA_COLLECTION, "before_send": wrap_before_send_callbacks( enrich_sentry_event_from_structlog_log, config.sentry_before_send diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 4b4d164..0f6002c 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -381,3 +381,85 @@ def test_sentry_does_not_warn_when_the_breadcrumb_level_is_left_at_its_default( assert [one for one in recwarn if "sentry_logging_breadcrumb_level" in str(one.message)] == [] finally: instrument.teardown() + + +_AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT: typing.Final = frozenset({"aiohttp", "httpx", "redis"}) + + +def _active_integrations(bootstrap_config: SentryConfig) -> set[str]: + instrument = SentryInstrument(bootstrap_config=bootstrap_config) + instrument.bootstrap() + try: + return set(sentry_sdk.get_client().integrations) + finally: + instrument.teardown() + + +def test_sentry_leaves_auto_enabling_integrations_off_while_tracing_is_off( + minimal_sentry_config: SentryConfig, +) -> None: + active = _active_integrations(minimal_sentry_config) + + assert not _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT & active + assert "logging" in active + + +@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_sentry_keeps_auto_enabling_integrations_while_tracing_is_on( + minimal_sentry_config: SentryConfig, tracing_overrides: dict[str, typing.Any] +) -> None: + additional_params = { + **minimal_sentry_config.sentry_additional_params, + **tracing_overrides.pop("sentry_additional_params", {}), + } + bootstrap_config = dataclasses.replace( + minimal_sentry_config, sentry_additional_params=additional_params, **tracing_overrides + ) + + assert _active_integrations(bootstrap_config) >= _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT + + +@pytest.mark.parametrize( + ("auto_enabling_integrations", "traces_sample_rate", "expected"), + [ + pytest.param(True, None, True, id="on-while-tracing-is-off"), + pytest.param(False, 1.0, False, id="off-while-tracing-is-on"), + ], +) +def test_sentry_auto_enabling_integrations_set_explicitly_wins( + minimal_sentry_config: SentryConfig, + auto_enabling_integrations: bool, + traces_sample_rate: float | None, + expected: bool, +) -> None: + bootstrap_config = dataclasses.replace( + minimal_sentry_config, + sentry_auto_enabling_integrations=auto_enabling_integrations, + sentry_traces_sample_rate=traces_sample_rate, + ) + instrument = SentryInstrument(bootstrap_config=bootstrap_config) + instrument.bootstrap() + + try: + assert sentry_sdk.get_client().options["auto_enabling_integrations"] is expected + finally: + instrument.teardown() + + +def test_sentry_additional_params_auto_enabling_integrations_wins_over_the_field( + minimal_sentry_config: SentryConfig, +) -> None: + bootstrap_config = dataclasses.replace( + minimal_sentry_config, + sentry_auto_enabling_integrations=False, + sentry_additional_params={**minimal_sentry_config.sentry_additional_params, "auto_enabling_integrations": True}, + ) + + assert _active_integrations(bootstrap_config) >= _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT diff --git a/tests/test_litestar_sentry_capture.py b/tests/test_litestar_sentry_capture.py index 69a7eb0..ec441af 100644 --- a/tests/test_litestar_sentry_capture.py +++ b/tests/test_litestar_sentry_capture.py @@ -419,6 +419,26 @@ def test_lean_capture_reports_once_when_litestar_logs_the_exception( assert _exception_types(sentry_mock) == ["RuntimeError"] +_AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT: typing.Final = frozenset({"aiohttp", "httpx", "redis"}) + + +def test_lean_capture_leaves_auto_enabling_integrations_off_and_still_reports(sentry_mock: SentryTestTransport) -> None: + application = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)).bootstrap() + with TestClient(app=application, raise_server_exceptions=False) as test_client: + active = test_client.get("/integrations").json() + test_client.get("/boom") + + assert not _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT & set(active) + [event] = _error_events(sentry_mock) + assert event["request"]["url"] == "http://testserver.local/boom" + + +def test_integration_capture_keeps_auto_enabling_integrations(sentry_mock: SentryTestTransport) -> None: + active = _active_integrations(sentry_mock, sentry_lean_error_capture=False) + + assert set(active) >= _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT + + def test_lean_capture_replaces_the_litestar_integration(sentry_mock: SentryTestTransport) -> None: active = _active_integrations(sentry_mock, sentry_lean_error_capture=True) @@ -441,13 +461,27 @@ def test_lean_capture_warns_when_an_earlier_init_installed_the_litestar_integrat sentry_mock: SentryTestTransport, ) -> None: sentry_integrations._installed_integrations.add(LitestarIntegration.identifier) # noqa: SLF001 - bootstrapper = LitestarBootstrapper(bootstrap_config=_build_config(sentry_mock)) + bootstrapper = LitestarBootstrapper( + bootstrap_config=_build_config(sentry_mock, sentry_auto_enabling_integrations=True) + ) with pytest.warns(UserWarning, match="installed by an earlier sentry_sdk.init"): bootstrapper.bootstrap() bootstrapper.teardown() +def test_lean_capture_ignores_an_earlier_installed_litestar_integration_while_auto_enabling_is_off( + sentry_mock: SentryTestTransport, +) -> None: + sentry_integrations._installed_integrations.add(LitestarIntegration.identifier) # noqa: SLF001 + + with warnings.catch_warnings(): + warnings.simplefilter("error") + active = _active_integrations(sentry_mock) + + assert LitestarIntegration.identifier not in active + + def test_forced_lean_capture_warns_that_traces_sample_rate_gets_no_request_transactions( sentry_mock: SentryTestTransport, ) -> None: diff --git a/tests/test_starlette_sentry_capture.py b/tests/test_starlette_sentry_capture.py index aca8b8c..c0d52a5 100644 --- a/tests/test_starlette_sentry_capture.py +++ b/tests/test_starlette_sentry_capture.py @@ -498,6 +498,43 @@ def test_one_event_per_failing_request_when_uvicorn_logs_the_exception( assert _exception_types(sentry_mock) == ["RuntimeError"] +_AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT: typing.Final = frozenset({"aiohttp", "httpx", "redis"}) + + +def test_lean_capture_leaves_auto_enabling_integrations_off_and_still_reports(sentry_mock: SentryTestTransport) -> None: + with TestClient(app=_bootstrap(sentry_mock), raise_server_exceptions=False) as test_client: + active = test_client.get("/integrations").json() + test_client.get("/boom") + + assert not _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT & set(active) + [event] = _error_events(sentry_mock) + assert event["request"]["url"] == "http://testserver/boom" + + +def test_integration_capture_keeps_auto_enabling_integrations(sentry_mock: SentryTestTransport) -> None: + active = _active_integrations(sentry_mock, sentry_lean_error_capture=False) + + assert set(active) >= _AUTO_ENABLED_IN_THE_TEST_ENVIRONMENT + + +def test_forced_lean_capture_leaves_auto_enabling_integrations_off_while_tracing_is_on( + 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)["auto_enabling_integrations"] is False + + +def test_fastmcp_lean_capture_leaves_mcp_integration_off(sentry_mock: SentryTestTransport) -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=_build_fastmcp_config(sentry_mock)) + application = bootstrapper.bootstrap() + with TestClient(application.http_app()) as test_client: + active = test_client.get("/integrations").json() + + assert "mcp" not in active + + def test_lean_capture_replaces_the_starlette_integrations(sentry_mock: SentryTestTransport) -> None: active = _active_integrations(sentry_mock, sentry_lean_error_capture=True) @@ -520,13 +557,27 @@ def test_lean_capture_warns_when_an_earlier_init_installed_the_starlette_integra sentry_mock: SentryTestTransport, ) -> None: sentry_integrations._installed_integrations.add(StarletteIntegration.identifier) # noqa: SLF001 - bootstrapper = FastAPIBootstrapper(bootstrap_config=_build_config(sentry_mock)) + bootstrapper = FastAPIBootstrapper( + bootstrap_config=_build_config(sentry_mock, sentry_auto_enabling_integrations=True) + ) with pytest.warns(UserWarning, match="installed by an earlier sentry_sdk.init"): bootstrapper.bootstrap() bootstrapper.teardown() +def test_lean_capture_ignores_an_earlier_installed_starlette_integration_while_auto_enabling_is_off( + sentry_mock: SentryTestTransport, +) -> None: + sentry_integrations._installed_integrations.add(StarletteIntegration.identifier) # noqa: SLF001 + + with warnings.catch_warnings(): + warnings.simplefilter("error") + active = _active_integrations(sentry_mock) + + assert StarletteIntegration.identifier not in active + + def test_forced_lean_capture_warns_that_traces_sample_rate_gets_no_request_transactions( sentry_mock: SentryTestTransport, ) -> None: