Skip to content

Commit d4001ca

Browse files
Support end_user_id for agentex agents & automatically include in spans
1 parent 77a1316 commit d4001ca

14 files changed

Lines changed: 1094 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from agentex.lib.core.temporal.interceptors.baggage_interceptor import (
2+
END_USER_ID_HEADER,
3+
DISABLE_TRACE_BAGGAGE_ENV,
4+
TraceBaggageInterceptor,
5+
trace_baggage_disabled,
6+
)
7+
8+
__all__ = [
9+
"END_USER_ID_HEADER",
10+
"DISABLE_TRACE_BAGGAGE_ENV",
11+
"TraceBaggageInterceptor",
12+
"trace_baggage_disabled",
13+
]
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Carries the caller-supplied ``end_user_id`` from a workflow into its activities.
2+
3+
For Temporal agents the ACP server only *starts* the workflow; the work runs in a
4+
separate worker process, so a contextvar set at the ACP boundary never reaches the
5+
code that creates spans. The value does survive that hop inside the workflow start
6+
args and signal payloads, which is where the workflow-inbound half harvests it
7+
from. Spans created by workflow code are covered too, since those dispatch through
8+
a ``START_SPAN`` activity and so pass through the same activity-inbound hop.
9+
10+
Replay-safe: headers are derived only from start/signal args, with no clock or I/O.
11+
12+
Registered by default in ``AgentexWorker``; ``AGENTEX_DISABLE_TRACE_BAGGAGE``
13+
(``1``/``true``/``yes``/``on``) disables it.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
from typing import Any, override
20+
21+
from temporalio import workflow
22+
from temporalio.worker import (
23+
Interceptor,
24+
HandleSignalInput,
25+
StartActivityInput,
26+
ExecuteActivityInput,
27+
ExecuteWorkflowInput,
28+
StartLocalActivityInput,
29+
ActivityInboundInterceptor,
30+
WorkflowInboundInterceptor,
31+
WorkflowOutboundInterceptor,
32+
)
33+
from temporalio.converter import default
34+
35+
from agentex.lib.utils.logging import make_logger
36+
from agentex.lib.core.tracing.baggage import set_end_user_id, reset_end_user_id
37+
38+
logger = make_logger(__name__)
39+
40+
END_USER_ID_HEADER = "agentex-end-user-id"
41+
_WORKFLOW_ATTR = "_agentex_end_user_id"
42+
DISABLE_TRACE_BAGGAGE_ENV = "AGENTEX_DISABLE_TRACE_BAGGAGE"
43+
44+
45+
def trace_baggage_disabled() -> bool:
46+
raw = os.environ.get(DISABLE_TRACE_BAGGAGE_ENV, "").strip().lower()
47+
return raw in ("1", "true", "yes", "on")
48+
49+
50+
def _extract_end_user_id(arg: Any) -> str | None:
51+
# Args arrive as the params model or as its dumped dict, depending on how the
52+
# caller serialized them.
53+
if arg is None:
54+
return None
55+
value = arg.get("end_user_id") if isinstance(arg, dict) else getattr(arg, "end_user_id", None)
56+
return value if isinstance(value, str) and value else None
57+
58+
59+
class TraceBaggageInterceptor(Interceptor):
60+
"""Threads the caller-supplied end user from workflow start args to activities."""
61+
62+
def __init__(self) -> None:
63+
self._payload_converter = default().payload_converter
64+
65+
@override
66+
def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor:
67+
return _TraceBaggageActivityInboundInterceptor(next, self._payload_converter)
68+
69+
@override
70+
def workflow_interceptor_class(self, input: Any) -> type[WorkflowInboundInterceptor] | None: # noqa: ARG002
71+
return _TraceBaggageWorkflowInboundInterceptor
72+
73+
74+
class _TraceBaggageWorkflowInboundInterceptor(WorkflowInboundInterceptor):
75+
"""Harvests the end user off inbound workflow args and signal args."""
76+
77+
@override
78+
async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any:
79+
self._stash(input.args[0] if input.args else None)
80+
return await self.next.execute_workflow(input)
81+
82+
@override
83+
async def handle_signal(self, input: HandleSignalInput) -> None:
84+
# Signals (e.g. event/send) carry their own end user, which may differ
85+
# from the one that created the task. Last one in wins so the activities
86+
# a signal triggers are attributed to the caller that triggered them.
87+
self._stash(input.args[0] if input.args else None)
88+
return await self.next.handle_signal(input)
89+
90+
def _stash(self, arg: Any) -> None:
91+
end_user_id = _extract_end_user_id(arg)
92+
if end_user_id is None:
93+
return
94+
try:
95+
setattr(workflow.instance(), _WORKFLOW_ATTR, end_user_id)
96+
except Exception as exc:
97+
logger.debug(f"Could not stash end_user_id on the workflow instance: {exc}")
98+
99+
@override
100+
def init(self, outbound: WorkflowOutboundInterceptor) -> None:
101+
self.next.init(_TraceBaggageWorkflowOutboundInterceptor(outbound, default().payload_converter))
102+
103+
104+
class _TraceBaggageWorkflowOutboundInterceptor(WorkflowOutboundInterceptor):
105+
"""Copies the stashed end user into activity headers."""
106+
107+
def __init__(self, next: WorkflowOutboundInterceptor, payload_converter: Any) -> None:
108+
super().__init__(next)
109+
self._payload_converter = payload_converter
110+
111+
@override
112+
def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle[Any]:
113+
self._add_header(input)
114+
return self.next.start_activity(input)
115+
116+
@override
117+
def start_local_activity(self, input: StartLocalActivityInput) -> workflow.ActivityHandle[Any]:
118+
self._add_header(input)
119+
return self.next.start_local_activity(input)
120+
121+
def _add_header(self, input: StartActivityInput | StartLocalActivityInput) -> None:
122+
try:
123+
end_user_id = getattr(workflow.instance(), _WORKFLOW_ATTR, None)
124+
if not end_user_id:
125+
return
126+
headers = dict(input.headers or {})
127+
headers[END_USER_ID_HEADER] = self._payload_converter.to_payload(end_user_id)
128+
input.headers = headers
129+
except Exception as exc:
130+
logger.debug(f"Could not add end_user_id to activity headers: {exc}")
131+
132+
133+
class _TraceBaggageActivityInboundInterceptor(ActivityInboundInterceptor):
134+
"""Decodes the header into the tracing baggage contextvar for the activity."""
135+
136+
def __init__(self, next: ActivityInboundInterceptor, payload_converter: Any) -> None:
137+
super().__init__(next)
138+
self._payload_converter = payload_converter
139+
140+
@override
141+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
142+
end_user_id: str | None = None
143+
if input.headers and END_USER_ID_HEADER in input.headers:
144+
try:
145+
end_user_id = self._payload_converter.from_payload(input.headers[END_USER_ID_HEADER], str)
146+
except Exception as exc:
147+
logger.debug(f"Could not decode the end_user_id activity header: {exc}")
148+
149+
if end_user_id is None:
150+
return await self.next.execute_activity(input)
151+
152+
token = set_end_user_id(end_user_id)
153+
try:
154+
return await self.next.execute_activity(input)
155+
finally:
156+
reset_end_user_id(token)

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ def __init__(
2727
self._env_vars = env_vars
2828

2929

30-
async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | None) -> str:
30+
async def submit_task(
31+
self,
32+
agent: Agent,
33+
task: Task,
34+
params: dict[str, Any] | None,
35+
end_user_id: str | None = None,
36+
) -> str:
3137
"""
3238
Submit a task to the async runtime for execution.
3339
@@ -48,6 +54,7 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
4854
agent=agent,
4955
task=task,
5056
params=params,
57+
end_user_id=end_user_id,
5158
),
5259
id=task.id,
5360
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
@@ -62,7 +69,14 @@ async def get_state(self, task_id: str) -> WorkflowState:
6269
workflow_id=task_id,
6370
)
6471

65-
async def send_event(self, agent: Agent, task: Task, event: Event, request: dict | None = None) -> None:
72+
async def send_event(
73+
self,
74+
agent: Agent,
75+
task: Task,
76+
event: Event,
77+
request: dict | None = None,
78+
end_user_id: str | None = None,
79+
) -> None:
6680
return await self._temporal_client.send_signal(
6781
workflow_id=task.id,
6882
signal=SignalName.RECEIVE_EVENT.value,
@@ -71,6 +85,7 @@ async def send_event(self, agent: Agent, task: Task, event: Event, request: dict
7185
task=task,
7286
event=event,
7387
request=request,
88+
end_user_id=end_user_id,
7489
).model_dump(),
7590
)
7691

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
from agentex.lib.utils.registration import register_agent
3232
from agentex.lib.environment_variables import EnvironmentVariables
3333
from agentex.lib.core.compat.version_guard import assert_backend_compatible
34+
from agentex.lib.core.temporal.interceptors.baggage_interceptor import (
35+
DISABLE_TRACE_BAGGAGE_ENV,
36+
TraceBaggageInterceptor,
37+
trace_baggage_disabled,
38+
)
3439

3540
logger = make_logger(__name__)
3641

@@ -91,6 +96,18 @@ def _validate_interceptors(interceptors: list) -> None:
9196
)
9297

9398

99+
def _with_default_interceptors(interceptors: list) -> list:
100+
"""Prepend the framework's own interceptors, first so user ones see their effect."""
101+
if trace_baggage_disabled():
102+
logger.info(f"Trace baggage propagation disabled via {DISABLE_TRACE_BAGGAGE_ENV}")
103+
return list(interceptors)
104+
105+
if any(isinstance(i, TraceBaggageInterceptor) for i in interceptors):
106+
return list(interceptors)
107+
108+
return [TraceBaggageInterceptor(), *interceptors]
109+
110+
94111
async def get_temporal_client(
95112
temporal_address: str,
96113
metrics_url: str | None = None,
@@ -203,6 +220,8 @@ async def run(
203220
if self.interceptors:
204221
_validate_interceptors(self.interceptors)
205222

223+
interceptors = _with_default_interceptors(self.interceptors)
224+
206225
temporal_client = await get_temporal_client(
207226
temporal_address=os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"),
208227
plugins=self.plugins,
@@ -229,7 +248,7 @@ async def run(
229248
max_concurrent_activities=self.max_concurrent_activities,
230249
build_id=str(uuid.uuid4()),
231250
debug_mode=debug_enabled, # Disable deadlock detection in debug mode
232-
interceptors=self.interceptors, # Pass interceptors to Worker
251+
interceptors=interceptors, # Pass interceptors to Worker
233252
)
234253

235254
logger.info(f"Starting workers for task queue: {self.task_queue}")
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Request-scoped identifiers that the framework stamps onto trace spans.
2+
3+
The tracing processors cannot read per-request context: they run on a long-lived
4+
queue-drain task whose contextvars were frozen when the first span in the process
5+
was enqueued (see ``span_queue.py``). So per-request values have to be attached to
6+
the ``Span`` object *before* it is enqueued, travelling with it as data rather than
7+
as ambient context.
8+
9+
Deliberately one reserved, size-capped scalar rather than an arbitrary dict, which
10+
keeps caller-controlled free-form data (and its PHI and cardinality risks) off the
11+
tracing backend.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import Any, TypeVar
17+
from contextvars import Token, ContextVar
18+
19+
from agentex.lib.utils.logging import make_logger
20+
21+
logger = make_logger(__name__)
22+
23+
# Matches the ``__key__`` style the SGP processor stamps its static keys with.
24+
END_USER_ID_SPAN_KEY = "__end_user_id__"
25+
# Mirrors the cap the control plane enforces on the RPC field.
26+
END_USER_ID_MAX_LENGTH = 256
27+
28+
_end_user_id: ContextVar[str | None] = ContextVar("agentex_end_user_id", default=None)
29+
30+
31+
def set_end_user_id(value: str | None) -> Token[str | None]:
32+
"""Bind the end user for the current context, returning a reset token.
33+
34+
Normalized here rather than at the merge point, so a span never carries
35+
something the backend would reject.
36+
"""
37+
normalized: str | None = None
38+
if isinstance(value, str):
39+
stripped = value.strip()
40+
if stripped:
41+
normalized = stripped[:END_USER_ID_MAX_LENGTH]
42+
if len(stripped) > END_USER_ID_MAX_LENGTH:
43+
logger.warning(f"Truncated end_user_id from {len(stripped)} to {END_USER_ID_MAX_LENGTH} characters")
44+
return _end_user_id.set(normalized)
45+
46+
47+
def get_end_user_id() -> str | None:
48+
return _end_user_id.get()
49+
50+
51+
def reset_end_user_id(token: Token[str | None]) -> None:
52+
_end_user_id.reset(token)
53+
54+
55+
def get_baggage() -> dict[str, str]:
56+
"""The span data keys to stamp for the current context."""
57+
end_user_id = _end_user_id.get()
58+
if end_user_id is None:
59+
return {}
60+
return {END_USER_ID_SPAN_KEY: end_user_id}
61+
62+
63+
DataT = TypeVar("DataT")
64+
65+
66+
def merge_baggage_into_span_data(data: DataT) -> DataT | dict[str, Any]:
67+
"""Merge the current context's baggage into already-serialized span data.
68+
69+
Explicit call-site data wins on collision. Span data may also be a list, which
70+
has nowhere to put a key, so those are returned untouched.
71+
"""
72+
baggage = get_baggage()
73+
if not baggage:
74+
return data
75+
if data is None:
76+
return dict(baggage)
77+
if not isinstance(data, dict):
78+
logger.debug(f"Skipping trace baggage merge for span data of type {type(data).__name__}")
79+
return data
80+
return {**baggage, **data}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from agentex.types.span import Span
1212
from agentex.lib.utils.logging import make_logger
1313
from agentex.lib.utils.model_utils import recursive_model_dump
14+
from agentex.lib.core.tracing.baggage import merge_baggage_into_span_data
1415
from agentex.lib.core.tracing.span_error import set_span_error
1516
from agentex.lib.core.tracing.span_queue import (
1617
SpanEventType,
@@ -79,6 +80,7 @@ def start_span(
7980

8081
serialized_input = recursive_model_dump(input) if input else None
8182
serialized_data = recursive_model_dump(data) if data else None
83+
serialized_data = merge_baggage_into_span_data(serialized_data)
8284
id = str(uuid.uuid4())
8385

8486
span = Span(
@@ -116,6 +118,9 @@ def end_span(
116118
span.input = recursive_model_dump(span.input) if span.input else None
117119
span.output = recursive_model_dump(span.output) if span.output else None
118120
span.data = recursive_model_dump(span.data) if span.data else None
121+
# Redundant unless the caller assigned ``span.data`` wholesale after start,
122+
# which would otherwise drop the baggage merged in there.
123+
span.data = merge_baggage_into_span_data(span.data)
119124

120125
for processor in self.processors:
121126
processor.on_span_end(span)
@@ -229,6 +234,7 @@ async def start_span(
229234

230235
serialized_input = recursive_model_dump(input) if input else None
231236
serialized_data = recursive_model_dump(data) if data else None
237+
serialized_data = merge_baggage_into_span_data(serialized_data)
232238
id = str(uuid.uuid4())
233239

234240
span = Span(
@@ -266,6 +272,9 @@ async def end_span(
266272
span.input = recursive_model_dump(span.input) if span.input else None
267273
span.output = recursive_model_dump(span.output) if span.output else None
268274
span.data = recursive_model_dump(span.data) if span.data else None
275+
# Redundant unless the caller assigned ``span.data`` wholesale after start,
276+
# which would otherwise drop the baggage merged in there.
277+
span.data = merge_baggage_into_span_data(span.data)
269278

270279
if self.processors:
271280
self._span_queue.enqueue(SpanEventType.END, span.model_copy(deep=True), self.processors)

0 commit comments

Comments
 (0)