Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
):
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 46 additions & 6 deletions tests/instruments/test_sentry_instrument.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import dataclasses
import json
import logging
import typing
from unittest.mock import patch
Expand All @@ -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


Expand All @@ -20,7 +22,6 @@
from lite_bootstrap.instruments.sentry_instrument import (
SentryConfig,
SentryInstrument,
enrich_sentry_event_from_structlog_log,
)


Expand Down Expand Up @@ -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 == []
Expand All @@ -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()
Expand Down Expand Up @@ -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
Loading