Skip to content

Commit 4870048

Browse files
committed
feat(tracing): opt-in SGP_OBS_SUPPRESS_EXPORT to suppress self-tracing of span export
Once outbound HTTP is instrumented, the library's own span export (to egp / Agentex) becomes an instrumented call whose spans nest into the trace being exported and drag in egp authz/identity/DB work — dominating the trace ("camera pointed at its own monitor"). Add suppress_export_instrumentation(): when SGP_OBS_SUPPRESS_EXPORT is enabled it attaches OTel's suppress-instrumentation context key around the export so it makes no spans and injects no traceparent. Applied at the processor-invocation boundaries (sync _run_on_span_*, async span_queue._handle) so it covers both the SGP and Agentex processors. Opt-in, default off, fail-open (no-op if disabled or if OTel instrumentation isn't importable). TEST/PREVIEW branch — not for merge as-is (belongs in its own PR).
1 parent f831957 commit 4870048

3 files changed

Lines changed: 78 additions & 6 deletions

File tree

src/agentex/lib/core/tracing/span_queue.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from agentex.types.span import Span
1010
from agentex.lib.utils.logging import make_logger
1111
from agentex.lib.core.observability import tracing_metrics_recording as _metrics
12+
from agentex.lib.core.tracing.suppression import suppress_export_instrumentation
1213
from agentex.lib.core.tracing.processors.tracing_processor_interface import (
1314
AsyncTracingProcessor,
1415
)
@@ -341,11 +342,14 @@ async def _handle(
341342
spans = [item.span for item in items]
342343
try:
343344
# Hold a concurrency slot only for the duration of the HTTP call.
345+
# Suppress instrumentation for the export so the export's own HTTP
346+
# call to egp/Agentex isn't traced back into the trace being exported.
344347
async with self._send_sema:
345-
if event_type == SpanEventType.START:
346-
await p.on_spans_start(spans)
347-
else:
348-
await p.on_spans_end(spans)
348+
with suppress_export_instrumentation():
349+
if event_type == SpanEventType.START:
350+
await p.on_spans_start(spans)
351+
else:
352+
await p.on_spans_end(spans)
349353
except Exception as exc:
350354
self._handle_failure(p, items, event_type, exc)
351355

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Suppress instrumentation while the tracing library exports its own telemetry.
2+
3+
Once a service instruments outbound HTTP, the library's own span export (shipping
4+
business spans to egp / the Agentex control plane) becomes an instrumented HTTP
5+
call. Those export spans nest into the very trace being exported and can dominate
6+
it -- exporting a span gets traced, which triggers egp authz/identity/DB work
7+
that is itself traced, and so on ("the camera pointed at its own monitor").
8+
9+
When ``SGP_OBS_SUPPRESS_EXPORT`` is enabled, wrapping the export in
10+
``suppress_export_instrumentation()`` disables OTel instrumentation for the
11+
duration: the export makes no spans and injects no ``traceparent``, so it can't
12+
pollute or hijack the trace. The persistence still happens -- it just stops
13+
being traced.
14+
15+
Opt-in, default OFF. Fail-open: a no-op if disabled or if OpenTelemetry's
16+
instrumentation utils aren't importable, so observability can never break the
17+
app path.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import os
23+
from typing import Iterator
24+
from contextlib import contextmanager
25+
26+
_ENV = "SGP_OBS_SUPPRESS_EXPORT"
27+
28+
29+
def suppress_export_enabled() -> bool:
30+
"""True when ``SGP_OBS_SUPPRESS_EXPORT`` is set to a truthy value."""
31+
return os.environ.get(_ENV, "").strip().lower() in ("1", "true", "yes", "on")
32+
33+
34+
@contextmanager
35+
def suppress_export_instrumentation() -> Iterator[None]:
36+
"""Disable OTel instrumentation for the wrapped telemetry-export block.
37+
38+
Attaches OpenTelemetry's suppress-instrumentation context key so instrumentors
39+
(httpx, requests, ...) skip span creation AND context injection for any call
40+
made inside the block. The key rides the current contextvar, so it applies to
41+
an awaited export running in the same task. No-op when disabled or when OTel
42+
instrumentation isn't importable; never raises.
43+
"""
44+
if not suppress_export_enabled():
45+
yield
46+
return
47+
try:
48+
from opentelemetry import context as _otel_context
49+
50+
# Only present when opentelemetry-instrumentation is installed (agents),
51+
# not in the bare SDK env -- hence the guarded import + fail-open above.
52+
from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY # type: ignore[import-not-found]
53+
except Exception: # pragma: no cover - obs must never break the app path
54+
yield
55+
return
56+
token = _otel_context.attach(_otel_context.set_value(_SUPPRESS_INSTRUMENTATION_KEY, True))
57+
try:
58+
yield
59+
finally:
60+
try:
61+
_otel_context.detach(token)
62+
except Exception: # pragma: no cover - best-effort
63+
pass

src/agentex/lib/core/tracing/trace.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AsyncSpanQueue,
2626
get_default_span_queue,
2727
)
28+
from agentex.lib.core.tracing.suppression import suppress_export_instrumentation
2829
from agentex.lib.core.tracing.processors.tracing_processor_interface import (
2930
SyncTracingProcessor,
3031
AsyncTracingProcessor,
@@ -81,7 +82,10 @@ def _run_on_span_start(processor: SyncTracingProcessor, span: Span) -> None:
8182
swallowing here, start_span returns normally and the standard end_span path
8283
pops and closes the handle -- no leak, no app-path failure."""
8384
try:
84-
processor.on_span_start(span)
85+
# Suppress instrumentation while the processor exports so the export's
86+
# own HTTP call to egp/Agentex isn't traced back into this trace.
87+
with suppress_export_instrumentation():
88+
processor.on_span_start(span)
8589
except Exception:
8690
logger.warning(
8791
"on_span_start raised for processor %r; skipping (observability must not fail the app path)",
@@ -97,7 +101,8 @@ def _run_on_span_end(processor: SyncTracingProcessor, span: Span) -> None:
97101
before this runs (see end_span), so this only guards the app path against a
98102
buggy processor -- there is no handle left to leak here."""
99103
try:
100-
processor.on_span_end(span)
104+
with suppress_export_instrumentation():
105+
processor.on_span_end(span)
101106
except Exception:
102107
logger.warning(
103108
"on_span_end raised for processor %r; skipping (observability must not fail the app path)",

0 commit comments

Comments
 (0)