Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"]:
Expand All @@ -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


Expand All @@ -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)


Expand Down Expand Up @@ -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:
Expand Down
67 changes: 65 additions & 2 deletions tests/instruments/test_sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import dataclasses
import logging
import typing
import uuid
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -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": {}},
),
(
{
Expand All @@ -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"}},
},
),
Expand Down Expand Up @@ -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()
Loading