Skip to content

Commit 7b34987

Browse files
NiteshDhanpalclaude
andcommitted
feat(tracing): propagate OTel trace context across Temporal boundaries
Temporal serializes start_workflow / execute_activity across (potentially cross-process) boundaries and does not carry the active W3C traceparent, so spans created inside a workflow or activity become detached roots -- the trace shatters at every Temporal hop. This bites agentex directly: adk.tracing.span creates the business span as a Temporal activity when in_temporal_workflow(), so without propagation those spans detach from the turn's obs trace. Wire temporalio's first-party TracingInterceptor onto both Temporal client factories (worker client + the ACP's workflow-starting TemporalClient) and the AgentexWorker, so client -> workflow -> activity is one trace. The interceptor injects context on outbound calls and extracts + roots execution spans under it, using the global OpenTelemetry propagator. - ENABLED BY DEFAULT. Opt out with AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false (also 0/no/off). Safe no-op (never raises) if temporalio's OTel contrib isn't importable, so default-on can't break a worker. - Tracing interceptor is placed OUTERMOST on the worker so existing business interceptors (and their spans) nest under the propagated span. Tests: tests/lib/core/tracing/test_temporal_interceptor.py -- default-on returns a TracingInterceptor, env opt-out returns [], contrib-missing returns []. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 37abff8 commit 7b34987

4 files changed

Lines changed: 125 additions & 1 deletion

File tree

src/agentex/lib/core/clients/temporal/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from temporalio.converter import PayloadCodec, DataConverter
1010
from temporalio.contrib.pydantic import pydantic_data_converter
1111

12+
from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors
13+
1214
# class DateTimeJSONEncoder(AdvancedJSONEncoder):
1315
# def default(self, o: Any) -> Any:
1416
# if isinstance(o, datetime.datetime):
@@ -136,6 +138,9 @@ async def get_temporal_client(
136138
connect_kwargs: dict[str, Any] = {
137139
"target_host": temporal_address,
138140
"plugins": plugins,
141+
# Propagate OTel trace context on outbound start_workflow / execute_activity
142+
# (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable).
143+
"interceptors": temporal_tracing_interceptors(),
139144
}
140145

141146
if data_converter is not None:

src/agentex/lib/core/temporal/workers/worker.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from agentex.lib.utils.logging import make_logger
3131
from agentex.lib.utils.registration import register_agent
32+
from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors
3233
from agentex.lib.environment_variables import EnvironmentVariables
3334
from agentex.lib.core.compat.version_guard import assert_backend_compatible
3435

@@ -126,6 +127,9 @@ async def get_temporal_client(
126127
connect_kwargs: dict[str, Any] = {
127128
"target_host": temporal_address,
128129
"plugins": plugins,
130+
# Propagate OTel trace context on outbound start_workflow / execute_activity
131+
# (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable).
132+
"interceptors": temporal_tracing_interceptors(),
129133
}
130134

131135
if data_converter is not None:
@@ -229,7 +233,9 @@ async def run(
229233
max_concurrent_activities=self.max_concurrent_activities,
230234
build_id=str(uuid.uuid4()),
231235
debug_mode=debug_enabled, # Disable deadlock detection in debug mode
232-
interceptors=self.interceptors, # Pass interceptors to Worker
236+
# Tracing interceptor OUTERMOST so business interceptors (and the spans
237+
# they create) nest under the propagated workflow/activity span.
238+
interceptors=[*temporal_tracing_interceptors(), *self.interceptors],
233239
)
234240

235241
logger.info(f"Starting workers for task queue: {self.task_queue}")
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""OpenTelemetry trace-context propagation across Temporal boundaries.
2+
3+
Temporal serializes ``start_workflow`` / ``execute_activity`` across (potentially
4+
cross-process) boundaries, and does NOT carry the active W3C ``traceparent`` by
5+
default. So any span created inside a workflow or activity becomes a **new
6+
detached root** -- the trace shatters at every Temporal hop.
7+
8+
This bites agentex directly: ``adk.tracing.span`` runs span creation as a
9+
Temporal activity when ``in_temporal_workflow()`` is true, so without propagation
10+
those business spans detach from the turn's obs trace.
11+
12+
Wiring temporalio's first-party ``TracingInterceptor`` onto the Temporal client
13+
and worker injects the active span context into Temporal headers on the caller
14+
side and extracts + continues it on the workflow/activity side, using the global
15+
OpenTelemetry propagator -- so ``client -> workflow -> activity`` is one trace.
16+
17+
Enabled by DEFAULT. Set ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false``
18+
(also accepts ``0`` / ``no`` / ``off``) to turn it off. It also degrades to a
19+
no-op -- and never raises -- if temporalio's OpenTelemetry contrib isn't
20+
importable, so enabling it by default can't break a worker.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import os
26+
from typing import Any
27+
28+
from agentex.lib.utils.logging import make_logger
29+
30+
logger = make_logger(__name__)
31+
32+
_ENABLE_ENV = "AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED"
33+
_FALSEY = {"0", "false", "no", "off"}
34+
35+
36+
def temporal_trace_interceptor_enabled() -> bool:
37+
"""Whether the Temporal OTel trace interceptor should be installed.
38+
39+
Defaults to True; disabled only when ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED``
40+
is set to a falsy value (``0`` / ``false`` / ``no`` / ``off``)."""
41+
return os.environ.get(_ENABLE_ENV, "true").strip().lower() not in _FALSEY
42+
43+
44+
def temporal_tracing_interceptors() -> list[Any]:
45+
"""Interceptors that propagate OpenTelemetry trace context across Temporal.
46+
47+
Returns ``[TracingInterceptor()]`` (enabled by default) so callers can splat
48+
it into a client's / worker's ``interceptors=`` list. Returns ``[]`` when
49+
disabled via env, or when temporalio's OpenTelemetry contrib is not
50+
importable. Never raises -- observability wiring must not break a worker.
51+
52+
``TracingInterceptor`` implements both the client and worker interceptor
53+
interfaces, so the same call is used on both sides:
54+
- on the **client**, it injects context on outbound ``start_workflow`` /
55+
``execute_activity`` calls;
56+
- on the **worker**, it extracts context and roots the workflow / activity
57+
execution spans under it.
58+
"""
59+
if not temporal_trace_interceptor_enabled():
60+
logger.info("Temporal OTel trace interceptor disabled via %s", _ENABLE_ENV)
61+
return []
62+
try:
63+
from temporalio.contrib.opentelemetry import TracingInterceptor
64+
65+
# Construct inside the try so a constructor failure (not just a missing
66+
# contrib) also falls back to a no-op instead of aborting worker startup.
67+
return [TracingInterceptor()]
68+
except Exception as exc: # contrib unavailable OR constructor failure -> no-op, never raise
69+
logger.warning(
70+
"Temporal OTel trace interceptor unavailable (%s); traces will not propagate across Temporal boundaries.",
71+
exc,
72+
)
73+
return []
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Unit tests for the Temporal OTel trace-interceptor wiring.
2+
3+
Verifies the interceptor is on by default, the opt-out env flag, and the safe
4+
no-op fallback when temporalio's OpenTelemetry contrib isn't importable.
5+
"""
6+
7+
import sys
8+
9+
import pytest
10+
11+
from agentex.lib.core.tracing import temporal as temporal_tracing
12+
13+
14+
class TestTemporalTraceInterceptor:
15+
def test_enabled_by_default(self, monkeypatch):
16+
monkeypatch.delenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", raising=False)
17+
assert temporal_tracing.temporal_trace_interceptor_enabled() is True
18+
19+
interceptors = temporal_tracing.temporal_tracing_interceptors()
20+
assert len(interceptors) == 1
21+
# temporalio's first-party OTel interceptor
22+
assert type(interceptors[0]).__name__ == "TracingInterceptor"
23+
24+
@pytest.mark.parametrize("value", ["false", "0", "no", "off", "FALSE", "Off"])
25+
def test_disabled_via_env(self, monkeypatch, value):
26+
monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", value)
27+
assert temporal_tracing.temporal_trace_interceptor_enabled() is False
28+
assert temporal_tracing.temporal_tracing_interceptors() == []
29+
30+
@pytest.mark.parametrize("value", ["true", "1", "yes", "TRUE", "anything"])
31+
def test_enabled_for_non_falsy_values(self, monkeypatch, value):
32+
monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", value)
33+
assert temporal_tracing.temporal_trace_interceptor_enabled() is True
34+
35+
def test_no_op_when_contrib_unimportable(self, monkeypatch):
36+
# Enabled, but temporalio's OTel contrib not importable -> [] (never raises),
37+
# so default-on can't break a worker that lacks the contrib.
38+
monkeypatch.delenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", raising=False)
39+
monkeypatch.setitem(sys.modules, "temporalio.contrib.opentelemetry", None)
40+
assert temporal_tracing.temporal_tracing_interceptors() == []

0 commit comments

Comments
 (0)