Skip to content

Commit c67e177

Browse files
[WIP] feat(tracing): correlate business spans with active observability trace (#465)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 199fd6a commit c67e177

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Correlate adk business spans with the active observability trace.
2+
3+
The adk business ``trace_id`` is the agent **task id** (run-level: it spans the
4+
whole agent run across many requests -- task/create, then each message/send turn),
5+
so we must NOT overwrite it with a per-request observability trace_id. Doing so
6+
would collapse the run-level grouping.
7+
8+
Instead, each business span is *tagged* with the active observability
9+
trace_id/span_id (this is the OpenTelemetry "span link" pattern -- correlate
10+
across trace granularities rather than merging them). You can then pivot from a
11+
persisted business span to the Tempo/Datadog trace for the turn that produced it,
12+
while the business trace still groups the entire run by task id.
13+
14+
Source selection follows SGP_OBS_MODE, matching egp-api-backend:
15+
- unset / "dd_only": ddtrace context (current stack)
16+
- "dual": OTel/LGTM preferred, ddtrace fallback
17+
- "lgtm": OTel/LGTM only
18+
19+
This never fabricates ids -- if no observability context is active, it returns
20+
an empty dict and the span is simply not tagged.
21+
"""
22+
from __future__ import annotations
23+
24+
import os
25+
from typing import Dict, Optional, Tuple
26+
27+
__all__ = ("get_obs_mode", "obs_correlation")
28+
29+
DD_ONLY = "dd_only"
30+
DUAL = "dual"
31+
LGTM = "lgtm"
32+
_DEFAULT_MODE = DD_ONLY
33+
_VALID_MODES = (DD_ONLY, DUAL, LGTM)
34+
35+
36+
def get_obs_mode() -> str:
37+
"""Unset/empty/unrecognized -> ``dd_only`` (current behavior)."""
38+
raw = os.getenv("SGP_OBS_MODE")
39+
if not raw:
40+
return _DEFAULT_MODE
41+
mode = raw.strip().lower()
42+
return mode if mode in _VALID_MODES else _DEFAULT_MODE
43+
44+
45+
def _lgtm_ids() -> Optional[Tuple[str, str]]:
46+
try:
47+
from opentelemetry import trace
48+
except ImportError:
49+
return None
50+
ctx = trace.get_current_span().get_span_context()
51+
if ctx and ctx.is_valid:
52+
return format(ctx.trace_id, "032x"), format(ctx.span_id, "016x")
53+
return None
54+
55+
56+
def _ddtrace_ids() -> Optional[Tuple[str, str]]:
57+
try:
58+
from ddtrace import tracer
59+
except ImportError:
60+
return None
61+
ctx = tracer.current_trace_context()
62+
if ctx and ctx.trace_id:
63+
return format(ctx.trace_id, "032x"), format(ctx.span_id or 0, "016x")
64+
return None
65+
66+
67+
def obs_correlation() -> Dict[str, str]:
68+
"""Return ``{"obs.trace_id": ..., "obs.span_id": ...}`` for the active
69+
observability context, or ``{}`` if none is active.
70+
71+
Never fabricates ids -- this is a correlation tag, not the span's id.
72+
"""
73+
mode = get_obs_mode()
74+
if mode == LGTM:
75+
ids = _lgtm_ids()
76+
elif mode == DUAL:
77+
ids = _lgtm_ids() or _ddtrace_ids()
78+
else: # dd_only
79+
ids = _ddtrace_ids()
80+
81+
if not ids:
82+
return {}
83+
return {"obs.trace_id": ids[0], "obs.span_id": ids[1]}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from agentex.types.span import Span
1212
from agentex.lib.utils.logging import make_logger
1313
from agentex.lib.utils.model_utils import recursive_model_dump
14+
from agentex.lib.core.tracing.obs_ids import obs_correlation
1415
from agentex.lib.core.tracing.span_error import set_span_error
1516
from agentex.lib.core.tracing.span_queue import (
1617
SpanEventType,
@@ -79,6 +80,12 @@ def start_span(
7980

8081
serialized_input = recursive_model_dump(input) if input else None
8182
serialized_data = recursive_model_dump(data) if data else None
83+
# Tag the business span with the active observability trace_id/span_id
84+
# (OTel/ddtrace) so it can be correlated to the per-turn obs trace. The
85+
# business trace_id stays the run-level task id -- see obs_ids.py.
86+
obs = obs_correlation()
87+
if obs:
88+
serialized_data = {**(serialized_data or {}), **obs}
8289
id = str(uuid.uuid4())
8390

8491
span = Span(
@@ -229,6 +236,12 @@ async def start_span(
229236

230237
serialized_input = recursive_model_dump(input) if input else None
231238
serialized_data = recursive_model_dump(data) if data else None
239+
# Tag the business span with the active observability trace_id/span_id
240+
# (OTel/ddtrace) so it can be correlated to the per-turn obs trace. The
241+
# business trace_id stays the run-level task id -- see obs_ids.py.
242+
obs = obs_correlation()
243+
if obs:
244+
serialized_data = {**(serialized_data or {}), **obs}
232245
id = str(uuid.uuid4())
233246

234247
span = Span(

0 commit comments

Comments
 (0)