diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 3670426..714a7ea 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -17,6 +17,8 @@ if import_checker.is_sentry_installed: import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration + from sentry_sdk.scrubber import EventScrubber + from sentry_sdk.serializer import serialize from sentry_sdk.utils import event_from_exception @@ -149,6 +151,13 @@ def warn_about_sentry_integrations_installed_earlier(integrations: typing.Sequen ) +def _scrub_structlog_extra(extra: dict[str, typing.Any]) -> dict[str, typing.Any]: + options = sentry_sdk.get_client().options + scrubber = options["event_scrubber"] or EventScrubber(recursive=True, send_default_pii=options["send_default_pii"]) + scrubber.scrub_dict(extra) + return serialize({"extra": extra})["extra"] + + def enrich_sentry_event_from_structlog_log( event: "sentry_types.Event", _: "sentry_types.Hint" ) -> typing.Optional["sentry_types.Event"]: @@ -165,12 +174,17 @@ def enrich_sentry_event_from_structlog_log( return event if payload.skip_sentry: return None - if not payload.message: + if not isinstance(payload.message, str): return event - event["logentry"]["formatted"] = payload.message # ty: ignore[invalid-assignment] + event["logentry"] = {**logentry, "formatted": payload.message, "message": payload.message, "params": []} if payload.extra: - event["contexts"]["structlog"] = payload.extra + event["contexts"]["structlog"] = _scrub_structlog_extra(payload.extra) + # Logging frames can retain the original rendered line in their locals. + for group in ("threads", "exception"): + for value in event.get(group, {}).get("values", []): + for frame in value.get("stacktrace", {}).get("frames", []): + frame.pop("vars", None) return event @@ -194,6 +208,27 @@ def run_before_send( return run_before_send +def wrap_before_breadcrumb( + callback: "sentry_types.BreadcrumbProcessor | None", +) -> "sentry_types.BreadcrumbProcessor": + def run_before_breadcrumb( + breadcrumb: "sentry_types.Breadcrumb", hint: "sentry_types.BreadcrumbHint" + ) -> "sentry_types.Breadcrumb | None": + message = breadcrumb.get("message") + if "log_record" in hint and isinstance(message, str): + payload = StructuredLogPayload.parse(message) + if payload is not None: + if payload.skip_sentry: + return None + if isinstance(payload.message, str): + breadcrumb["message"] = payload.message + if payload.extra: + breadcrumb["data"] = {**breadcrumb.get("data", {}), **_scrub_structlog_extra(payload.extra)} + return callback(breadcrumb, hint) if callback is not None else breadcrumb + + return run_before_breadcrumb + + SentryConfigT = typing.TypeVar("SentryConfigT", bound=SentryConfig) @@ -240,13 +275,15 @@ def _build_init_params(self) -> dict[str, typing.Any]: "default_integrations": config.sentry_default_integrations, "auto_enabling_integrations": config.is_sentry_auto_enabling_integrations_on(), "data_collection": DEFAULT_DATA_COLLECTION, - "before_send": wrap_before_send_callbacks( - enrich_sentry_event_from_structlog_log, config.sentry_before_send - ), + "before_send": config.sentry_before_send, } if config.service_version: init_params["release"] = config.service_version init_params.update(config.sentry_additional_params) + init_params["before_send"] = wrap_before_send_callbacks( + enrich_sentry_event_from_structlog_log, init_params["before_send"] + ) + init_params["before_breadcrumb"] = wrap_before_breadcrumb(init_params.get("before_breadcrumb")) return init_params def bootstrap(self) -> None: diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 758ef80..57d4aaf 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -2,6 +2,7 @@ import dataclasses import logging import typing +import uuid from unittest.mock import patch import pytest @@ -114,7 +115,7 @@ def test_drops_event_on_skip_sentry_before_message_check(self) -> None: [ ( {"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}}, - {"logentry": {"formatted": "event name"}, "contexts": {}}, + {"logentry": {"formatted": "event name", "message": "event name", "params": []}, "contexts": {}}, ), ( { @@ -124,7 +125,7 @@ def test_drops_event_on_skip_sentry_before_message_check(self) -> None: "contexts": {}, }, { - "logentry": {"formatted": "event name"}, + "logentry": {"formatted": "event name", "message": "event name", "params": []}, "contexts": {"structlog": {"foo": "bar"}}, }, ), @@ -493,3 +494,65 @@ def test_sentry_release_follows_service_version( assert sentry_sdk.get_client().options["release"] == expected_release finally: instrument.teardown() + + +@pytest.mark.parametrize("attach_stacktrace", [False, True]) +def test_structlog_envelope_redacts_kwargs_and_excludes_skipped_breadcrumbs( + minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, *, attach_stacktrace: bool +) -> None: + def before_breadcrumb( + crumb: "sentry_types.Breadcrumb", _: "sentry_types.BreadcrumbHint" + ) -> "sentry_types.Breadcrumb | None": + if crumb.get("message") == "dropped by hook": + return None + return crumb + + def before_send(event: "sentry_types.Event", _: "sentry_types.Hint") -> "sentry_types.Event": + event.setdefault("tags", {})["hook"] = "called" + return event + + config = dataclasses.replace( + minimal_sentry_config, + sentry_attach_stacktrace=attach_stacktrace, + sentry_additional_params={ + **minimal_sentry_config.sentry_additional_params, + "before_breadcrumb": before_breadcrumb, + "before_send": before_send, + }, + ) + sentry = SentryInstrument(bootstrap_config=config) + logs = LoggingInstrument(bootstrap_config=LoggingConfig()) + sentry.bootstrap() + logs.bootstrap() + try: + marker = uuid.uuid4().hex + skipped = uuid.uuid4().hex + logger.warning("breadcrumb", password=marker, component="worker") + logger.warning(skipped, skip_sentry=True) + std_logger.warning("dropped by hook") + logger.error("error", password=marker, component="worker") + logger.error("error", component="other-worker") + logger.error("") + first, repeated, second = sentry_mock.mock_envelopes + for envelope in sentry_mock.mock_envelopes: + assert marker.encode() not in envelope.serialize() + assert skipped.encode() not in envelope.serialize() + event = first.get_event() + assert event is not None + assert ("threads" in event) == attach_stacktrace + repeated_event = repeated.get_event() + assert repeated_event is not None + assert repeated_event["logentry"] == event["logentry"] + assert event["tags"]["hook"] == "called" + assert event["logentry"] == {"message": "error", "formatted": "error", "params": []} + assert event["contexts"]["structlog"]["component"] == "worker" + breadcrumbs = event["breadcrumbs"] + assert isinstance(breadcrumbs, dict) + assert not any(crumb.get("message") == "dropped by hook" for crumb in breadcrumbs["values"]) + assert any(crumb.get("data", {}).get("component") == "worker" for crumb in breadcrumbs["values"]) + empty = second.get_event() + assert empty is not None + assert empty["logentry"] == {"message": "", "formatted": "", "params": []} + finally: + logs.teardown() + sentry.teardown()