Skip to content

Commit 9018a61

Browse files
NiteshDhanpalclaude
andcommitted
fix(tracing): address PR #484 review — error status, obs fallback, handle leak, dd_only Temporal backend
Four review-comment fixes for the business<->obs correlation edge, each with unit coverage: - ADK span() CM recorded no error on a failing step, so a failed agent step closed a green obs span. Now sets span error (guarded so obs can't shadow the app exception) and re-raises. (tests/test_adk_tracing_span_error.py) - In lgtm mode with no TracerProvider, open_obs_span returned a handle with empty correlation, suppressing the ambient obs_correlation() fallback -> business span got no obs_* ids. Now bails to None so the caller falls back. (tests/test_obs_span_fallback.py) - Obs-handle registry could leak (registration-order + start-without-end via public API). Processor hooks now swallow (obs must never crash the app path) and the registry is a bounded OrderedDict that evicts+closes the oldest. (tests/test_obs_handle_registry.py) - On the Temporal path the ambient span is the OTel interceptor span regardless of SGP_OBS_MODE, but tag/read branched on mode -> in the default dd_only they tagged/read an unrelated ddtrace span. Added prefer_otel (OTel-first, ddtrace fallback) via a shared _begin_obs helper used by both start_span paths. (tests/test_temporal_obs_backend.py) Also: ObsSpanHandle.close() instead of reaching into _close; Iterator from collections.abc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2740bac commit 9018a61

9 files changed

Lines changed: 712 additions & 68 deletions

File tree

src/agentex/lib/adk/_modules/tracing.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
TracingActivityName,
2121
)
2222
from agentex.lib.core.tracing.tracer import AsyncTracer
23+
from agentex.lib.core.tracing.span_error import set_span_error
2324
from agentex.lib.core.harness.types import TurnUsage
2425
from agentex.types.span import Span
2526
from agentex.lib.utils.logging import make_logger
@@ -236,6 +237,24 @@ async def span(
236237
)
237238
try:
238239
yield span
240+
except Exception as exc:
241+
# Record the failure on the span so the obs span reflects the error
242+
# instead of a false green. Agents use THIS context manager (not
243+
# AsyncTrace.span, which is the only other place set_span_error is
244+
# called), so without this a failed step closes green. end_span (in
245+
# finally) reads it via get_span_error and propagates it to
246+
# close_obs_span. Stored on span.data, so it round-trips through the
247+
# END_SPAN activity on the Temporal path too.
248+
#
249+
# Guard set_span_error itself: it's obs work and must never replace
250+
# the app's exception on the way out. We always re-raise the ORIGINAL
251+
# exc regardless.
252+
if span:
253+
try:
254+
set_span_error(span, exc)
255+
except Exception: # pragma: no cover - obs must not break app path
256+
pass
257+
raise
239258
finally:
240259
if span:
241260
await self.end_span(

src/agentex/lib/core/temporal/services/temporal_task_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3-
from typing import Any, Iterator
3+
from typing import Any
44
from datetime import timedelta
55
from contextlib import contextmanager
6+
from collections.abc import Iterator
67

78
from agentex.types.task import Task
89
from agentex.types.agent import Agent

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def _ddtrace_ids() -> Optional[Tuple[str, str]]:
6969
return None
7070

7171

72-
def obs_correlation() -> Dict[str, str]:
72+
def obs_correlation(prefer_otel: bool = False) -> Dict[str, str]:
7373
"""Return ``{"obs_trace_id": ..., "obs_span_id": ...}`` for the active
7474
observability context, or ``{}`` if none is active.
7575
@@ -79,10 +79,18 @@ def obs_correlation() -> Dict[str, str]:
7979
dotted) keep them addressable via Postgres JSON paths
8080
(``operation_metadata->>'obs_trace_id'``).
8181
82+
``prefer_otel``: on the Temporal path the active span is the temporalio OTel
83+
``TracingInterceptor`` span regardless of ``SGP_OBS_MODE``, so callers there
84+
read OTel first (falling back to ddtrace) -- otherwise the default ``dd_only``
85+
mode would read ids for an unrelated ddtrace trace, not the activity span.
86+
8287
Never fabricates ids -- this is a correlation tag, not the span's id.
8388
"""
8489
try:
85-
ids = _lgtm_ids() if get_obs_mode() == LGTM else _ddtrace_ids()
90+
if prefer_otel:
91+
ids = _lgtm_ids() or _ddtrace_ids()
92+
else:
93+
ids = _lgtm_ids() if get_obs_mode() == LGTM else _ddtrace_ids()
8694
except Exception: # obs must never fail an app call
8795
return {}
8896

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

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def __init__(
5656
self.correlation = correlation
5757
self._close = close
5858

59+
def close(self, error: Optional[Dict[str, str]] = None) -> None:
60+
"""Run the backend-specific closer (detach+end for OTel, finish for
61+
ddtrace). ``error`` marks the obs span failed so it isn't a false green."""
62+
self._close(error)
63+
5964

6065
def _hex_ids(trace_id: int, span_id: int) -> Dict[str, str]:
6166
"""W3C-hex form: 32-hex trace, 16-hex span."""
@@ -82,7 +87,19 @@ def _open_otel_span(
8287
span.set_attribute(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
8388
token = context.attach(trace.set_span_in_context(span))
8489
sc = span.get_span_context()
85-
correlation = _hex_ids(sc.trace_id, sc.span_id) if (sc and sc.is_valid) else {}
90+
if not (sc and sc.is_valid):
91+
# No real TracerProvider installed (lgtm mode but the agent has no
92+
# OTel provider yet): the proxy tracer hands back a NonRecordingSpan
93+
# with an invalid context. Returning a handle with empty correlation
94+
# here would make the caller (trace.py) take obs_handle.correlation
95+
# == {} and NEVER consult the obs_correlation() ambient fallback --
96+
# so the business span would get no obs_* ids at all, strictly worse
97+
# than falling back. Detach the useless context, end the no-op span,
98+
# and return None so the caller uses the ambient ids instead.
99+
context.detach(token)
100+
span.end()
101+
return None
102+
correlation = _hex_ids(sc.trace_id, sc.span_id)
86103

87104
def _close(error: Optional[Dict[str, str]] = None) -> None:
88105
try:
@@ -124,11 +141,18 @@ def _open_ddtrace_span(
124141
# Datadog traces. Parenting to the active request/turn context rolls them
125142
# into one trace while obs_span_id stays distinct per step.
126143
span = tracer.start_span(name, child_of=ctx, activate=True)
144+
if not span.trace_id:
145+
# Symmetry with the OTel path: a handle carrying empty correlation
146+
# would suppress the ambient obs_correlation() fallback in trace.py.
147+
# (child_of=ctx normally guarantees a real trace_id, so this is
148+
# belt-and-braces.) Finish the span and fall back to ambient ids.
149+
span.finish()
150+
return None
127151
if business_span_id:
128152
span.set_tag(_ATTR_BUSINESS_SPAN_ID, business_span_id)
129153
if business_trace_id:
130154
span.set_tag(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
131-
correlation = _hex_ids(span.trace_id, span.span_id) if span.trace_id else {}
155+
correlation = _hex_ids(span.trace_id, span.span_id)
132156

133157
def _close(error: Optional[Dict[str, str]] = None) -> None:
134158
try:
@@ -175,9 +199,44 @@ def open_obs_span(
175199
return None
176200

177201

202+
def _tag_otel_ambient(business_span_id: Optional[str], business_trace_id: Optional[str]) -> bool:
203+
"""Stamp the reverse tag onto the active OTel span. Returns True iff a valid
204+
OTel span was found and tagged."""
205+
try:
206+
from opentelemetry import trace
207+
except ImportError:
208+
return False
209+
span = trace.get_current_span()
210+
if span is not None and span.get_span_context().is_valid:
211+
if business_span_id:
212+
span.set_attribute(_ATTR_BUSINESS_SPAN_ID, business_span_id)
213+
if business_trace_id:
214+
span.set_attribute(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
215+
return True
216+
return False
217+
218+
219+
def _tag_ddtrace_ambient(business_span_id: Optional[str], business_trace_id: Optional[str]) -> bool:
220+
"""Stamp the reverse tag onto the active ddtrace span. Returns True iff a
221+
ddtrace span was found and tagged."""
222+
try:
223+
from ddtrace.trace import tracer
224+
except ImportError:
225+
return False
226+
span = tracer.current_span()
227+
if span is not None:
228+
if business_span_id:
229+
span.set_tag(_ATTR_BUSINESS_SPAN_ID, business_span_id)
230+
if business_trace_id:
231+
span.set_tag(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
232+
return True
233+
return False
234+
235+
178236
def tag_ambient_obs_span(
179237
business_span_id: Optional[str] = None,
180238
business_trace_id: Optional[str] = None,
239+
prefer_otel: bool = False,
181240
) -> None:
182241
"""Stamp the reverse tag onto the CURRENTLY ACTIVE obs span -- without opening
183242
a new one.
@@ -188,26 +247,23 @@ def tag_ambient_obs_span(
188247
closed. Instead we lean on the span the Temporal OTel ``TracingInterceptor``
189248
already made active for this activity and just add
190249
``agentex.business_span_id`` / ``agentex.business_trace_id`` so the obs -> business
191-
pivot still works. Best-effort; never raises."""
250+
pivot still works. Best-effort; never raises.
251+
252+
``prefer_otel``: on the Temporal path the ambient span is the temporalio OTel
253+
``TracingInterceptor`` span REGARDLESS of ``SGP_OBS_MODE`` -- so callers there
254+
pass ``prefer_otel=True`` to tag OTel first (falling back to ddtrace only if
255+
no valid OTel span is active). Without this, the default ``dd_only`` mode would
256+
tag an unrelated ddtrace span (or nothing) instead of the real activity span."""
192257
try:
258+
if prefer_otel:
259+
if _tag_otel_ambient(business_span_id, business_trace_id):
260+
return
261+
_tag_ddtrace_ambient(business_span_id, business_trace_id)
262+
return
193263
if get_obs_mode() == LGTM:
194-
from opentelemetry import trace
195-
196-
span = trace.get_current_span()
197-
if span is not None and span.get_span_context().is_valid:
198-
if business_span_id:
199-
span.set_attribute(_ATTR_BUSINESS_SPAN_ID, business_span_id)
200-
if business_trace_id:
201-
span.set_attribute(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
264+
_tag_otel_ambient(business_span_id, business_trace_id)
202265
else:
203-
from ddtrace.trace import tracer
204-
205-
span = tracer.current_span()
206-
if span is not None:
207-
if business_span_id:
208-
span.set_tag(_ATTR_BUSINESS_SPAN_ID, business_span_id)
209-
if business_trace_id:
210-
span.set_tag(_ATTR_BUSINESS_TRACE_ID, business_trace_id)
266+
_tag_ddtrace_ambient(business_span_id, business_trace_id)
211267
except Exception: # pragma: no cover - best-effort; obs must never break a call
212268
pass
213269

@@ -222,6 +278,6 @@ def close_obs_span(
222278
if handle is None:
223279
return
224280
try:
225-
handle._close(error)
281+
handle.close(error)
226282
except Exception: # pragma: no cover - best-effort
227283
pass

0 commit comments

Comments
 (0)