@@ -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 )
0 commit comments