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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions lite_bootstrap/instruments/logging_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 1 addition & 7 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
19 changes: 14 additions & 5 deletions lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]:
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/ft_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
2 changes: 1 addition & 1 deletion tests/instruments/test_opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions tests/instruments/test_sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading