Skip to content

Commit a77cef6

Browse files
lesnik512claude
andcommitted
fix(observability): wrap lazy OTel import in try/except ImportError
_emit_event gated the OTel path on `if import_checker.is_otel_installed` but ran `from opentelemetry import trace` unguarded. If is_otel_installed was True yet the import failed (PEP 420 namespace false-positive in 0.8.3 and earlier; or any future partial install / broken api package), the ImportError escaped _emit_event and crashed the middleware calling it (AsyncRetry, Retry, AsyncBulkhead, Bulkhead) mid-request. Wrap the lazy import in `try/except ImportError`. On failure, return — the structured log record on the line above has already fired, so emission degrades to log-only. Catch ImportError specifically, not bare Exception: misconfigured-tracer crashes (RuntimeError, AttributeError) should still surface; only the install-gate-is-wrong case is in scope. _emit_event's docstring grows one sentence describing the soft-fallback. Closes audit Low finding (observability.py:40) from planning/audit/2026-06-07-deep-audit.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06dfb75 commit a77cef6

2 files changed

Lines changed: 54 additions & 3 deletions

File tree

src/httpware/_internal/observability.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,20 @@ def _emit_event(
2929
``trace.get_current_span().add_event(event_name, attributes=attributes)``.
3030
When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan``
3131
whose ``add_event`` is a documented no-op — so the call is unconditional
32-
behind the install gate.
32+
behind the install gate. If the install gate is wrong (the namespace exists
33+
but the api package is missing or broken), the lazy import raises
34+
``ImportError``; we degrade silently to log-only emission.
3335
3436
The lazy ``from opentelemetry import trace`` inside the if-block preserves
3537
the optional-extras isolation invariant: ``import httpware`` must not pull
3638
``opentelemetry`` into ``sys.modules`` when the extra is absent.
3739
"""
3840
logger.log(level, message, extra=attributes)
3941
if import_checker.is_otel_installed:
40-
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
41-
42+
try:
43+
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
44+
except ImportError:
45+
# opentelemetry namespace exists but the api package is broken or missing —
46+
# degrade to log-only emission. The structured log record above has already fired.
47+
return
4248
trace.get_current_span().add_event(event_name, attributes=attributes)

tests/test_optional_extras_otel_missing.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import logging
14+
import sys
1415
from importlib.metadata import distribution
1516
from unittest.mock import patch
1617

@@ -87,3 +88,47 @@ def test_is_otel_installed_uses_opentelemetry_trace_probe() -> None:
8788
"(PEP 420 namespace hazard + sys.modules side-effect); "
8889
"see planning/audit/2026-06-07-deep-audit.md (Low finding on import_checker.py:8)."
8990
)
91+
92+
93+
def test_emit_event_survives_lazy_import_failure(caplog: pytest.LogCaptureFixture) -> None:
94+
"""_emit_event degrades to log-only when the lazy OTel import fails.
95+
96+
When is_otel_installed=True but ``from opentelemetry import trace`` raises
97+
ImportError, _emit_event must not crash — it emits the structured log record
98+
and returns.
99+
100+
Simulates the partial-install case: opentelemetry/ namespace directory exists
101+
(created by some instrumentation package) but opentelemetry-api is missing or
102+
broken, so importing ``trace`` from it fails.
103+
"""
104+
105+
class _BrokenOpenTelemetry:
106+
"""Stand-in for opentelemetry/ namespace directory without working api."""
107+
108+
def __getattr__(self, name: str) -> object:
109+
msg = f"cannot import name {name!r} from 'opentelemetry'"
110+
raise ImportError(msg)
111+
112+
# Save and replace the real opentelemetry module for the duration of the test.
113+
saved = sys.modules.pop("opentelemetry", None)
114+
sys.modules["opentelemetry"] = _BrokenOpenTelemetry() # ty: ignore[invalid-assignment]
115+
try:
116+
with (
117+
patch("httpware._internal.import_checker.is_otel_installed", True),
118+
caplog.at_level(logging.WARNING, logger="httpware.test.otel_missing"),
119+
):
120+
_emit_event(
121+
_TEST_LOGGER,
122+
"test.event",
123+
level=logging.WARNING,
124+
message="survives broken otel",
125+
attributes={"k": "v"},
126+
)
127+
finally:
128+
if saved is not None:
129+
sys.modules["opentelemetry"] = saved
130+
else:
131+
sys.modules.pop("opentelemetry", None)
132+
133+
# The structured log record still fired despite the OTel branch failing.
134+
assert any(r.message == "survives broken otel" for r in caplog.records)

0 commit comments

Comments
 (0)