Skip to content

Commit 044b3f4

Browse files
NiteshDhanpalclaude
andcommitted
feat(tracing): span the ACP->Temporal dispatch for end-to-end async traces
The Temporal OTel interceptor propagates trace context by injecting the active span into the Temporal message headers on start_workflow/signal, so the worker roots the workflow+activity under it. But the ACP server dispatches from a bare async handler with no active span -> nothing injected -> the workflow's activity becomes a detached trace root, disconnected from the task/create / event/send that triggered it. Wrap submit_task and send_event in an OTel span (agentex.acp) so the interceptor has a context to inject. Child of the ingress request span when one is active (front-of-request propagation), else a per-turn root. Fail-open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1159d23 commit 044b3f4

1 file changed

Lines changed: 54 additions & 28 deletions

File tree

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

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, Iterator
44
from datetime import timedelta
5+
from contextlib import contextmanager
56

67
from agentex.types.task import Task
78
from agentex.types.agent import Agent
@@ -13,6 +14,34 @@
1314
from agentex.lib.core.clients.temporal.temporal_client import TemporalClient
1415

1516

17+
@contextmanager
18+
def _acp_dispatch_span(name: str) -> Iterator[None]:
19+
"""Wrap an ACP -> Temporal dispatch (start_workflow / signal) in an OTel span.
20+
21+
The Temporal OpenTelemetry interceptor propagates trace context by injecting
22+
the CURRENTLY ACTIVE span into the Temporal message headers on the caller
23+
side (``start_workflow`` / ``signal_workflow``); the worker then extracts it
24+
and roots the workflow / activity spans under it. But the ACP server dispatches
25+
from a bare async handler with no active span, so nothing is injected and the
26+
workflow's activities become DETACHED trace roots -- the business work shows up
27+
in Tempo as a fresh trace with no link back to the ``task/create`` /
28+
``event/send`` that triggered it.
29+
30+
Opening a span here gives the interceptor something to inject. It becomes a
31+
child of the ingress request span when one is active (front-of-request
32+
propagation), or a fresh per-turn root otherwise. Fail-open: never raises if
33+
OpenTelemetry isn't importable.
34+
"""
35+
try:
36+
from opentelemetry import trace as _otel_trace
37+
except Exception: # pragma: no cover - obs must never break a dispatch
38+
yield
39+
return
40+
tracer = _otel_trace.get_tracer("agentex.acp")
41+
with tracer.start_as_current_span(name, kind=_otel_trace.SpanKind.PRODUCER):
42+
yield
43+
44+
1645
class TemporalTaskService:
1746
"""
1847
Submits Agent agent_tasks to the async runtime for execution.
@@ -26,7 +55,6 @@ def __init__(
2655
self._temporal_client = temporal_client
2756
self._env_vars = env_vars
2857

29-
3058
async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | None) -> str:
3159
"""
3260
Submit a task to the async runtime for execution.
@@ -37,22 +65,19 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
3765
# indefinitely, which long-lived chat/session agents rely on). A positive
3866
# value bounds the whole continue-as-new chain's wall-clock lifetime.
3967
timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS
40-
execution_timeout = (
41-
timedelta(seconds=timeout_seconds)
42-
if timeout_seconds and timeout_seconds > 0
43-
else None
44-
)
45-
return await self._temporal_client.start_workflow(
46-
workflow=self._env_vars.WORKFLOW_NAME,
47-
arg=CreateTaskParams(
48-
agent=agent,
49-
task=task,
50-
params=params,
51-
),
52-
id=task.id,
53-
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
54-
execution_timeout=execution_timeout,
55-
)
68+
execution_timeout = timedelta(seconds=timeout_seconds) if timeout_seconds and timeout_seconds > 0 else None
69+
with _acp_dispatch_span(f"acp.task_create:{task.id}"):
70+
return await self._temporal_client.start_workflow(
71+
workflow=self._env_vars.WORKFLOW_NAME,
72+
arg=CreateTaskParams(
73+
agent=agent,
74+
task=task,
75+
params=params,
76+
),
77+
id=task.id,
78+
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
79+
execution_timeout=execution_timeout,
80+
)
5681

5782
async def get_state(self, task_id: str) -> WorkflowState:
5883
"""
@@ -63,16 +88,17 @@ async def get_state(self, task_id: str) -> WorkflowState:
6388
)
6489

6590
async def send_event(self, agent: Agent, task: Task, event: Event, request: dict | None = None) -> None:
66-
return await self._temporal_client.send_signal(
67-
workflow_id=task.id,
68-
signal=SignalName.RECEIVE_EVENT.value,
69-
payload=SendEventParams(
70-
agent=agent,
71-
task=task,
72-
event=event,
73-
request=request,
74-
).model_dump(),
75-
)
91+
with _acp_dispatch_span(f"acp.event_send:{task.id}"):
92+
return await self._temporal_client.send_signal(
93+
workflow_id=task.id,
94+
signal=SignalName.RECEIVE_EVENT.value,
95+
payload=SendEventParams(
96+
agent=agent,
97+
task=task,
98+
event=event,
99+
request=request,
100+
).model_dump(),
101+
)
76102

77103
async def interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None:
78104
"""Forward a task/interrupt to the running workflow as a dedicated signal.

0 commit comments

Comments
 (0)