diff --git a/microbootstrap/instruments/logging_instrument.py b/microbootstrap/instruments/logging_instrument.py index 07839a2..be4f81a 100644 --- a/microbootstrap/instruments/logging_instrument.py +++ b/microbootstrap/instruments/logging_instrument.py @@ -93,6 +93,19 @@ def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any serializer=_serialize_log_with_orjson_to_string ) +_FAKER_STDLIB_LOGGER = logging.getLogger("microbootstrap.structlog") +_FAKER_STDLIB_LOGGER.propagate = False +_FAKER_STDLIB_LOGGER.addHandler(logging.NullHandler()) + + +def redirect_json_log_to_stdlib(_: WrappedLogger, __: str, event_dict: EventDict) -> EventDict: + __tracebackhide__ = True + getattr(_FAKER_STDLIB_LOGGER, event_dict["level"])( + STRUCTLOG_FORMATTER_PROCESSOR(_, __, event_dict), + exc_info=event_dict.get("exc_info", bool(event_dict.get("exception", False))), + ) + return event_dict + class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): def __init__( @@ -161,6 +174,13 @@ def _unset_handlers(self) -> None: def _configure_structlog_loggers(self) -> None: if self.instrument_config.service_debug: + structlog.configure( + processors=[ + *structlog.get_config()["processors"][:-1], + redirect_json_log_to_stdlib, # ensure log is sent to Sentry + structlog.get_config()["processors"][-1], + ] + ) return structlog.configure( processors=[ diff --git a/tests/instruments/test_sentry.py b/tests/instruments/test_sentry.py index e0e4818..bdc541d 100644 --- a/tests/instruments/test_sentry.py +++ b/tests/instruments/test_sentry.py @@ -1,13 +1,16 @@ from __future__ import annotations import copy +import logging import typing from unittest import mock import litestar import pytest +import structlog from litestar.testing import TestClient as LitestarTestClient from microbootstrap.bootstrappers.litestar import LitestarSentryInstrument +from microbootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument from microbootstrap.instruments.sentry_instrument import ( SENTRY_EXTRA_OTEL_TRACE_ID_KEY, SENTRY_EXTRA_OTEL_TRACE_URL_KEY, @@ -160,3 +163,31 @@ def test_add_trace_url_creates_contexts(self, faker: faker.Faker, event: sentry_ assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY in result["extra"] assert SENTRY_EXTRA_OTEL_TRACE_ID_KEY in result["extra"] + + +@pytest.mark.parametrize("logger_instance", [structlog.get_logger(__name__), logging.getLogger(__name__)]) +@pytest.mark.parametrize("is_exception", [True, False]) +@pytest.mark.parametrize("service_debug", [True, False]) +def test_sentry_captures_structlog_logs( # noqa: PLR0913 + logger_instance: logging.Logger, + is_exception: bool, + service_debug: bool, + monkeypatch: pytest.MonkeyPatch, + faker: faker.Faker, + minimal_sentry_config: SentryConfig, +) -> None: + monkeypatch.setattr("sentry_sdk.Scope.capture_event", capture_mock := mock.Mock()) + SentryInstrument(minimal_sentry_config).bootstrap() + LoggingInstrument(LoggingConfig(service_debug=service_debug)).bootstrap() + + if is_exception: + try: + _ = 1 / 0 + except ZeroDivisionError: + logger_instance.exception("in exception") + else: + logger_instance.error(faker.pystr()) + + assert capture_mock.mock_calls + if service_debug: + assert bool(capture_mock.mock_calls[0].args[0].get("exception")) == is_exception