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: 2 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/httpware/_internal/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
23 changes: 23 additions & 0 deletions tests/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading