Skip to content

Commit 4ee90f3

Browse files
authored
Adapt structlog logs for Sentry (#119)
* Add structlog integration and debug changes to litestar example * Remove and re-add filter_by_level, add Sentry before_send hook with JSON processing * Refactor Sentry instrument to improve log processing and type safety * Remove debug param and enhance Sentry event processing * Refactor Sentry instrument and add tests for structlog event enrichment * Clean up unused imports and simplify app creation in litestar example
1 parent ea1acd9 commit 4ee90f3

File tree

3 files changed

+108
-3
lines changed

3 files changed

+108
-3
lines changed

microbootstrap/instruments/logging_instrument.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ def tracer_injection(_: WrappedLogger, __: str, event_dict: EventDict) -> EventD
7373

7474

7575
STRUCTLOG_PRE_CHAIN_PROCESSORS: typing.Final[list[typing.Any]] = [
76-
structlog.stdlib.filter_by_level,
7776
structlog.stdlib.add_log_level,
7877
structlog.stdlib.add_logger_name,
7978
tracer_injection,
@@ -162,6 +161,7 @@ def _unset_handlers(self) -> None:
162161
def _configure_structlog_loggers(self) -> None:
163162
structlog.configure(
164163
processors=[
164+
structlog.stdlib.filter_by_level,
165165
*STRUCTLOG_PRE_CHAIN_PROCESSORS,
166166
*self.instrument_config.logging_extra_processors,
167167
STRUCTLOG_FORMATTER_PROCESSOR,

microbootstrap/instruments/sentry_instrument.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import contextlib
33
import typing
44

5+
import orjson
56
import pydantic
67
import sentry_sdk
8+
from sentry_sdk import _types as sentry_types
79
from sentry_sdk.integrations import Integration # noqa: TC002
810

911
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
@@ -21,6 +23,53 @@ class SentryConfig(BaseInstrumentConfig):
2123
sentry_integrations: list[Integration] = pydantic.Field(default_factory=list)
2224
sentry_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
2325
sentry_tags: dict[str, str] | None = None
26+
sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None
27+
28+
29+
IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset({"event", "level", "logger", "tracing", "timestamp"})
30+
31+
32+
def enrich_sentry_event_from_structlog_log(event: sentry_types.Event, _hint: sentry_types.Hint) -> sentry_types.Event:
33+
if (
34+
(logentry := event.get("logentry"))
35+
and (formatted_message := logentry.get("formatted"))
36+
and (isinstance(formatted_message, str))
37+
and formatted_message.startswith("{")
38+
and (isinstance(event.get("contexts"), dict))
39+
):
40+
try:
41+
loaded_formatted_log = orjson.loads(formatted_message)
42+
except orjson.JSONDecodeError:
43+
return event
44+
if not isinstance(loaded_formatted_log, dict):
45+
return event
46+
47+
if event_name := loaded_formatted_log.get("event"):
48+
event["logentry"]["formatted"] = event_name # type: ignore[index]
49+
else:
50+
return event
51+
52+
additional_extra = loaded_formatted_log
53+
for one_attr in IGNORED_STRUCTLOG_ATTRIBUTES:
54+
additional_extra.pop(one_attr, None)
55+
if additional_extra:
56+
event["contexts"]["structlog"] = additional_extra
57+
58+
return event
59+
60+
61+
def wrap_before_send_callbacks(*callbacks: sentry_types.EventProcessor | None) -> sentry_types.EventProcessor:
62+
def run_before_send(event: sentry_types.Event, hint: sentry_types.Hint) -> sentry_types.Event | None:
63+
for callback in callbacks:
64+
if not callback:
65+
continue
66+
temp_event = callback(event, hint)
67+
if temp_event is None:
68+
return None
69+
event = temp_event
70+
return event
71+
72+
return run_before_send
2473

2574

2675
class SentryInstrument(Instrument[SentryConfig]):
@@ -39,6 +88,9 @@ def bootstrap(self) -> None:
3988
max_breadcrumbs=self.instrument_config.sentry_max_breadcrumbs,
4089
max_value_length=self.instrument_config.sentry_max_value_length,
4190
attach_stacktrace=self.instrument_config.sentry_attach_stacktrace,
91+
before_send=wrap_before_send_callbacks(
92+
enrich_sentry_event_from_structlog_log, self.instrument_config.sentry_before_send
93+
),
4294
integrations=self.instrument_config.sentry_integrations,
4395
**self.instrument_config.sentry_additional_params,
4496
)

tests/instruments/test_sentry.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
from __future__ import annotations
2+
import copy
13
import typing
4+
from unittest import mock
25
from unittest.mock import patch
36

47
import litestar
8+
import pytest
59
from litestar.testing import TestClient as LitestarTestClient
610

7-
from microbootstrap import SentryConfig
811
from microbootstrap.bootstrappers.litestar import LitestarSentryInstrument
9-
from microbootstrap.instruments.sentry_instrument import SentryInstrument
12+
from microbootstrap.instruments.sentry_instrument import SentryInstrument, enrich_sentry_event_from_structlog_log
13+
14+
15+
if typing.TYPE_CHECKING:
16+
from sentry_sdk import _types as sentry_types
17+
18+
from microbootstrap import SentryConfig
1019

1120

1221
def test_sentry_is_ready(minimal_sentry_config: SentryConfig) -> None:
@@ -57,3 +66,47 @@ async def error_handler() -> None:
5766
test_client.get("/test-error-handler")
5867

5968
assert mock_capture_event.called
69+
70+
71+
class TestSentryEnrichEventFromStructlog:
72+
@pytest.mark.parametrize(
73+
"event",
74+
[
75+
{},
76+
{"logentry": None},
77+
{"logentry": {}},
78+
{"logentry": {"formatted": b""}},
79+
{"logentry": {"formatted": ""}},
80+
{"logentry": {"formatted": "hi"}},
81+
{"logentry": {"formatted": "[]"}},
82+
{"logentry": {"formatted": "[{}]"}},
83+
{"logentry": {"formatted": "{"}, "contexts": {}},
84+
{"logentry": {"formatted": "{}"}, "contexts": {}},
85+
],
86+
)
87+
def test_skip(self, event: sentry_types.Event) -> None:
88+
assert enrich_sentry_event_from_structlog_log(copy.deepcopy(event), mock.Mock()) == event
89+
90+
@pytest.mark.parametrize(
91+
("event_before", "event_after"),
92+
[
93+
(
94+
{"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}},
95+
{"logentry": {"formatted": "event name"}, "contexts": {}},
96+
),
97+
(
98+
{
99+
"logentry": {
100+
"formatted": '{"event": "event name", "timestamp": 1, "level": "error", "logger": "event.logger", "tracing": {}, "foo": "bar"}' # noqa: E501
101+
},
102+
"contexts": {},
103+
},
104+
{
105+
"logentry": {"formatted": "event name"},
106+
"contexts": {"structlog": {"foo": "bar"}},
107+
},
108+
),
109+
],
110+
)
111+
def test_modify(self, event_before: sentry_types.Event, event_after: sentry_types.Event) -> None:
112+
assert enrich_sentry_event_from_structlog_log(event_before, mock.Mock()) == event_after

0 commit comments

Comments
 (0)