Skip to content

Commit 5decbec

Browse files
committed
feat(tracing): per-step obs wrappers inside business Temporal activities (1:1)
Previously _begin_obs skipped the obs wrapper for ANY Temporal activity (Option A) and only stamped the ambient RunActivity span, so all business spans in a turn collapsed onto ONE obs span (52:1). But inside a *business* activity, start_span and end_span run in the SAME process, so a wrapper is safe there. Option A is only required for the SDK's own dispatched START_SPAN/END_SPAN activities (the in_temporal_workflow path), where start and end are separate activities on possibly different workers. Discriminate on activity type: _in_tracing_dispatch_activity() is true only for the "start-span"/"end-span" activities. For everything else (sync, or a business activity) open a real per-step wrapper — it nests under the interceptor's ambient RunActivity span and closes in-process, giving each business span its own obs span (1:1), matching the sync path. The bounded _OBS_HANDLES registry backstops any mis-discrimination.
1 parent 0f820a3 commit 5decbec

3 files changed

Lines changed: 68 additions & 45 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,10 @@ def tag_ambient_obs_span(
241241
"""Stamp the reverse tag onto the CURRENTLY ACTIVE obs span -- without opening
242242
a new one.
243243
244-
Used on the Temporal path (see ``trace._in_temporal_activity``): there we must
245-
NOT open our own wrapper span, because start_span/end_span run as separate
246-
activities on possibly different workers and the wrapper could never be
247-
closed. Instead we lean on the span the Temporal OTel ``TracingInterceptor``
244+
Used inside the SDK's dispatched start-span/end-span activities (see
245+
``trace._in_tracing_dispatch_activity``): there we must NOT open our own
246+
wrapper span, because start_span/end_span run as separate activities on
247+
possibly different workers and the wrapper could never be closed. Instead we lean on the span the Temporal OTel ``TracingInterceptor``
248248
already made active for this activity and just add
249249
``agentex.business_span_id`` / ``agentex.business_trace_id`` so the obs -> business
250250
pivot still works. Best-effort; never raises.

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

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -106,39 +106,25 @@ def _run_on_span_end(processor: SyncTracingProcessor, span: Span) -> None:
106106
)
107107

108108

109-
def _in_temporal_activity() -> bool:
110-
"""True when executing inside a Temporal activity.
111-
112-
On the Temporal path ``start_span`` and ``end_span`` run as SEPARATE
113-
activities (START_SPAN / END_SPAN) that Temporal can route to DIFFERENT
114-
worker processes. A wrapper obs span opened in the START_SPAN activity could
115-
therefore never be closed by END_SPAN -- its handle lives in another
116-
process's ``_OBS_HANDLES`` -- so it would leak (unbounded, OOM risk) and its
117-
persisted ``obs_span_id`` would dangle (the span is never .end()ed, so never
118-
exported to Tempo).
119-
120-
So inside an activity we do NOT open our own wrapper. We lean on the span the
121-
Temporal OTel ``TracingInterceptor`` (see ``core/tracing/temporal.py`` +
122-
scale-agentex-python#485) already made active for this activity -- which is
123-
rooted under the turn's propagated trace -- and merely stamp the reverse tag
124-
onto it (``tag_ambient_obs_span``). That keeps trace-level correlation with
125-
no cross-process handle to leak.
126-
127-
Never raises; returns False when temporalio isn't importable.
128-
129-
TODO(obs-followup): this intentionally drops the *named per-step* wrapper on
130-
the Temporal path (obs_span_id becomes the ambient activity span, not a
131-
step-named span) and does NOT add TurnTrace RETRY/ASYNC roll-up -- retried
132-
turns still surface as N unlinked spans. Follow-up diff should (a) optionally
133-
materialize a self-contained named wrapper inside a single activity using the
134-
span's own start/end timestamps, and (b) build the TurnTrace roll-up.
135-
Test-later: on a multi-replica worker fleet, assert _OBS_HANDLES stays
136-
bounded (no leak / OOM) and that obs_trace_id resolves to the turn trace.
137-
"""
109+
def _in_tracing_dispatch_activity() -> bool:
110+
"""True only when running inside the SDK's OWN dispatched START_SPAN / END_SPAN
111+
activity (the ``in_temporal_workflow()`` path, where a workflow runs span start
112+
and end as SEPARATE activities that Temporal can route to different workers).
113+
114+
That is the one case a per-step obs wrapper can't work: the wrapper opened in
115+
the START_SPAN activity could never be closed by the END_SPAN activity. A span
116+
created directly inside a *business* activity (an agent turn's own
117+
``adk.tracing.span``) runs start AND end in the same activity process, so a
118+
wrapper there is safe -- it nests under the interceptor's ambient RunActivity
119+
span and closes in-process. The tracing dispatch activities are named
120+
``start-span`` / ``end-span`` (``TracingActivityName``). Never raises; False
121+
when temporalio isn't importable or we're not in an activity."""
138122
try:
139123
from temporalio import activity
140124

141-
return activity.in_activity()
125+
if not activity.in_activity():
126+
return False
127+
return activity.info().activity_type in ("start-span", "end-span")
142128
except Exception:
143129
return False
144130

@@ -148,23 +134,27 @@ def _begin_obs(
148134
span_id: str,
149135
trace_id: str | None,
150136
) -> tuple[ObsSpanHandle | None, dict[str, str]]:
151-
"""Open the obs wrapper for a business span (or, inside a Temporal activity,
152-
tag the ambient interceptor span) and return ``(handle, correlation)``.
137+
"""Open the obs wrapper for a business span and return ``(handle, correlation)``.
153138
154139
Shared by ``Trace.start_span`` and ``AsyncTrace.start_span`` so the two paths
155140
can't drift. The wrapper is named for the step so ``obs_span_id`` is
156141
stable/meaningful (not an arbitrary innermost httpx span), and it carries the
157142
reverse tag (business span/trace id) for the obs -> business pivot.
158143
159-
Temporal path: we do NOT open our own wrapper -- start_span / end_span run as
160-
separate activities on possibly different workers, so the handle could never
161-
be closed. Instead we tag the span the temporalio OTel ``TracingInterceptor``
162-
already made active. That span is OTel REGARDLESS of ``SGP_OBS_MODE``, so we
163-
pass ``prefer_otel=True`` to both the tag and the correlation read -- otherwise
164-
the default ``dd_only`` mode would tag/read an unrelated ddtrace span and the
165-
ids would point at the wrong trace. See ``_in_temporal_activity``.
144+
We open a real per-step wrapper on the sync path AND inside a *business*
145+
Temporal activity -- there the wrapper nests under the interceptor's ambient
146+
RunActivity span and start/end run in-process, so it closes cleanly and each
147+
business step gets its own obs span (1:1), just like sync.
148+
149+
The ONE exception is the SDK's own dispatched START_SPAN / END_SPAN activity
150+
(a workflow calling ``adk.tracing`` -- see ``_in_tracing_dispatch_activity``):
151+
there start and end are separate activities on possibly different workers, so
152+
a wrapper could never be closed. We fall back to tagging the ambient
153+
interceptor span instead, with ``prefer_otel=True`` (the interceptor span is
154+
OTel regardless of ``SGP_OBS_MODE``, so a plain ``dd_only`` read would
155+
otherwise point at an unrelated ddtrace span).
166156
"""
167-
if _in_temporal_activity():
157+
if _in_tracing_dispatch_activity():
168158
tag_ambient_obs_span(business_span_id=span_id, business_trace_id=trace_id, prefer_otel=True)
169159
return None, obs_correlation(prefer_otel=True)
170160
handle = open_obs_span(name, business_span_id=span_id, business_trace_id=trace_id)

tests/test_temporal_obs_backend.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ def _activate_otel_span(monkeypatch: pytest.MonkeyPatch) -> _RecordingOtelSpan:
6767

6868
def test_temporal_path_tags_and_reads_otel_in_dd_only(monkeypatch: pytest.MonkeyPatch) -> None:
6969
# Default/dd_only mode is exactly where the old code went to ddtrace.
70+
# Tagging the ambient interceptor span (no wrapper) now applies only inside
71+
# the SDK's dispatched START_SPAN/END_SPAN activity, not any activity.
7072
monkeypatch.setenv("SGP_OBS_MODE", "dd_only")
71-
monkeypatch.setattr(trace_mod, "_in_temporal_activity", lambda: True)
73+
monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: True)
7274
activity_span = _activate_otel_span(monkeypatch)
7375

7476
trace_obj = Trace(processors=[], client=cast(Any, object()), trace_id="trace-1")
@@ -132,3 +134,34 @@ def current_span(self) -> _FakeDDSpan:
132134
assert tagged["agentex.business_trace_id"] == "bt"
133135
# The invalid OTel span was NOT tagged.
134136
assert invalid.attributes == {}
137+
138+
139+
class _FakeHandle:
140+
def __init__(self, corr):
141+
self.correlation = corr
142+
143+
144+
def test_begin_obs_opens_wrapper_outside_dispatch_activity(monkeypatch: pytest.MonkeyPatch) -> None:
145+
"""Sync path or inside a business Temporal activity: open a per-step wrapper
146+
(1:1), not the ambient-span tag. Each business span gets its own obs span."""
147+
monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: False)
148+
monkeypatch.setattr(
149+
trace_mod, "open_obs_span",
150+
lambda *a, **k: _FakeHandle({"obs_trace_id": "t1", "obs_span_id": "s1"}),
151+
)
152+
handle, corr = trace_mod._begin_obs("mortgage.classify_intent", "bs", "bt")
153+
assert handle is not None
154+
assert corr == {"obs_trace_id": "t1", "obs_span_id": "s1"}
155+
156+
157+
def test_begin_obs_tags_ambient_inside_dispatch_activity(monkeypatch: pytest.MonkeyPatch) -> None:
158+
"""Inside the dispatched START_SPAN/END_SPAN activity: no wrapper (would leak
159+
across activities); tag the ambient interceptor span instead."""
160+
monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: True)
161+
tagged: dict = {}
162+
monkeypatch.setattr(trace_mod, "tag_ambient_obs_span", lambda **k: tagged.update(k))
163+
monkeypatch.setattr(trace_mod, "obs_correlation", lambda **k: {"obs_trace_id": "amb", "obs_span_id": "amb"})
164+
handle, corr = trace_mod._begin_obs("mortgage.advisor.turn", "bs", "bt")
165+
assert handle is None
166+
assert tagged.get("business_span_id") == "bs" and tagged.get("prefer_otel") is True
167+
assert corr == {"obs_trace_id": "amb", "obs_span_id": "amb"}

0 commit comments

Comments
 (0)