diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py index 6d186de5f..81a2e1780 100644 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py @@ -54,11 +54,30 @@ def _get_span_type(span: Span) -> str: return "STANDALONE" +def _active_otel_trace_id() -> str | None: + """Active OpenTelemetry/W3C trace id as 32-char hex, or None when OTel is absent or no span is recording.""" + try: + from opentelemetry import trace as otel_trace + except ImportError: + return None + context = otel_trace.get_current_span().get_span_context() + if not context.is_valid: + return None + return format(context.trace_id, "032x") + + +def _resolve_trace_id(agentex_trace_id: str) -> str: + """Prefer the ambient W3C/OTel trace id so agent spans share the request's observability trace, else keep agentex's own id.""" + return _active_otel_trace_id() or agentex_trace_id + + def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None: if span.data is None: span.data = {} if isinstance(span.data, dict): span.data["__source__"] = "agentex" + # Preserve agentex's own id so the task linkage survives when the SGP trace_id adopts the W3C id. + span.data["__agentex_trace_id__"] = span.trace_id if env_vars.ACP_TYPE is not None: span.data["__acp_type__"] = env_vars.ACP_TYPE if env_vars.AGENT_NAME is not None: @@ -77,7 +96,7 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: span_type=_get_span_type(span), span_id=span.id, parent_id=span.parent_id, - trace_id=span.trace_id, + trace_id=_resolve_trace_id(span.trace_id), input=span.input, output=span.output, metadata=span.data, diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py index dc8bab127..a259cbda0 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -494,3 +494,66 @@ def test_falsy_values_restore_span_start(self, monkeypatch, val): def test_other_values_keep_skip_enabled(self, monkeypatch, val): monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", val) assert self._fn()() is True + + +class TestResolveTraceId: + def _mod(self): + import agentex.lib.core.tracing.processors.sgp_tracing_processor as m + + return m + + def test_prefers_active_otel_trace_id(self, monkeypatch): + m = self._mod() + monkeypatch.setattr(m, "_active_otel_trace_id", lambda: "4bf92f3577b34da6a3ce929d0e0e4736") + assert m._resolve_trace_id("task-123") == "4bf92f3577b34da6a3ce929d0e0e4736" + + def test_falls_back_to_agentex_id_without_otel(self, monkeypatch): + m = self._mod() + monkeypatch.setattr(m, "_active_otel_trace_id", lambda: None) + assert m._resolve_trace_id("task-123") == "task-123" + + def test_active_otel_trace_id_none_without_span(self): + pytest.importorskip("opentelemetry") + assert self._mod()._active_otel_trace_id() is None + + def test_active_otel_trace_id_returns_w3c_hex(self): + otel_trace = pytest.importorskip("opentelemetry.trace") + otel_context = pytest.importorskip("opentelemetry.context") + + span_context = otel_trace.SpanContext( + trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736, + span_id=0x00F067AA0BA902B7, + is_remote=False, + trace_flags=otel_trace.TraceFlags(otel_trace.TraceFlags.SAMPLED), + ) + token = otel_context.attach(otel_trace.set_span_in_context(otel_trace.NonRecordingSpan(span_context))) + try: + resolved = self._mod()._active_otel_trace_id() + finally: + otel_context.detach(token) + assert resolved == "4bf92f3577b34da6a3ce929d0e0e4736" + + +class TestBuildSgpSpanTraceId: + def _build(self, monkeypatch, otel_id): + import agentex.lib.core.tracing.processors.sgp_tracing_processor as m + + monkeypatch.setattr(m, "_active_otel_trace_id", lambda: otel_id) + captured = {} + monkeypatch.setattr(m, "create_span", lambda **kw: captured.update(kw) or _make_mock_sgp_span()) + env = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) + span = _make_span() + m._build_sgp_span(span, env) + return captured, span + + def test_adopts_otel_trace_id_and_preserves_agentex_id(self, monkeypatch): + captured, span = self._build(monkeypatch, "4bf92f3577b34da6a3ce929d0e0e4736") + assert captured["trace_id"] == "4bf92f3577b34da6a3ce929d0e0e4736" + assert isinstance(span.data, dict) + assert span.data["__agentex_trace_id__"] == "trace-1" + + def test_keeps_agentex_trace_id_without_otel(self, monkeypatch): + captured, span = self._build(monkeypatch, None) + assert captured["trace_id"] == "trace-1" + assert isinstance(span.data, dict) + assert span.data["__agentex_trace_id__"] == "trace-1"