diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 71d7f2d..d47abb8 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -65,6 +65,25 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test-ci + sentry-majors: + # The pytest jobs resolve whichever sentry-sdk major is newest; this runs the suite on each one. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sentry-sdk: ["sentry-sdk<3", "sentry-sdk==3.0.0b2"] + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.14 + - run: uv python pin 3.14 + - run: just install + - run: uv pip install "${{ matrix.sentry-sdk }}" + - run: just test free-threaded: runs-on: ubuntu-latest strategy: diff --git a/benchmarks/README.md b/benchmarks/README.md index f9da5f5..5fbbd7d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -600,6 +600,12 @@ scenario there, `full_all_tuned` included, is reachable through `FastAPIConfig`. [sentry-python#7400](https://github.com/getsentry/sentry-python/issues/7400) and imports nothing from this directory. +Every number here was measured on sentry-sdk 2.67.1. The scripts also run on 3.x, which builds no +transaction while tracing is off, so the patched scenarios, `errors_only_txn_endpoint_style` and +`repro_sentry_txn.py --patched` exist only on 2.x. On 3.x, `micro.py` times `continue_trace` and +the root `start_span` in place of the `Transaction` calls. 3.x also leaves `LoggingIntegration` out +of its default integrations, so its `errors_only` records no log breadcrumbs. + Numbers are machine-specific and move a few percent run to run; the ratios and the ordering are the durable part. Everything here was measured in one session on one idle machine, which is the only way the columns are comparable to each other. diff --git a/benchmarks/micro.py b/benchmarks/micro.py index ae7a852..3e91640 100644 --- a/benchmarks/micro.py +++ b/benchmarks/micro.py @@ -1,10 +1,13 @@ """Per-operation costs of the pieces sentry-sdk runs on every request. Attributes the per-request total from `run.py` to individual calls, which is what makes the -upstream issues actionable: `Transaction(...)` and the Mersenne Twister seed inside it are the -two largest items, and neither is reachable from a configuration knob. +upstream issues actionable: on sentry-sdk 2.x, `Transaction(...)` and the Mersenne Twister seed +inside it are the two largest items, and neither is reachable from a configuration knob. 3.x builds +no transaction while tracing is off, so there it times `continue_trace` and the root `start_span` +that the ASGI integration calls on every request instead. """ +import importlib import statistics import timeit import typing @@ -14,9 +17,10 @@ import sentry_scenarios import sentry_sdk from sentry_sdk.scope import Scope -from sentry_sdk.tracing import Transaction from sentry_sdk.tracing_utils import Baggage, PropagationContext, _generate_sample_rand +from lite_bootstrap.instruments.sentry_instrument import IS_SENTRY_SDK_2 + HEADERS: typing.Final = { "host": "testserver", @@ -25,6 +29,7 @@ "connection": "keep-alive", } TRACE_ID: typing.Final = uuid.uuid4().hex +SDK2_TRACING: typing.Final[typing.Any] = importlib.import_module("sentry_sdk.tracing") if IS_SENTRY_SDK_2 else None def timed(label: str, stmt: typing.Callable[[], object], number: int = 20000, repeat: int = 5) -> None: @@ -34,15 +39,35 @@ def timed(label: str, stmt: typing.Callable[[], object], number: int = 20000, re print(f"{label:<46} {best:>8.2f} us (best) {median:>8.2f} us (med)") -def main() -> None: - sentry_sdk.init(**sentry_scenarios.base_kwargs(max_breadcrumbs=15, attach_stacktrace=True)) - scope = Scope.get_isolation_scope() +def time_sdk2_transaction(isolation_scope: typing.Any, current_scope: typing.Any) -> None: # noqa: ANN401 + def new_transaction() -> object: + return SDK2_TRACING.Transaction(op="http.server", name="GET /ping", source="route") def start_and_finish() -> None: - transaction = Transaction(op="http.server", name="GET /ping", source="route") - with sentry_sdk.start_transaction(transaction, custom_sampling_context={"asgi_scope": {}}): + with current_scope.start_transaction(new_transaction(), custom_sampling_context={"asgi_scope": {}}): + pass + + timed( + "scope.continue_trace(headers)", + lambda: isolation_scope.continue_trace(HEADERS, op="http.server", name="GET /ping", source="route"), + ) + timed("Transaction(op, name, source)", new_transaction) + timed("start_transaction(txn) + exit (tracing off)", start_and_finish, number=5000) + + +def time_sdk3_span() -> None: + def start_and_end() -> None: + with sentry_sdk.start_span(name="GET /ping", parent_span=None): pass + timed("continue_trace(headers)", lambda: sentry_sdk.continue_trace(HEADERS)) + timed("start_span(root) + exit (tracing off)", start_and_end, number=5000) + + +def main() -> None: + sentry_sdk.init(**sentry_scenarios.base_kwargs(max_breadcrumbs=15, attach_stacktrace=True)) + scope = Scope.get_isolation_scope() + def isolation() -> None: with sentry_sdk.isolation_scope(): pass @@ -54,12 +79,10 @@ def isolation() -> None: timed("PropagationContext.from_incoming_data(headers)", lambda: PropagationContext.from_incoming_data(HEADERS)) timed("Baggage.from_incoming_header(None)", lambda: Baggage.from_incoming_header(None)) timed("scope.generate_propagation_context(headers)", lambda: scope.generate_propagation_context(HEADERS)) - timed( - "scope.continue_trace(headers)", - lambda: scope.continue_trace(HEADERS, op="http.server", name="GET /ping", source="route"), - ) - timed("Transaction(op, name, source)", lambda: Transaction(op="http.server", name="GET /ping", source="route")) - timed("start_transaction(txn) + exit (tracing off)", start_and_finish, number=5000) + if IS_SENTRY_SDK_2: + time_sdk2_transaction(scope, sentry_sdk.get_current_scope()) + else: + time_sdk3_span() timed("isolation_scope() enter/exit", isolation) timed("scope.fork()", scope.fork) timed("get_client()", sentry_sdk.get_client) diff --git a/benchmarks/repro_sentry_txn.py b/benchmarks/repro_sentry_txn.py index ac4cd17..8ddf887 100644 --- a/benchmarks/repro_sentry_txn.py +++ b/benchmarks/repro_sentry_txn.py @@ -7,6 +7,8 @@ python repro_sentry_txn.py --off # SDK never initialised python repro_sentry_txn.py # SDK as shipped python repro_sentry_txn.py --patched # skip the Transaction, keep trace propagation + +`--patched` runs on sentry-sdk 2.x only: 3.x builds no transaction while tracing is off. """ import asyncio @@ -55,7 +57,7 @@ def setup(*, patched: bool) -> None: # tracing is DISABLED: traces_sample_rate and traces_sampler are both unset sentry_sdk.init(dsn="https://public@o0.ingest.sentry.io/0", transport=NullTransport) if patched: - Scope.continue_trace = continue_trace_without_transaction # ty: ignore[invalid-assignment] + setattr(Scope, "continue_trace", continue_trace_without_transaction) # noqa: B010 async def drive(app: typing.Callable[..., typing.Awaitable[None]], requests: int) -> float: @@ -76,6 +78,8 @@ async def send(message: dict[str, typing.Any]) -> None: def main() -> None: if "--patched" in sys.argv: + if not sentry_sdk.VERSION.startswith("2."): + sys.exit(f"--patched needs sentry-sdk 2.x; {sentry_sdk.VERSION} builds no transaction to skip") mode = "patched" elif "--off" in sys.argv: mode = "off" diff --git a/benchmarks/sentry_scenarios.py b/benchmarks/sentry_scenarios.py index 331158f..5323e65 100644 --- a/benchmarks/sentry_scenarios.py +++ b/benchmarks/sentry_scenarios.py @@ -4,15 +4,17 @@ `starlette.middleware.Middleware.__init__` and `logging.Logger.callHandlers`, and none of it can be undone. -The `*_patch` scenarios are not configurations a user can reach - they simulate an upstream -fix so its value can be measured before proposing it. +The patched scenarios are not configurations a user can reach - they simulate an upstream +fix so its value can be measured before proposing it. They, and `errors_only_txn_endpoint_style`, +exist only on sentry-sdk 2.x: 3.x builds no transaction while tracing is off and has no +`transaction_style`. """ +import importlib import logging import typing import sentry_sdk -import sentry_sdk.tracing from driver import DSN, TRANSPORT from fastapi import FastAPI from sentry_sdk.integrations.dedupe import DedupeIntegration @@ -25,6 +27,10 @@ from sentry_sdk.integrations.threading import ThreadingIntegration from sentry_sdk.scope import Scope +from lite_bootstrap.instruments.sentry_instrument import IS_SENTRY_SDK_2 + + +SDK2_TRACING: typing.Final = importlib.import_module("sentry_sdk.tracing") if IS_SENTRY_SDK_2 else None logger = logging.getLogger("bench") @@ -59,6 +65,17 @@ def _no_transaction_integrations() -> list[typing.Any]: ] +def _errors_and_breadcrumbs_only(*, breadcrumbs: bool) -> LoggingIntegration: + """Errors become events and nothing goes to Sentry Logs; 3.0 renamed the arguments for that.""" + breadcrumb_level = logging.INFO if breadcrumbs else None + kwargs: dict[str, typing.Any] = ( + {"level": breadcrumb_level, "sentry_logs_level": None} + if IS_SENTRY_SDK_2 + else {"breadcrumb_level": breadcrumb_level, "event_level": logging.ERROR, "level": None} + ) + return LoggingIntegration(**kwargs) + + SCENARIOS: dict[str, typing.Callable[[], dict[str, typing.Any]] | None] = { "off": None, # lite-bootstrap's own defaults: tracing off, all default and auto integrations on. @@ -113,24 +130,16 @@ def _no_transaction_integrations() -> list[typing.Any]: auto_enabling_integrations=False, integrations=[StarletteIntegration()], ), - "errors_only_txn_endpoint_style": lambda: base_kwargs( - max_breadcrumbs=15, - attach_stacktrace=True, - integrations=[ - StarletteIntegration(transaction_style="endpoint"), - FastApiIntegration(transaction_style="endpoint"), - ], - ), # --- logging integration knobs --- "errors_only_no_sentry_logs": lambda: base_kwargs( max_breadcrumbs=15, attach_stacktrace=True, - integrations=[LoggingIntegration(sentry_logs_level=None)], + integrations=[_errors_and_breadcrumbs_only(breadcrumbs=True)], ), "errors_only_logging_lean": lambda: base_kwargs( max_breadcrumbs=15, attach_stacktrace=True, - integrations=[LoggingIntegration(level=None, sentry_logs_level=None)], + integrations=[_errors_and_breadcrumbs_only(breadcrumbs=False)], ), # --- tracing on --- "traces_0": lambda: base_kwargs(traces_sample_rate=0.0, attach_stacktrace=True), @@ -146,7 +155,7 @@ def _no_transaction_integrations() -> list[typing.Any]: def _patch_lazy_sample_rand() -> None: """`Transaction.__init__` derives sample_rand from the trace id even when tracing is off.""" - sentry_sdk.tracing._generate_sample_rand = lambda trace_id, **kwargs: 0.5 # ty: ignore[invalid-assignment] # noqa: ARG005 + setattr(SDK2_TRACING, "_generate_sample_rand", lambda trace_id, **kwargs: 0.5) # noqa: ARG005, B010 def _patch_skip_transaction() -> None: @@ -157,20 +166,30 @@ def continue_trace( ) -> None: self.generate_propagation_context(environ_or_headers) - Scope.continue_trace = continue_trace # ty: ignore[invalid-assignment] + setattr(Scope, "continue_trace", continue_trace) # noqa: B010 -PATCHES: dict[str, list[typing.Callable[[], None]]] = { - "errors_only_lazy_sample_rand": [_patch_lazy_sample_rand], - "errors_only_skip_txn": [_patch_skip_transaction], - "errors_only_skip_txn_no_sessions": [_patch_skip_transaction], - "errors_only_logging_lean_skip_txn": [_patch_skip_transaction], -} +PATCHES: dict[str, list[typing.Callable[[], None]]] = {} -SCENARIOS["errors_only_lazy_sample_rand"] = SCENARIOS["errors_only"] -SCENARIOS["errors_only_skip_txn"] = SCENARIOS["errors_only"] -SCENARIOS["errors_only_skip_txn_no_sessions"] = SCENARIOS["errors_only_no_sessions"] -SCENARIOS["errors_only_logging_lean_skip_txn"] = SCENARIOS["errors_only_logging_lean"] +if IS_SENTRY_SDK_2: + endpoint_style: dict[str, typing.Any] = {"transaction_style": "endpoint"} + SCENARIOS["errors_only_txn_endpoint_style"] = lambda: base_kwargs( + max_breadcrumbs=15, + attach_stacktrace=True, + integrations=[StarletteIntegration(**endpoint_style), FastApiIntegration(**endpoint_style)], + ) + PATCHES.update( + { + "errors_only_lazy_sample_rand": [_patch_lazy_sample_rand], + "errors_only_skip_txn": [_patch_skip_transaction], + "errors_only_skip_txn_no_sessions": [_patch_skip_transaction], + "errors_only_logging_lean_skip_txn": [_patch_skip_transaction], + } + ) + SCENARIOS["errors_only_lazy_sample_rand"] = SCENARIOS["errors_only"] + SCENARIOS["errors_only_skip_txn"] = SCENARIOS["errors_only"] + SCENARIOS["errors_only_skip_txn_no_sessions"] = SCENARIOS["errors_only_no_sessions"] + SCENARIOS["errors_only_logging_lean_skip_txn"] = SCENARIOS["errors_only_logging_lean"] def setup(scenario: str) -> None: diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 9e138bf..d8e22cf 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -17,7 +17,7 @@ Additional parameters can also be supplied through the settings object: - `sentry_attach_stacktrace` - if True, stack traces are automatically attached to all messages logged - `sentry_auto_session_tracking` - whether every request opens and closes a Sentry release-health `Session` (default: `True`), measured at ~7.7 µs per request. Set it to `False` if you do not use Sentry release health. - `sentry_integrations` - list of integrations to enable -- `sentry_logging_breadcrumb_level` - the minimum standard-library log level recorded as a breadcrumb (default: `logging.INFO`). Passed as `LoggingIntegration(level=...)`; see below. +- `sentry_logging_breadcrumb_level` - the minimum standard-library log level recorded as a breadcrumb (default: `logging.INFO`). See below for how it reaches `LoggingIntegration`. - `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`) @@ -30,15 +30,21 @@ integration; see [Performance](performance.md). ### Sentry logging integration -Unless `sentry_integrations` already contains a `LoggingIntegration`, lite-bootstrap appends one built -as `LoggingIntegration(level=sentry_logging_breadcrumb_level, sentry_logs_level=None)`. Its event -handler keeps the sentry-sdk default (`ERROR`), so the only departure from sentry-sdk's own default -integration is `sentry_logs_level`. - -Turning `sentry_logs_level` off is free. lite-bootstrap never sets `enable_logs`, so Sentry Logs is -off, but `SentryLogsHandler.emit` formats the record *before* it checks whether logs are enabled -([getsentry/sentry-python#7402](https://github.com/getsentry/sentry-python/issues/7402)) - it formats -every `INFO`+ record and discards the result. Dropping breadcrumbs as well, with +lite-bootstrap supports sentry-sdk 2.70 and later, on both the 2.x and 3.x majors. Unless +`sentry_integrations` already contains a `LoggingIntegration`, it appends one that turns `ERROR` +records into events, records breadcrumbs from `sentry_logging_breadcrumb_level` up and sends no +Sentry Logs. 3.0 renamed the integration's arguments, so the call differs per major: + +- 2.x: `LoggingIntegration(level=sentry_logging_breadcrumb_level, sentry_logs_level=None)`, with the + event level left at its `ERROR` default. +- 3.x: `LoggingIntegration(breadcrumb_level=sentry_logging_breadcrumb_level, event_level=logging.ERROR, level=None)`. + On 3.x `level` means Sentry Logs, and `event_level` defaults to `None`, so without it a logged + error creates no event. + +On 2.x, turning `sentry_logs_level` off is free. lite-bootstrap never sets `enable_logs`, so Sentry +Logs is off, but `SentryLogsHandler.emit` formats the record *before* it checks whether logs are +enabled ([getsentry/sentry-python#7402](https://github.com/getsentry/sentry-python/issues/7402)) - it +formats every `INFO`+ record and discards the result. Dropping breadcrumbs as well, with `sentry_logging_breadcrumb_level=None`, saves more but costs you log breadcrumbs on error events, so it stays on by default. Both are measured on the [performance page](performance.md#where-the-time-goes). @@ -47,6 +53,27 @@ 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 data collection + +lite-bootstrap passes an explicit `data_collection` to `sentry_sdk.init`, close to what +`send_default_pii=False` collected: no user identity, HTTP bodies, GraphQL documents, generative AI or +MCP inputs and outputs, database query data or queue message bodies. Request headers, cookies and URL +query parameters are sent with IP- and user-identifying keys filtered. Stack frame variables are sent +with secret-looking names (`password`, `token`, `key`, `session` and the rest of sentry-sdk's +denylist) replaced by `[Filtered]`. The dict is `sentry_instrument.DEFAULT_DATA_COLLECTION`. + +To collect more, pass your own `data_collection` in `sentry_additional_params`. It replaces the +default as a whole, so keys you leave out take sentry-sdk's own defaults, which collect more. To +change one key, spread the default: `{**DEFAULT_DATA_COLLECTION, "user_info": True}`. Read more about +the option [here](https://docs.sentry.io/platforms/python/data-management/data-collected/). + +`send_default_pii` has no effect on 2.x while `data_collection` is set, and on 3.x it is gone. 3.0 +also raises `TypeError` at `sentry_sdk.init` for any option it removed, so passing one of these +through `sentry_additional_params` fails at bootstrap on 3.x, `enable_tracing`, `enable_logs` and +`include_local_variables` among them. sentry-sdk's +[migration guide](https://github.com/getsentry/sentry-python/blob/master/MIGRATION_GUIDE.md) lists +every removed option. + ## Prometheus diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index af350c7..42320f9 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -1,5 +1,4 @@ import dataclasses -import inspect import logging import typing @@ -18,11 +17,23 @@ import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration - # sentry-sdk 2.25 added the parameter along with Sentry Logs; the declared floor is 2.1, - # where passing it raises TypeError and there is no logs handler to disable anyway. - SENTRY_LOGS_LEVEL_SUPPORTED: typing.Final = ( - "sentry_logs_level" in inspect.signature(LoggingIntegration.__init__).parameters - ) + IS_SENTRY_SDK_2: typing.Final = sentry_sdk.VERSION.startswith("2.") + + +_PII_KEY_DENYLIST: typing.Final = {"mode": "denylist", "terms": ["forwarded", "-ip", "remote-", "via", "-user"]} +DEFAULT_DATA_COLLECTION: typing.Final[dict[str, typing.Any]] = { + "user_info": False, + "http_bodies": [], + "graphql": {"document": False, "variables": False}, + "gen_ai": {"inputs": False, "outputs": False}, + "mcp": {"inputs": False, "outputs": False}, + "database_query_data": False, + "queues": False, + "http_headers": {"request": _PII_KEY_DENYLIST}, + "cookies": _PII_KEY_DENYLIST, + "url_query_params": _PII_KEY_DENYLIST, + "stack_frame_variables": {"mode": "denylist"}, +} @dataclasses.dataclass(kw_only=True, frozen=True) @@ -45,12 +56,9 @@ class SentryConfig(BaseConfig): def enrich_sentry_event_from_structlog_log( event: "sentry_types.Event", _: "sentry_types.Hint" ) -> typing.Optional["sentry_types.Event"]: - # sentry-sdk fills "formatted" on newer versions and "message" at the declared floor of 2.1, - # so read whichever is present and write the rewritten text back to that same key. logentry = event.get("logentry") or {} - message_key = "formatted" if logentry.get("formatted") else "message" if not ( - (formatted_message := logentry.get(message_key)) + (formatted_message := logentry.get("formatted")) and isinstance(formatted_message, str) and isinstance(event.get("contexts"), dict) ): @@ -64,7 +72,7 @@ def enrich_sentry_event_from_structlog_log( if not payload.message: return event - event["logentry"][message_key] = payload.message # ty: ignore[invalid-assignment] + event["logentry"]["formatted"] = payload.message # ty: ignore[invalid-assignment] if payload.extra: event["contexts"]["structlog"] = payload.extra return event @@ -115,9 +123,12 @@ def _build_integrations(self) -> list["Integration"]: if not config.sentry_default_integrations: self._warn_breadcrumb_level_ignored("sentry_default_integrations is False") return config.sentry_integrations - logging_integration_kwargs: dict[str, typing.Any] = {"level": config.sentry_logging_breadcrumb_level} - if SENTRY_LOGS_LEVEL_SUPPORTED: - logging_integration_kwargs["sentry_logs_level"] = None + breadcrumb_level = config.sentry_logging_breadcrumb_level + logging_integration_kwargs: dict[str, typing.Any] = ( + {"level": breadcrumb_level, "sentry_logs_level": None} + if IS_SENTRY_SDK_2 + else {"breadcrumb_level": breadcrumb_level, "event_level": logging.ERROR, "level": None} + ) return [*config.sentry_integrations, LoggingIntegration(**logging_integration_kwargs)] def bootstrap(self) -> None: @@ -133,6 +144,7 @@ def bootstrap(self) -> None: "auto_session_tracking": config.sentry_auto_session_tracking, "integrations": self._build_integrations(), "default_integrations": config.sentry_default_integrations, + "data_collection": DEFAULT_DATA_COLLECTION, "before_send": wrap_before_send_callbacks( enrich_sentry_event_from_structlog_log, config.sentry_before_send ), diff --git a/pyproject.toml b/pyproject.toml index 8ef198d..39872fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,18 +44,9 @@ orjson = [ "orjson>=3.8.0", ] sentry = [ - # >=1.31 for the `max_value_length` init option; >=2.1 for sentry_sdk.set_tags (plural, - # SentryInstrument.bootstrap) -- the higher floor wins. - # 2.1 predates Python 3.13's FrameLocalsProxy, which the SDK fails to pickle when capturing a - # request, and 3.14 needs later fixes again. Marked rather than raised, so 3.11-3.12 keep 2.1. - # 2.69.0 signs `sentry-trace`/`baggage` into SigV4 SignedHeaders and then rewrites them after - # signing, so every aiobotocore AWS call inside a transaction fails with SignatureDoesNotMatch - # (getsentry/sentry-python#7426, fixed in 2.69.1). - # <3 because 3.0 gives LoggingIntegration an `event_level` that defaults to None, so the - # `level`-only integration SentryInstrument builds would stop turning logged errors into events. - "sentry-sdk>=2.1,!=2.69.0,<3; python_version < '3.13'", - "sentry-sdk>=2.11,!=2.69.0,<3; python_version == '3.13'", - "sentry-sdk>=2.59,!=2.69.0,<3; python_version >= '3.14'", + # >=2.70 for the top-level `data_collection` option, which 3.x keeps and SentryInstrument + # passes on both majors. 3.x is supported: LoggingIntegration's arguments are chosen per major. + "sentry-sdk>=2.70,<4", ] pyroscope = [ # >=0.7.2 for pyroscope.shutdown() (PyroscopeInstrument.teardown); 0.7.1 and below carry diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 3a0f962..2503534 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -1,6 +1,5 @@ import copy import dataclasses -import json import logging import typing from unittest.mock import patch @@ -10,7 +9,6 @@ import structlog from sentry_sdk.integrations.logging import LoggingIntegration -from lite_bootstrap.instruments import sentry_instrument from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument from lite_bootstrap.instruments.sentry_instrument import enrich_sentry_event_from_structlog_log from tests.conftest import LoggingMock, SentryTestTransport @@ -159,47 +157,69 @@ def installed_logging_integration() -> LoggingIntegration: return integration -@pytest.mark.parametrize("message_key", ["formatted", "message"]) -def test_structlog_enrichment_reads_whichever_logentry_key_the_sdk_populates(message_key: str) -> None: - """INVARIANT: the structlog payload is read from the logentry key the installed sentry-sdk fills. +def test_sentry_bootstrap_turns_error_logs_into_events_and_sends_no_sentry_logs( + minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger=std_logger.name) + instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) + instrument.bootstrap() - `logentry.formatted` is not universal: at the declared floor of sentry-sdk 2.1 a log event carries - its text in `logentry.message` and has no `formatted` key at all. Reading only `formatted` makes - `skip_sentry=True` silently stop suppressing events there — the log reaches Sentry anyway, and - nothing fails, so only a run at the floor shows it. - """ - event = {"logentry": {message_key: json.dumps({"event": "boom", "skip_sentry": True})}, "contexts": {}} + try: + std_logger.info("some info") + std_logger.error("some error") + sentry_sdk.flush() + assert [item.type for envelope in sentry_mock.mock_envelopes for item in envelope.items] == ["event"] + finally: + instrument.teardown() - assert enrich_sentry_event_from_structlog_log(event, {}) is None # ty: ignore[invalid-argument-type] +def raise_with_secret_locals() -> None: + password = "hunter2" # noqa: S105 + api_token = "abc123" # noqa: S105 + raise RuntimeError(len(password) + len(api_token)) -@pytest.mark.parametrize("message_key", ["formatted", "message"]) -def test_structlog_enrichment_writes_back_to_the_key_it_read(message_key: str) -> None: - """The rewritten message has to land on the key the SDK actually renders.""" - event = { - "logentry": {message_key: json.dumps({"event": "boom", "user": 7})}, - "contexts": {}, - } - enriched = enrich_sentry_event_from_structlog_log(event, {}) # ty: ignore[invalid-argument-type] +def test_sentry_filters_secret_stack_frame_variables( + minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport +) -> None: + instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) + instrument.bootstrap() - assert enriched is not None - assert enriched["logentry"][message_key] == "boom" - assert enriched["contexts"]["structlog"] == {"user": 7} + try: + try: + raise_with_secret_locals() + except RuntimeError: + sentry_sdk.capture_exception() + event = sentry_mock.mock_envelopes[0].get_event() + assert event is not None + frame_vars = event["exception"]["values"][0]["stacktrace"]["frames"][-1]["vars"] + assert {frame_vars["password"], frame_vars["api_token"]} == {repr("[Filtered]")} + finally: + instrument.teardown() -def test_sentry_bootstrap_disables_the_sentry_logs_handler(minimal_sentry_config: SentryConfig) -> None: +def test_sentry_collects_no_user_info_or_http_bodies_by_default(minimal_sentry_config: SentryConfig) -> None: instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) instrument.bootstrap() try: - integration = installed_logging_integration() - if sentry_instrument.SENTRY_LOGS_LEVEL_SUPPORTED: - # Below sentry-sdk 2.25 there is no Sentry Logs handler to disable, or to look at. - assert integration._sentry_logs_handler is None # noqa: SLF001 - assert integration._breadcrumb_handler is not None # noqa: SLF001 - assert integration._handler is not None # noqa: SLF001 - assert minimal_sentry_config.sentry_integrations == [] + data_collection = sentry_sdk.get_client().options["data_collection"] + assert data_collection["user_info"] is False + assert data_collection["http_bodies"] == [] + finally: + instrument.teardown() + + +def test_sentry_additional_params_data_collection_replaces_the_default(minimal_sentry_config: SentryConfig) -> None: + bootstrap_config = dataclasses.replace( + minimal_sentry_config, + sentry_additional_params={**minimal_sentry_config.sentry_additional_params, "data_collection": {}}, + ) + instrument = SentryInstrument(bootstrap_config=bootstrap_config) + instrument.bootstrap() + + try: + assert sentry_sdk.get_client().options["data_collection"]["user_info"] is True finally: instrument.teardown() @@ -306,28 +326,3 @@ 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() - - -@pytest.mark.parametrize("supported", [True, False], ids=["sdk_has_it", "sdk_lacks_it"]) -def test_sentry_passes_sentry_logs_level_only_when_the_sdk_accepts_it( - minimal_sentry_config: SentryConfig, monkeypatch: pytest.MonkeyPatch, supported: bool -) -> None: - """INVARIANT: `sentry_logs_level` reaches only the sentry-sdk versions that accept it. - - It arrived in sentry-sdk 2.25 while the declared floor is 2.1, where passing it raises - `TypeError: LoggingIntegration.__init__() got an unexpected keyword argument` at bootstrap. - Below 2.25 there is no Sentry Logs feature, so there is no handler to disable either. - """ - recorded: dict[str, typing.Any] = {} - - class RecordingLoggingIntegration(LoggingIntegration): - def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401 - recorded.update(kwargs) - - monkeypatch.setattr(sentry_instrument, "SENTRY_LOGS_LEVEL_SUPPORTED", supported) - monkeypatch.setattr(sentry_instrument, "LoggingIntegration", RecordingLoggingIntegration) - SentryInstrument(bootstrap_config=minimal_sentry_config)._build_integrations() # noqa: SLF001 - - # Asserted on the kwargs rather than on the constructed integration: the attribute that would - # reveal them does not exist on an SDK without the parameter, which is the case under test. - assert ("sentry_logs_level" in recorded) is supported