diff --git a/docs/observability.md b/docs/observability.md index 2b55554..123f5f9 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -35,6 +35,8 @@ pip install httpware[otel] httpware then adds each event to the current span with `trace.get_current_span().add_event(...)`. It never creates spans itself, so the events only show up when something else has started one. The next section shows the minimal setup that does. +A span event leaves out any attribute whose value is `None`, such as `last_status` when the last attempt raised instead of returning a response, because OpenTelemetry SDKs before 1.45 reject `None` values. The log record still has them. + ## Wiring OpenTelemetry `httpware[otel]` only installs `opentelemetry-api`. To see the events you also need `opentelemetry-sdk` to collect spans, and `opentelemetry-instrumentation-httpx` to create a span for each HTTP call. httpware's events attach to that span. diff --git a/src/httpware/_internal/observability.py b/src/httpware/_internal/observability.py index 0b25458..23a0863 100644 --- a/src/httpware/_internal/observability.py +++ b/src/httpware/_internal/observability.py @@ -33,7 +33,9 @@ def _emit_event( 1. Always emits a structured log record at ``level`` with ``extra=attributes`` (so log aggregators that index ``extra`` see structured fields). 2. If ``opentelemetry-api`` is installed, calls - ``trace.get_current_span().add_event(event_name, attributes=attributes)``. + ``trace.get_current_span().add_event(event_name, attributes=attributes)``, + leaving out attributes whose value is ``None``: OTel SDKs before 1.45 reject + them with a warning. The log record keeps them. When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan`` whose ``add_event`` is a documented no-op — so the call is unconditional behind the install gate. If the install gate is wrong (the namespace exists @@ -58,5 +60,6 @@ def _emit_event( # add_event (e.g. a recording span with a broken exporter or attribute validation). # The structured log record above has already fired; CancelledError/KeyboardInterrupt # are not Exception subclasses and will still propagate. + span_attributes = {key: value for key, value in safe_attributes.items() if value is not None} with contextlib.suppress(Exception): - trace.get_current_span().add_event(event_name, attributes=safe_attributes) + trace.get_current_span().add_event(event_name, attributes=span_attributes) diff --git a/tests/test_observability.py b/tests/test_observability.py index f4e6f7c..4e00044 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -158,3 +158,26 @@ def test_emit_event_swallows_add_event_failure() -> None: attributes={"k": "v"}, ) mock_span.add_event.assert_called_once() + + +def test_emit_event_drops_none_attributes_from_otel_event_only(caplog: pytest.LogCaptureFixture) -> None: + """OTel SDKs before 1.45 reject None attribute values, so the span event omits them; the log record keeps them.""" + mock_span = MagicMock(name="MockSpan") + with ( + caplog.at_level(logging.WARNING, logger="httpware.test.observability"), + patch("httpware._internal.import_checker.is_otel_installed", True), + patch("opentelemetry.trace.get_current_span", return_value=mock_span), + ): + _emit_event( + _TEST_LOGGER, + "test.event", + level=logging.WARNING, + message="partial", + attributes={"last_status": None, "url": "https://example.test/x?token=s", "attempts": 3}, + ) + + mock_span.add_event.assert_called_once_with( + "test.event", + attributes={"url": "https://example.test/x?token=REDACTED", "attempts": 3}, + ) + assert caplog.records[0].last_status is None # ty: ignore[unresolved-attribute]