diff --git a/AGENTS.md b/AGENTS.md index d667691..73f6924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,4 +82,4 @@ Pyright reports as errors (conditional imports for optional dependencies, covari Two suppressions recur and are both correct: `invalid-method-override` on a framework subclass's `is_configured` classmethod, which narrows its parameter type where `ty` enforces invariance, and -`unresolved-attribute` on the optional OTel/pyroscope symbols whose guard `ty` does not follow. +`missing-attribute` on the optional OTel/pyroscope symbols whose guard `ty` does not follow. diff --git a/lite_bootstrap/instruments/logging_factory.py b/lite_bootstrap/instruments/logging_factory.py index 6f39722..575b9ed 100644 --- a/lite_bootstrap/instruments/logging_factory.py +++ b/lite_bootstrap/instruments/logging_factory.py @@ -9,6 +9,12 @@ import orjson +class _RenderedLine(str): + """A line already rendered by lite-bootstrap's structlog chain.""" + + __slots__ = () + + def _dumps_orjson(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 return orjson.dumps(value, **kwargs).decode() diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index d38d372..2469df5 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -6,19 +6,13 @@ from lite_bootstrap import import_checker from lite_bootstrap.exceptions import collect_teardown_errors from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument -from lite_bootstrap.instruments.logging_factory import _serialize_log_to_string +from lite_bootstrap.instruments.logging_factory import _RenderedLine, _serialize_log_to_string if typing.TYPE_CHECKING: from structlog.typing import EventDict, WrappedLogger -class _RenderedLine(str): - """A line structlog's chain already rendered, which the root handler's formatter emits as is.""" - - __slots__ = () - - if import_checker.is_structlog_installed: import structlog diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index 00d052f..d343f22 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -187,10 +187,10 @@ def _is_root_span(span: "ReadableSpan") -> bool: class PyroscopeSpanProcessor(SpanProcessor): def on_start(self, span: "Span", parent_context: "Context | None" = None) -> None: # noqa: ARG002 if _is_root_span(span): # ty: ignore[invalid-argument-type] - formatted_span_id = format_span_id(span.context.span_id) # ty: ignore[unresolved-attribute] + formatted_span_id = format_span_id(span.context.span_id) # ty: ignore[missing-attribute] span.set_attribute(_OTEL_PROFILE_ID_KEY, formatted_span_id) pyroscope.add_thread_tag(_PYROSCOPE_SPAN_ID_KEY, formatted_span_id) - pyroscope.add_thread_tag(_PYROSCOPE_SPAN_NAME_KEY, span.name) # ty: ignore[unresolved-attribute] + pyroscope.add_thread_tag(_PYROSCOPE_SPAN_NAME_KEY, span.name) # ty: ignore[missing-attribute] def on_end(self, span: "ReadableSpan") -> None: if _is_root_span(span): diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index d07c141..2d32891 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -6,7 +6,7 @@ from lite_bootstrap import import_checker from lite_bootstrap.helpers.warn import warn_at_caller from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument -from lite_bootstrap.instruments.logging_factory import StructuredLogPayload +from lite_bootstrap.instruments.logging_factory import StructuredLogPayload, _RenderedLine if typing.TYPE_CHECKING: @@ -173,6 +173,14 @@ def _scrub_structlog_extra(extra: dict[str, typing.Any]) -> dict[str, typing.Any return serialize({"extra": extra})["extra"] +def _structlog_message(payload: StructuredLogPayload, record: object) -> str | None: + if isinstance(payload.message, str): + return payload.message + if isinstance(record, logging.LogRecord) and type(record.msg) is _RenderedLine: + return "[No message]" + return None + + def enrich_sentry_event_from_structlog_log( event: "sentry_types.Event", hint: "sentry_types.Hint" ) -> typing.Optional["sentry_types.Event"]: @@ -193,10 +201,11 @@ def enrich_sentry_event_from_structlog_log( return event if payload.skip_sentry: return None - if not isinstance(payload.message, str): + message = _structlog_message(payload, record) + if message is None: return event - event["logentry"] = {**logentry, "formatted": payload.message, "message": payload.message, "params": []} + event["logentry"] = {**logentry, "formatted": message, "message": message, "params": []} if payload.extra: event["contexts"]["structlog"] = _scrub_structlog_extra(payload.extra) # Logging frames can retain the original rendered line in their locals. @@ -239,8 +248,8 @@ def run_before_breadcrumb( if payload is not None: if payload.skip_sentry: return None - if isinstance(payload.message, str): - breadcrumb["message"] = payload.message + if (normalized_message := _structlog_message(payload, hint["log_record"])) is not None: + breadcrumb["message"] = normalized_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 diff --git a/scripts/ft_smoke.py b/scripts/ft_smoke.py index b9305c1..b37d332 100644 --- a/scripts/ft_smoke.py +++ b/scripts/ft_smoke.py @@ -17,7 +17,7 @@ def main() -> None: - assert sys._is_gil_enabled() is False, "expected a free-threaded interpreter" # noqa: SLF001 # ty: ignore[unresolved-attribute] + assert sys._is_gil_enabled() is False, "expected a free-threaded interpreter" # noqa: SLF001 # ty: ignore[missing-attribute] assert import_checker.is_orjson_installed is False, "orjson must be absent on the ft leg" formatted = _serialize_log_to_string({"event": "ft ok", "level": "info", "n": 1}) diff --git a/tests/instruments/test_opentelemetry_instrument.py b/tests/instruments/test_opentelemetry_instrument.py index 07e9620..3405eec 100644 --- a/tests/instruments/test_opentelemetry_instrument.py +++ b/tests/instruments/test_opentelemetry_instrument.py @@ -380,7 +380,7 @@ def test_insecure_warning_fires_exactly_when_the_grpc_exporter_sends_plaintext( exporter = instrument._build_span_exporter() if signal == "traces" else instrument._build_metric_exporter() # noqa: SLF001 assert exporter is not None try: - assert exporter._insecure is expected_plaintext # noqa: SLF001 # ty: ignore[unresolved-attribute] + assert exporter._insecure is expected_plaintext # noqa: SLF001 # ty: ignore[missing-attribute] assert any("unencrypted" in str(w.message) for w in caught) is expected_plaintext finally: exporter.shutdown() diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 1c0c511..77fb637 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -668,6 +668,41 @@ def before_send(event: "sentry_types.Event", _: "sentry_types.Hint") -> "sentry_ sentry.teardown() +def test_message_less_structlog_error_is_scrubbed_in_sentry_envelopes( + minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, capsys: pytest.CaptureFixture[str] +) -> None: + marker = uuid.uuid4().hex + sentry = SentryInstrument(bootstrap_config=minimal_sentry_config) + logs = LoggingInstrument(bootstrap_config=LoggingConfig()) + sentry.bootstrap() + logs.bootstrap() + try: + logger.error(password=marker, component="worker") + logger.error("later error") + + first_line, second_line = map(json.loads, capsys.readouterr().out.splitlines()) + assert "event" not in first_line + assert first_line["password"] == marker + assert first_line["component"] == "worker" + assert second_line["event"] == "later error" + + first, second = sentry_mock.mock_envelopes + assert marker.encode() not in first.serialize() + assert marker.encode() not in second.serialize() + first_event = first.get_event() + assert first_event is not None + assert first_event["contexts"]["structlog"]["component"] == "worker" + second_event = second.get_event() + assert second_event is not None + assert second_event["logentry"]["message"] == "later error" + breadcrumbs = second_event["breadcrumbs"] + assert isinstance(breadcrumbs, dict) + assert any(crumb.get("data", {}).get("component") == "worker" for crumb in breadcrumbs["values"]) + finally: + logs.teardown() + sentry.teardown() + + @pytest.mark.parametrize("template", ["plain %s", "{invalid %s", '{"unrelated":"%s"}']) def test_unstructured_log_keeps_sdk_truncation( minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, template: str