Skip to content

Commit d990136

Browse files
committed
fix(python-sdk): make OTel tracing strictly best-effort (review)
- A fault in the tracer itself (broken provider/sampler/exporter) now degrades to a no-op instead of propagating into and breaking a discover/inspect/call: span start is guarded (fall back to untraced), set_span_attributes swallows set_attribute errors, and span end is guarded in finally. The traced body's own exception is still recorded and re-raised unchanged. - Record the exception once: pass record_exception=False/set_status_on_exception =False to start_as_current_span (the SDK would otherwise double-record on exit). - Broaden the module-load guard so a broken otel install can't break importing qveris. Added a test that a throwing tracer doesn't break the call; clarified the README (active when the API is importable; near-zero, not literal-zero).
1 parent 2791740 commit d990136

3 files changed

Lines changed: 77 additions & 21 deletions

File tree

packages/python-sdk/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,12 @@ agent = Agent(llm_provider=MyProvider())
153153

154154
## Observability (OpenTelemetry)
155155

156-
`discover` / `inspect` / `call` emit one OpenTelemetry span each, so you can trace QVeris activity and correlate it with the usage/ledger records. Tracing is **opt-in and dependency-free**:
156+
`discover` / `inspect` / `call` emit one OpenTelemetry span each, so you can trace QVeris activity and correlate it with the usage/ledger records. Tracing is **dependency-free and best-effort**:
157157

158-
- Without the extra, the spans are a no-op — zero overhead, zero behavior change.
159-
- Install `pip install qveris[otel]` (adds `opentelemetry-api`) and configure any OTLP exporter to see them.
158+
- If `opentelemetry-api` is not importable (install it with `pip install qveris[otel]`), the helpers are a no-op — no overhead, no behavior change.
159+
- If it is importable but no tracer provider is configured, spans go to OpenTelemetry's default no-op provider (near-zero cost, nothing exported).
160+
- Configure a provider + OTLP exporter and the spans flow to Jaeger/Tempo/any OTLP backend.
161+
- A fault in the tracer itself (broken provider/sampler/exporter) degrades to a no-op — it never breaks a `discover`/`inspect`/`call`.
160162

161163
Span attributes live under a `qveris.` namespace: `operation`, `tool_id` / `tool_id_count`, `search_id`, `execution_id`, `elapsed_time_ms`, `success`, and `credits` (pre-settlement). The natural-language query and tool parameters are intentionally **not** recorded. A failed `call` is marked as an error span.
162164

packages/python-sdk/qveris/observability.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,22 @@
2525
try: # opentelemetry-api is an optional extra (qveris[otel]).
2626
from opentelemetry import trace as _otel_trace
2727
from opentelemetry.trace import Status, StatusCode
28-
28+
except ImportError: # opentelemetry not installed -> fully no-op
29+
_otel_trace = None # type: ignore[assignment]
30+
Status = None # type: ignore[assignment]
31+
StatusCode = None # type: ignore[assignment]
32+
_tracer: Any = None
33+
else:
2934
try: # keep the instrumenting-library version on the tracer when available.
3035
from importlib.metadata import version as _pkg_version
3136

3237
_QVERIS_VERSION: Optional[str] = _pkg_version("qveris")
3338
except Exception: # pragma: no cover - metadata missing in odd installs
3439
_QVERIS_VERSION = None
35-
36-
_tracer: Any = _otel_trace.get_tracer("qveris", _QVERIS_VERSION)
37-
except ImportError: # opentelemetry not installed -> fully no-op
38-
_otel_trace = None # type: ignore[assignment]
39-
Status = None # type: ignore[assignment]
40-
StatusCode = None # type: ignore[assignment]
41-
_tracer = None
40+
try:
41+
_tracer = _otel_trace.get_tracer("qveris", _QVERIS_VERSION)
42+
except Exception: # pragma: no cover - a broken otel install must not break import
43+
_tracer = None
4244

4345

4446
# Span attribute keys (kept under a `qveris.` namespace).
@@ -61,31 +63,62 @@ def is_tracing_enabled() -> bool:
6163

6264

6365
def set_span_attributes(span: Any, attributes: Dict[str, Any]) -> None:
64-
"""Set non-``None`` attributes on ``span`` (no-op when tracing is off)."""
66+
"""Set non-``None`` attributes on ``span``, best-effort.
67+
68+
No-op when tracing is off, and swallows any tracer error — instrumentation
69+
must never break the traced operation.
70+
"""
6571
if span is None:
6672
return
6773
for key, value in attributes.items():
6874
if value is not None:
69-
span.set_attribute(key, value)
75+
try:
76+
span.set_attribute(key, value)
77+
except Exception: # pragma: no cover - defensive
78+
pass
7079

7180

7281
@contextmanager
7382
def start_span(name: str, attributes: Optional[Dict[str, Any]] = None) -> Iterator[Any]:
7483
"""Start a span for a QVeris operation.
7584
76-
Yields the span (or ``None`` when tracing is disabled). On an exception the
77-
span is marked ``ERROR`` and the exception recorded before re-raising, so a
78-
failed ``call`` still shows up as a failed span.
85+
Yields the span (or ``None`` when tracing is disabled/unavailable). On an
86+
exception from the traced body the span is marked ``ERROR`` and the exception
87+
recorded before re-raising, so a failed ``call`` shows up as a failed span.
88+
89+
Tracing is strictly best-effort: any fault in the tracer itself (a broken
90+
provider, sampler, or exporter) degrades to a no-op rather than propagating
91+
into — and breaking — the traced operation.
7992
"""
8093
if _tracer is None:
8194
yield None
8295
return
8396

84-
with _tracer.start_as_current_span(name) as span:
85-
set_span_attributes(span, attributes or {})
97+
try:
98+
# Disable the SDK's own exception recording; we record once, manually.
99+
span_cm = _tracer.start_as_current_span(
100+
name, record_exception=False, set_status_on_exception=False
101+
)
102+
span = span_cm.__enter__()
103+
except Exception: # pragma: no cover - tracer setup fault -> run untraced
104+
yield None
105+
return
106+
107+
set_span_attributes(span, attributes or {})
108+
exc_info: Any = (None, None, None)
109+
try:
110+
yield span
111+
except BaseException as exc: # noqa: BLE001 - record then re-raise the ORIGINAL error
112+
exc_info = (type(exc), exc, exc.__traceback__)
86113
try:
87-
yield span
88-
except BaseException as exc: # noqa: BLE001 - record then re-raise
89114
span.record_exception(exc)
90115
span.set_status(Status(StatusCode.ERROR, str(exc)))
91-
raise
116+
except Exception: # pragma: no cover - defensive
117+
pass
118+
raise
119+
finally:
120+
# End the span; guard so an exporter/exit fault can't mask the result.
121+
try:
122+
span_cm.__exit__(*exc_info)
123+
except Exception: # pragma: no cover - defensive
124+
pass

packages/python-sdk/tests/test_observability.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ def handler(_request: httpx.Request) -> httpx.Response:
114114
assert span.status.status_code == StatusCode.ERROR
115115

116116

117+
@pytest.mark.asyncio
118+
async def test_broken_tracer_does_not_break_the_call(monkeypatch: pytest.MonkeyPatch) -> None:
119+
# A misbehaving tracer provider must never break the traced operation.
120+
class BrokenTracer:
121+
def start_as_current_span(self, *args, **kwargs):
122+
raise RuntimeError("sampler exploded")
123+
124+
monkeypatch.setattr(observability, "_tracer", BrokenTracer())
125+
126+
def handler(_request: httpx.Request) -> httpx.Response:
127+
return httpx.Response(200, json={"execution_id": "e1", "success": True})
128+
129+
client = make_client(handler)
130+
try:
131+
result = await client.call("t1", {"x": 1}, search_id="s1")
132+
finally:
133+
await client.close()
134+
135+
assert result.execution_id == "e1" # call succeeded despite the broken tracer
136+
137+
117138
@pytest.mark.asyncio
118139
async def test_tracing_disabled_is_a_noop(monkeypatch: pytest.MonkeyPatch) -> None:
119140
# Simulate opentelemetry not installed: the client must still work unchanged.

0 commit comments

Comments
 (0)