diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 0feaf3f..1984334 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -4,6 +4,8 @@ import sys import typing +import orjson + from lite_bootstrap import import_checker from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -13,9 +15,7 @@ if import_checker.is_structlog_installed: - import orjson import structlog - from structlog.processors import ExceptionRenderer ScopeType = typing.MutableMapping[str, typing.Any] @@ -97,15 +97,6 @@ class LoggingConfig(BaseConfig): ) -class CustomExceptionRenderer(ExceptionRenderer): - def __call__(self, logger: "WrappedLogger", name: str, event_dict: "EventDict") -> "EventDict": - exc_info = event_dict.get("exc_info") - event_dict = super().__call__(logger=logger, name=name, event_dict=event_dict) - if exc_info: - event_dict["exc_info"] = exc_info - return event_dict - - @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class LoggingInstrument(BaseInstrument): bootstrap_config: LoggingConfig @@ -121,7 +112,7 @@ def structlog_pre_chain_processors(self) -> list[typing.Any]: structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"), structlog.processors.StackInfoRenderer(), - CustomExceptionRenderer(), + structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), ] @@ -162,7 +153,8 @@ def _configure_foreign_loggers(self) -> None: foreign_pre_chain=self.structlog_pre_chain_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, - structlog.processors.JSONRenderer(), + *self.bootstrap_config.logging_extra_processors, + structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), ], logger=root_logger, ) @@ -177,3 +169,8 @@ def bootstrap(self) -> None: def teardown(self) -> None: structlog.reset_defaults() + root_logger = logging.getLogger() + for h in root_logger.handlers[:]: + root_logger.removeHandler(h) + h.close() + root_logger.setLevel(logging.WARNING) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index 2bd0780..5cb854d 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -70,7 +70,7 @@ def bootstrap(self) -> None: tracer_provider = TracerProvider(resource=resource) if self.bootstrap_config.opentelemetry_log_traces: tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) - if self.bootstrap_config.opentelemetry_endpoint: + if self.bootstrap_config.opentelemetry_endpoint: # pragma: no cover tracer_provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter( diff --git a/pyproject.toml b/pyproject.toml index acea905..91e4be6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] version = "0" +dependencies = [ + "orjson", +] [project.urls] repository = "https://github.com/modern-python/lite-bootstrap" @@ -45,7 +48,6 @@ otl = [ ] logging = [ "structlog", - "orjson", ] free-all = [ "lite-bootstrap[sentry,otl,logging]", diff --git a/tests/conftest.py b/tests/conftest.py index 1fe8c27..e22d74a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,9 @@ import sys import typing from importlib import reload -from unittest.mock import Mock import pytest +import sentry_sdk from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] from structlog.testing import capture_logs from structlog.typing import EventDict @@ -20,9 +20,21 @@ def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None: pass -@pytest.fixture(autouse=True) -def mock_sentry_init(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("sentry_sdk.init", Mock) +P = typing.ParamSpec("P") + + +class SentryTestTransport(sentry_sdk.Transport): + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 + super().__init__(*args, **kwargs) + self.mock_envelopes: list[sentry_sdk.envelope.Envelope] = [] + + def capture_envelope(self, envelope: sentry_sdk.envelope.Envelope) -> None: + self.mock_envelopes.append(envelope) + + +@pytest.fixture +def sentry_mock() -> SentryTestTransport: + return SentryTestTransport() @contextlib.contextmanager diff --git a/tests/instruments/test_logging_instrument.py b/tests/instruments/test_logging_instrument.py index 2fdce14..5ad4bbc 100644 --- a/tests/instruments/test_logging_instrument.py +++ b/tests/instruments/test_logging_instrument.py @@ -3,47 +3,62 @@ import structlog from opentelemetry.trace import get_tracer +from structlog.testing import LogCapture from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -logger = structlog.getLogger(__name__) std_logger = logging.getLogger(__name__) def test_logging_instrument_simple() -> None: + log_capture = LogCapture() logging_instrument = LoggingInstrument( bootstrap_config=LoggingConfig( - logging_unset_handlers=["uvicorn"], logging_buffer_capacity=0, service_debug=False + logging_unset_handlers=["uvicorn"], + logging_buffer_capacity=0, + service_debug=False, + logging_extra_processors=[log_capture], ) ) try: logging_instrument.bootstrap() + + logger = structlog.getLogger(__name__) logger.info("testing structlog", key="value") + std_logger.info("testing std logger", extra={"key": "value"}) try: msg = "some error" raise ValueError(msg) # noqa: TRY301 except ValueError: logger.exception("logging error") - std_logger.info("testing std logger", extra={"key": "value"}) + + events_number = 2 + assert len(log_capture.entries) == events_number finally: logging_instrument.teardown() def test_logging_instrument_tracer_injection() -> None: + log_capture = LogCapture() logging_instrument = LoggingInstrument( - bootstrap_config=LoggingConfig(logging_unset_handlers=["uvicorn"], logging_buffer_capacity=0) + bootstrap_config=LoggingConfig( + logging_unset_handlers=["uvicorn"], + logging_buffer_capacity=0, + logging_extra_processors=[log_capture], + ) ) opentelemetry_instrument = OpenTelemetryInstrument( bootstrap_config=OpentelemetryConfig( - opentelemetry_endpoint="otl", opentelemetry_log_traces=True, ) ) try: logging_instrument.bootstrap() opentelemetry_instrument.bootstrap() + + logger = structlog.getLogger(__name__) tracer = get_tracer(__name__) logger.info("testing tracer injection without spans") with tracer.start_as_current_span("my_fake_span") as span: @@ -51,6 +66,16 @@ def test_logging_instrument_tracer_injection() -> None: span.set_attribute("example_attribute", "value") span.add_event("example_event", {"event_attr": 1}) logger.info("testing tracer injection with span attributes") + + assert log_capture.entries[0]["event"] == "testing tracer injection without spans" + + assert log_capture.entries[1]["event"] == "testing tracer injection without span attributes" + assert log_capture.entries[2]["event"] == "testing tracer injection with span attributes" + + tracing1 = log_capture.entries[1]["tracing"] + tracing2 = log_capture.entries[2]["tracing"] + assert tracing1 + assert tracing1 == tracing2 finally: logging_instrument.teardown() opentelemetry_instrument.teardown() diff --git a/tests/instruments/test_opentelemetry_instrument.py b/tests/instruments/test_opentelemetry_instrument.py index 6a06958..0fbccd7 100644 --- a/tests/instruments/test_opentelemetry_instrument.py +++ b/tests/instruments/test_opentelemetry_instrument.py @@ -9,7 +9,6 @@ def test_opentelemetry_instrument() -> None: opentelemetry_instrument = OpenTelemetryInstrument( bootstrap_config=OpentelemetryConfig( - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[ InstrumentorWithParams(instrumentor=CustomInstrumentor(), additional_params={"key": "value"}), CustomInstrumentor(), @@ -26,7 +25,6 @@ def test_opentelemetry_instrument() -> None: def test_opentelemetry_instrument_empty_instruments() -> None: opentelemetry_instrument = OpenTelemetryInstrument( bootstrap_config=OpentelemetryConfig( - opentelemetry_endpoint="otl", opentelemetry_log_traces=True, ) ) diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 37500e2..2d70761 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -1,10 +1,41 @@ -from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument +import logging +import typing +import pytest +import sentry_sdk -def test_sentry_instrument() -> None: - SentryInstrument( - bootstrap_config=SentryConfig(sentry_dsn="https://testdsn@localhost/1", sentry_tags={"tag": "value"}) - ).bootstrap() +from tests.conftest import SentryTestTransport + + +if typing.TYPE_CHECKING: + pass + +from lite_bootstrap.instruments.sentry_instrument import ( + SentryConfig, + SentryInstrument, +) + + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def minimal_sentry_config(sentry_mock: SentryTestTransport) -> SentryConfig: + return SentryConfig( + sentry_dsn="https://testdsn@localhost/1", + sentry_tags={"test": "test"}, + sentry_additional_params={"transport": sentry_mock}, + ) + + +def test_sentry_instrument_with_raise(minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport) -> None: + SentryInstrument(bootstrap_config=minimal_sentry_config).bootstrap() + + try: + logger.error("some error") + assert len(sentry_mock.mock_envelopes) == 1 + finally: + sentry_sdk.init() def test_sentry_instrument_empty_dsn() -> None: diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 1f2c32d..0ab2fdf 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -8,7 +8,7 @@ from starlette.testclient import TestClient from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig -from tests.conftest import CustomInstrumentor, emulate_package_missing +from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing logger = structlog.getLogger(__name__) @@ -25,12 +25,12 @@ def fastapi_config() -> FastAPIConfig: cors_allowed_origins=["http://test"], health_checks_path="/custom-health/", logging_buffer_capacity=0, - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[CustomInstrumentor()], opentelemetry_log_traces=True, opentelemetry_generate_health_check_spans=False, prometheus_metrics_path="/custom-metrics/", sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": SentryTestTransport()}, swagger_offline_docs=True, ) @@ -75,7 +75,7 @@ async def home() -> str: test_client.get("/") stdout = capsys.readouterr().out - assert '"event": "std logger", "level": "info", "logger": "root"' in stdout + assert '"event":"std logger","level":"info","logger":"root"' in stdout assert stdout.count("std logger") == 1 diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index 3f999e8..5c04d67 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -11,7 +11,7 @@ from starlette.testclient import TestClient from lite_bootstrap import FastStreamBootstrapper, FastStreamConfig -from tests.conftest import CustomInstrumentor, emulate_package_missing +from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing logger = structlog.getLogger(__name__) @@ -30,13 +30,13 @@ def build_faststream_config( service_version="2.0.0", service_environment="test", service_debug=False, - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[CustomInstrumentor()], opentelemetry_log_traces=True, opentelemetry_middleware_cls=RedisTelemetryMiddleware, prometheus_metrics_path="/custom-metrics/", prometheus_middleware_cls=RedisPrometheusMiddleware, sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": SentryTestTransport()}, health_checks_path="/custom-health/", logging_buffer_capacity=0, application=faststream.asgi.AsgiFastStream( diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index aa1bff8..37e8143 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -3,7 +3,7 @@ from structlog.typing import EventDict from lite_bootstrap import FreeBootstrapper, FreeBootstrapperConfig -from tests.conftest import CustomInstrumentor, emulate_package_missing +from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing logger = structlog.getLogger(__name__) @@ -13,7 +13,6 @@ def free_bootstrapper_config() -> FreeBootstrapperConfig: return FreeBootstrapperConfig( service_debug=False, - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[CustomInstrumentor()], opentelemetry_log_traces=True, sentry_dsn="https://testdsn@localhost/1", @@ -34,10 +33,10 @@ def test_free_bootstrap_logging_not_ready(log_output: list[EventDict]) -> None: FreeBootstrapper( bootstrap_config=FreeBootstrapperConfig( service_debug=True, - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[CustomInstrumentor()], opentelemetry_log_traces=True, sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": SentryTestTransport()}, logging_buffer_capacity=0, ), ) diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 4cb1387..7ac07db 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -4,7 +4,7 @@ from litestar.testing import TestClient from lite_bootstrap import LitestarBootstrapper, LitestarConfig -from tests.conftest import CustomInstrumentor, emulate_package_missing +from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing logger = structlog.getLogger(__name__) @@ -19,12 +19,12 @@ def litestar_config() -> LitestarConfig: service_debug=False, cors_allowed_origins=["http://test"], health_checks_path="/custom-health/", - opentelemetry_endpoint="otl", opentelemetry_instrumentors=[CustomInstrumentor()], opentelemetry_log_traces=True, opentelemetry_generate_health_check_spans=False, prometheus_metrics_path="/custom-metrics/", sentry_dsn="https://testdsn@localhost/1", + sentry_additional_params={"transport": SentryTestTransport()}, swagger_offline_docs=True, logging_buffer_capacity=0, )