From 437060c75ef7df82cc7fb2b030255d330aae8bc5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 19:55:44 +0300 Subject: [PATCH] fix(sentry): read the logentry key the installed SDK fills, and mark its floor skip_sentry=True silently stopped suppressing events at the declared floor: sentry-sdk fills logentry.formatted on newer versions but logentry.message at 2.1, and the enrichment read only the former, so the log reached Sentry with nothing failing. It now reads whichever key is present and writes back to it. sentry-sdk 2.1 also predates Python 3.13's FrameLocalsProxy, which the SDK fails to pickle while capturing a request, and 3.14 needs later fixes again. Marked per interpreter so 3.10-3.12 keep 2.1. The four remaining failures were test-side: they read _sentry_logs_handler and passed sentry_logs_level, neither of which exists below sentry-sdk 2.25. The version guard in the instrument was already correct. Refs #242 --- .../instruments/sentry_instrument.py | 9 ++-- pyproject.toml | 6 ++- tests/instruments/test_sentry_instrument.py | 52 ++++++++++++++++--- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 8564554..8f0e19e 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -50,9 +50,12 @@ 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 ( - (logentry := event.get("logentry")) - and (formatted_message := logentry.get("formatted")) + (formatted_message := logentry.get(message_key)) and isinstance(formatted_message, str) and isinstance(event.get("contexts"), dict) ): @@ -66,7 +69,7 @@ def enrich_sentry_event_from_structlog_log( if not payload.message: return event - event["logentry"]["formatted"] = payload.message # ty: ignore[invalid-assignment] + event["logentry"][message_key] = payload.message # ty: ignore[invalid-assignment] if payload.extra: event["contexts"]["structlog"] = payload.extra return event diff --git a/pyproject.toml b/pyproject.toml index 4952e05..589c534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,11 @@ orjson = [ sentry = [ # >=1.31 for the `max_value_length` init option; >=2.1 for sentry_sdk.set_tags (plural, # SentryInstrument.bootstrap) -- the higher floor wins. - "sentry-sdk>=2.1", + # 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.10-3.12 keep 2.1. + "sentry-sdk>=2.1; python_version < '3.13'", + "sentry-sdk>=2.11; python_version == '3.13'", + "sentry-sdk>=2.59; python_version >= '3.14'", ] 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 0ced91b..3a0f962 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -1,5 +1,6 @@ import copy import dataclasses +import json import logging import typing from unittest.mock import patch @@ -11,6 +12,7 @@ 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 @@ -20,7 +22,6 @@ from lite_bootstrap.instruments.sentry_instrument import ( SentryConfig, SentryInstrument, - enrich_sentry_event_from_structlog_log, ) @@ -158,13 +159,44 @@ 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. + + `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": {}} + + assert enrich_sentry_event_from_structlog_log(event, {}) is None # ty: ignore[invalid-argument-type] + + +@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] + + assert enriched is not None + assert enriched["logentry"][message_key] == "boom" + assert enriched["contexts"]["structlog"] == {"user": 7} + + def test_sentry_bootstrap_disables_the_sentry_logs_handler(minimal_sentry_config: SentryConfig) -> None: instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) instrument.bootstrap() try: integration = installed_logging_integration() - assert integration._sentry_logs_handler is None # noqa: SLF001 + 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 == [] @@ -173,7 +205,7 @@ def test_sentry_bootstrap_disables_the_sentry_logs_handler(minimal_sentry_config def test_sentry_bootstrap_keeps_a_user_supplied_logging_integration(minimal_sentry_config: SentryConfig) -> None: - supplied = LoggingIntegration(sentry_logs_level=logging.INFO) + supplied = LoggingIntegration() bootstrap_config = dataclasses.replace(minimal_sentry_config, sentry_integrations=[supplied]) instrument = SentryInstrument(bootstrap_config=bootstrap_config) instrument.bootstrap() @@ -286,8 +318,16 @@ def test_sentry_passes_sentry_logs_level_only_when_the_sdk_accepts_it( `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) - integrations = SentryInstrument(bootstrap_config=minimal_sentry_config)._build_integrations() # noqa: SLF001 + monkeypatch.setattr(sentry_instrument, "LoggingIntegration", RecordingLoggingIntegration) + SentryInstrument(bootstrap_config=minimal_sentry_config)._build_integrations() # noqa: SLF001 - logging_integration = next(one for one in integrations if isinstance(one, LoggingIntegration)) - assert (logging_integration._sentry_logs_handler is None) is supported # 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