Skip to content

Commit 6966f80

Browse files
authored
Merge pull request #67 from modern-python/format-logs-sentry
format logs for sentry
2 parents 56582ee + 22941b2 commit 6966f80

File tree

6 files changed

+196
-47
lines changed

6 files changed

+196
-47
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ Those instruments can be bootstrapped for:
2525
- [services and scripts without frameworks](https://lite-bootstrap.readthedocs.io/integrations/free)
2626
---
2727

28+
Usage examples:
29+
30+
- with LiteStar - [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template)
31+
- with FastAPI - [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template)
32+
2833
## 📚 [Documentation](https://lite-bootstrap.readthedocs.io)
2934

3035
## 📦 [PyPi](https://pypi.org/project/lite-bootstrap)

lite_bootstrap/instruments/sentry_instrument.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import dataclasses
22
import typing
33

4+
import orjson
5+
46
from lite_bootstrap import import_checker
57
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
68

79

810
if typing.TYPE_CHECKING:
11+
from sentry_sdk import _types as sentry_types
912
from sentry_sdk.integrations import Integration
1013

1114

1215
if import_checker.is_sentry_installed:
1316
import sentry_sdk
1417

1518

19+
IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset(
20+
{"event", "level", "logger", "tracing", "timestamp", "exception"}
21+
)
22+
23+
1624
@dataclasses.dataclass(kw_only=True, frozen=True)
1725
class SentryConfig(BaseConfig):
1826
sentry_dsn: str | None = None
@@ -25,6 +33,62 @@ class SentryConfig(BaseConfig):
2533
sentry_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
2634
sentry_tags: dict[str, str] | None = None
2735
sentry_default_integrations: bool = True
36+
sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None
37+
38+
39+
def enrich_sentry_event_from_structlog_log(
40+
event: "sentry_types.Event", _: "sentry_types.Hint"
41+
) -> typing.Optional["sentry_types.Event"]:
42+
if (
43+
(logentry := event.get("logentry"))
44+
and (formatted_message := logentry.get("formatted"))
45+
and (isinstance(formatted_message, str))
46+
and formatted_message.startswith("{")
47+
and (isinstance(event.get("contexts"), dict))
48+
):
49+
try:
50+
loaded_formatted_log = orjson.loads(formatted_message)
51+
except orjson.JSONDecodeError:
52+
return event
53+
54+
if not isinstance(loaded_formatted_log, dict): # pragma: no cover
55+
return event
56+
57+
if loaded_formatted_log.get("skip_sentry"):
58+
return None
59+
60+
if event_name := loaded_formatted_log.get("event"):
61+
event["logentry"]["formatted"] = event_name # type: ignore[index]
62+
else:
63+
return event
64+
65+
additional_extra = loaded_formatted_log
66+
for one_attr in IGNORED_STRUCTLOG_ATTRIBUTES:
67+
additional_extra.pop(one_attr, None)
68+
if additional_extra:
69+
event["contexts"]["structlog"] = additional_extra
70+
71+
return event
72+
73+
74+
def wrap_before_send_callbacks(
75+
*callbacks: typing.Optional["sentry_types.EventProcessor"],
76+
) -> "sentry_types.EventProcessor":
77+
def run_before_send(
78+
event: "sentry_types.Event", hint: "sentry_types.Hint"
79+
) -> typing.Optional["sentry_types.Event"]:
80+
for callback in callbacks:
81+
if not callback:
82+
continue
83+
84+
temp_event = callback(event, hint)
85+
if temp_event is None:
86+
return None
87+
88+
event = temp_event
89+
return event
90+
91+
return run_before_send
2892

2993

3094
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@@ -50,6 +114,9 @@ def bootstrap(self) -> None:
50114
max_value_length=self.bootstrap_config.sentry_max_value_length,
51115
attach_stacktrace=self.bootstrap_config.sentry_attach_stacktrace,
52116
integrations=self.bootstrap_config.sentry_integrations,
117+
before_send=wrap_before_send_callbacks(
118+
enrich_sentry_event_from_structlog_log, self.bootstrap_config.sentry_before_send
119+
),
53120
**self.bootstrap_config.sentry_additional_params,
54121
)
55122
tags: dict[str, str] = self.bootstrap_config.sentry_tags or {}

tests/conftest.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import pytest
77
import sentry_sdk
88
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
9-
from structlog.testing import capture_logs
10-
from structlog.typing import EventDict
9+
from structlog.typing import EventDict, WrappedLogger
1110

1211
from lite_bootstrap import import_checker
1312

@@ -20,9 +19,6 @@ def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None:
2019
pass
2120

2221

23-
P = typing.ParamSpec("P")
24-
25-
2622
class SentryTestTransport(sentry_sdk.Transport):
2723
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401
2824
super().__init__(*args, **kwargs)
@@ -37,6 +33,20 @@ def sentry_mock() -> SentryTestTransport:
3733
return SentryTestTransport()
3834

3935

36+
class LoggingMock:
37+
def __init__(self) -> None:
38+
self.entries: list[EventDict] = []
39+
40+
def __call__(self, _: WrappedLogger, __: str, event_dict: EventDict) -> EventDict:
41+
self.entries.append(event_dict)
42+
return event_dict
43+
44+
45+
@pytest.fixture
46+
def logging_mock() -> LoggingMock:
47+
return LoggingMock()
48+
49+
4050
@contextlib.contextmanager
4151
def emulate_package_missing(package_name: str) -> typing.Iterator[None]:
4252
old_module = sys.modules[package_name]
@@ -47,9 +57,3 @@ def emulate_package_missing(package_name: str) -> typing.Iterator[None]:
4757
finally:
4858
sys.modules[package_name] = old_module
4959
reload(import_checker)
50-
51-
52-
@pytest.fixture(name="log_output")
53-
def fixture_log_output() -> typing.Iterator[list[EventDict]]:
54-
with capture_logs() as cap_logs:
55-
yield cap_logs

tests/instruments/test_logging_instrument.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,48 @@
33

44
import structlog
55
from opentelemetry.trace import get_tracer
6-
from structlog.testing import LogCapture
76

87
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory
98
from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument
9+
from tests.conftest import LoggingMock
1010

1111

12-
std_logger = logging.getLogger(__name__)
13-
14-
15-
def test_logging_instrument_simple() -> None:
16-
log_capture = LogCapture()
12+
def test_logging_instrument_simple(logging_mock: LoggingMock) -> None:
1713
logging_instrument = LoggingInstrument(
1814
bootstrap_config=LoggingConfig(
1915
logging_unset_handlers=["uvicorn"],
2016
logging_buffer_capacity=0,
2117
service_debug=False,
22-
logging_extra_processors=[log_capture],
18+
logging_extra_processors=[logging_mock],
2319
)
2420
)
2521
try:
2622
logging_instrument.bootstrap()
2723

24+
std_logger = logging.getLogger(__name__)
25+
std_logger.info("testing std logger", extra={"key": "value"})
26+
2827
logger = structlog.getLogger(__name__)
2928
logger.info("testing structlog", key="value")
30-
std_logger.info("testing std logger", extra={"key": "value"})
29+
3130
try:
3231
msg = "some error"
3332
raise ValueError(msg) # noqa: TRY301
3433
except ValueError:
3534
logger.exception("logging error")
3635

37-
events_number = 2
38-
assert len(log_capture.entries) == events_number
36+
events_number = 3
37+
assert len(logging_mock.entries) == events_number
3938
finally:
4039
logging_instrument.teardown()
4140

4241

43-
def test_logging_instrument_tracer_injection() -> None:
44-
log_capture = LogCapture()
42+
def test_logging_instrument_tracer_injection(logging_mock: LoggingMock) -> None:
4543
logging_instrument = LoggingInstrument(
4644
bootstrap_config=LoggingConfig(
4745
logging_unset_handlers=["uvicorn"],
4846
logging_buffer_capacity=0,
49-
logging_extra_processors=[log_capture],
47+
logging_extra_processors=[logging_mock],
5048
)
5149
)
5250
opentelemetry_instrument = OpenTelemetryInstrument(
@@ -67,13 +65,13 @@ def test_logging_instrument_tracer_injection() -> None:
6765
span.add_event("example_event", {"event_attr": 1})
6866
logger.info("testing tracer injection with span attributes")
6967

70-
assert log_capture.entries[0]["event"] == "testing tracer injection without spans"
68+
assert logging_mock.entries[0]["event"] == "testing tracer injection without spans"
7169

72-
assert log_capture.entries[1]["event"] == "testing tracer injection without span attributes"
73-
assert log_capture.entries[2]["event"] == "testing tracer injection with span attributes"
70+
assert logging_mock.entries[1]["event"] == "testing tracer injection without span attributes"
71+
assert logging_mock.entries[2]["event"] == "testing tracer injection with span attributes"
7472

75-
tracing1 = log_capture.entries[1]["tracing"]
76-
tracing2 = log_capture.entries[2]["tracing"]
73+
tracing1 = logging_mock.entries[1]["tracing"]
74+
tracing2 = logging_mock.entries[2]["tracing"]
7775
assert tracing1
7876
assert tracing1 == tracing2
7977
finally:

tests/instruments/test_sentry_instrument.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1+
import copy
12
import logging
23
import typing
34

45
import pytest
56
import sentry_sdk
7+
import structlog
8+
from sentry_sdk.integrations.logging import LoggingIntegration
69

7-
from tests.conftest import SentryTestTransport
10+
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
11+
from tests.conftest import LoggingMock, SentryTestTransport
812

913

1014
if typing.TYPE_CHECKING:
11-
pass
15+
from sentry_sdk import _types as sentry_types
1216

1317
from lite_bootstrap.instruments.sentry_instrument import (
1418
SentryConfig,
1519
SentryInstrument,
20+
enrich_sentry_event_from_structlog_log,
1621
)
1722

1823

19-
logger = logging.getLogger(__name__)
24+
std_logger = logging.getLogger(__name__)
25+
logger = structlog.getLogger(__name__)
2026

2127

2228
@pytest.fixture
@@ -31,12 +37,80 @@ def minimal_sentry_config(sentry_mock: SentryTestTransport) -> SentryConfig:
3137
def test_sentry_instrument_with_raise(minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport) -> None:
3238
SentryInstrument(bootstrap_config=minimal_sentry_config).bootstrap()
3339

40+
try:
41+
std_logger.error("some error")
42+
assert len(sentry_mock.mock_envelopes) == 1
43+
finally:
44+
sentry_sdk.init()
45+
46+
47+
def test_sentry_instrument_with_structlog_error(
48+
minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, logging_mock: LoggingMock
49+
) -> None:
50+
SentryInstrument(bootstrap_config=minimal_sentry_config).bootstrap()
51+
logging_instrument = LoggingInstrument(
52+
bootstrap_config=LoggingConfig(
53+
logging_unset_handlers=["uvicorn"],
54+
logging_buffer_capacity=0,
55+
service_debug=False,
56+
logging_extra_processors=[logging_mock],
57+
)
58+
)
59+
logging_instrument.bootstrap()
60+
3461
try:
3562
logger.error("some error")
63+
logger.error("some error, skipping sentry", skip_sentry=True)
3664
assert len(sentry_mock.mock_envelopes) == 1
65+
LoggingIntegration()
3766
finally:
3867
sentry_sdk.init()
68+
logging_instrument.teardown()
3969

4070

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

0 commit comments

Comments
 (0)