Skip to content

Commit 89553a6

Browse files
authored
fix: drop None attributes from OTel span events (#190)
* fix: drop None attributes from OTel span events OpenTelemetry SDKs before 1.45 reject None attribute values with a warning, so a retry.giving_up after a raised NetworkError logged a warning and lost last_status. The span event now leaves out None values; the log record keeps them. Closes #171 * docs: keep the span-setup pointer next to the sentence it refers to
1 parent 660146e commit 89553a6

3 files changed

Lines changed: 30 additions & 2 deletions

File tree

‎docs/observability.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pip install httpware[otel]
3535

3636
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.
3737

38+
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.
39+
3840
## Wiring OpenTelemetry
3941

4042
`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.

‎src/httpware/_internal/observability.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def _emit_event(
3333
1. Always emits a structured log record at ``level`` with ``extra=attributes``
3434
(so log aggregators that index ``extra`` see structured fields).
3535
2. If ``opentelemetry-api`` is installed, calls
36-
``trace.get_current_span().add_event(event_name, attributes=attributes)``.
36+
``trace.get_current_span().add_event(event_name, attributes=attributes)``,
37+
leaving out attributes whose value is ``None``: OTel SDKs before 1.45 reject
38+
them with a warning. The log record keeps them.
3739
When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan``
3840
whose ``add_event`` is a documented no-op — so the call is unconditional
3941
behind the install gate. If the install gate is wrong (the namespace exists
@@ -58,5 +60,6 @@ def _emit_event(
5860
# add_event (e.g. a recording span with a broken exporter or attribute validation).
5961
# The structured log record above has already fired; CancelledError/KeyboardInterrupt
6062
# are not Exception subclasses and will still propagate.
63+
span_attributes = {key: value for key, value in safe_attributes.items() if value is not None}
6164
with contextlib.suppress(Exception):
62-
trace.get_current_span().add_event(event_name, attributes=safe_attributes)
65+
trace.get_current_span().add_event(event_name, attributes=span_attributes)

‎tests/test_observability.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,26 @@ def test_emit_event_swallows_add_event_failure() -> None:
158158
attributes={"k": "v"},
159159
)
160160
mock_span.add_event.assert_called_once()
161+
162+
163+
def test_emit_event_drops_none_attributes_from_otel_event_only(caplog: pytest.LogCaptureFixture) -> None:
164+
"""OTel SDKs before 1.45 reject None attribute values, so the span event omits them; the log record keeps them."""
165+
mock_span = MagicMock(name="MockSpan")
166+
with (
167+
caplog.at_level(logging.WARNING, logger="httpware.test.observability"),
168+
patch("httpware._internal.import_checker.is_otel_installed", True),
169+
patch("opentelemetry.trace.get_current_span", return_value=mock_span),
170+
):
171+
_emit_event(
172+
_TEST_LOGGER,
173+
"test.event",
174+
level=logging.WARNING,
175+
message="partial",
176+
attributes={"last_status": None, "url": "https://example.test/x?token=s", "attempts": 3},
177+
)
178+
179+
mock_span.add_event.assert_called_once_with(
180+
"test.event",
181+
attributes={"url": "https://example.test/x?token=REDACTED", "attempts": 3},
182+
)
183+
assert caplog.records[0].last_status is None # ty: ignore[unresolved-attribute]

0 commit comments

Comments
 (0)