Skip to content

Commit a49980d

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 4870048 commit a49980d

2 files changed

Lines changed: 71 additions & 11 deletions

File tree

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,28 +148,55 @@ def _in_temporal_activity() -> bool:
148148
return False
149149

150150

151+
def _in_tracing_dispatch_activity() -> bool:
152+
"""True only when running inside the SDK's OWN dispatched START_SPAN / END_SPAN
153+
activity (the ``in_temporal_workflow()`` path, where a workflow runs span start
154+
and end as SEPARATE activities that Temporal can route to different workers).
155+
156+
That is the one case a per-step obs wrapper can't work: the wrapper opened in
157+
the START_SPAN activity could never be closed by the END_SPAN activity. A span
158+
created directly inside a *business* activity (an agent turn's own
159+
``adk.tracing.span``) runs start AND end in the same activity process, so a
160+
wrapper there is safe -- it nests under the interceptor's ambient RunActivity
161+
span and closes in-process. The tracing dispatch activities are named
162+
``start-span`` / ``end-span`` (``TracingActivityName``). Never raises; False
163+
when temporalio isn't importable or we're not in an activity."""
164+
try:
165+
from temporalio import activity
166+
167+
if not activity.in_activity():
168+
return False
169+
return activity.info().activity_type in ("start-span", "end-span")
170+
except Exception:
171+
return False
172+
173+
151174
def _begin_obs(
152175
name: str,
153176
span_id: str,
154177
trace_id: str | None,
155178
) -> tuple[ObsSpanHandle | None, dict[str, str]]:
156-
"""Open the obs wrapper for a business span (or, inside a Temporal activity,
157-
tag the ambient interceptor span) and return ``(handle, correlation)``.
179+
"""Open the obs wrapper for a business span and return ``(handle, correlation)``.
158180
159181
Shared by ``Trace.start_span`` and ``AsyncTrace.start_span`` so the two paths
160182
can't drift. The wrapper is named for the step so ``obs_span_id`` is
161183
stable/meaningful (not an arbitrary innermost httpx span), and it carries the
162184
reverse tag (business span/trace id) for the obs -> business pivot.
163185
164-
Temporal path: we do NOT open our own wrapper -- start_span / end_span run as
165-
separate activities on possibly different workers, so the handle could never
166-
be closed. Instead we tag the span the temporalio OTel ``TracingInterceptor``
167-
already made active. That span is OTel REGARDLESS of ``SGP_OBS_MODE``, so we
168-
pass ``prefer_otel=True`` to both the tag and the correlation read -- otherwise
169-
the default ``dd_only`` mode would tag/read an unrelated ddtrace span and the
170-
ids would point at the wrong trace. See ``_in_temporal_activity``.
186+
We open a real per-step wrapper on the sync path AND inside a *business*
187+
Temporal activity -- there the wrapper nests under the interceptor's ambient
188+
RunActivity span and start/end run in-process, so it closes cleanly and each
189+
business step gets its own obs span (1:1), just like sync.
190+
191+
The ONE exception is the SDK's own dispatched START_SPAN / END_SPAN activity
192+
(a workflow calling ``adk.tracing`` -- see ``_in_tracing_dispatch_activity``):
193+
there start and end are separate activities on possibly different workers, so
194+
a wrapper could never be closed. We fall back to tagging the ambient
195+
interceptor span instead, with ``prefer_otel=True`` (the interceptor span is
196+
OTel regardless of ``SGP_OBS_MODE``, so a plain ``dd_only`` read would
197+
otherwise point at an unrelated ddtrace span).
171198
"""
172-
if _in_temporal_activity():
199+
if _in_tracing_dispatch_activity():
173200
tag_ambient_obs_span(business_span_id=span_id, business_trace_id=trace_id, prefer_otel=True)
174201
return None, obs_correlation(prefer_otel=True)
175202
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+
# Option A (tag the ambient interceptor span, no wrapper) now applies only
71+
# inside 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 Option A. 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 (Option A)."""
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)