Skip to content

Commit d718f78

Browse files
feat(tracing): stamp sgp evals run and row ids on SGP spans
Spans of a task whose task_metadata carries sgp_evals now get flat sgp_evals_generation_run_id, sgp_evals_row_id and sgp_evals_attempt_idx keys on the SGP copy. Sync and async agents read the task in the ACP server. Temporal agents carry it in the workflow memo and activity headers to the worker. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 18926e8 commit d718f78

7 files changed

Lines changed: 380 additions & 5 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from agentex.types.agent import Agent
1111
from agentex.types.event import Event
1212
from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
13+
from agentex.lib.core.tracing import sgp_evals
1314
from agentex.lib.environment_variables import EnvironmentVariables
1415
from agentex.lib.core.clients.temporal.types import WorkflowState, ConflictWorkflowPolicy
1516
from agentex.lib.core.temporal.types.workflow import SignalName
@@ -91,6 +92,9 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
9192
execution_timeout = timedelta(seconds=timeout_seconds) if timeout_seconds and timeout_seconds > 0 else None
9293
# USE_EXISTING makes task/create idempotent
9394
# If same task ID is already running Temporal returns a handle to the existing run instead of raising WorkflowAlreadyStarted
95+
# Eval tasks carry their span attrs in the memo so the worker can stamp them.
96+
eval_attrs = sgp_evals.span_attrs_from_task_metadata(task.task_metadata)
97+
memo_kwargs: dict[str, Any] = {"memo": {sgp_evals.MEMO_KEY: eval_attrs}} if eval_attrs else {}
9498
with _acp_dispatch_span("acp.task_create", task_id=task.id):
9599
return await self._temporal_client.start_workflow(
96100
workflow=self._env_vars.WORKFLOW_NAME,
@@ -103,6 +107,7 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
103107
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
104108
execution_timeout=execution_timeout,
105109
conflict_policy=ConflictWorkflowPolicy.USE_EXISTING,
110+
**memo_kwargs,
106111
)
107112

108113
async def get_state(self, task_id: str) -> WorkflowState:

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from agentex.lib.core.tracing.span_queue import shutdown_default_span_queue
3535
from agentex.lib.core.compat.version_guard import assert_backend_compatible
3636
from agentex.lib.core.observability.sgp_obs_setup import init_sgp_obs, shutdown_sgp_obs
37+
from agentex.lib.core.tracing.sgp_evals_interceptor import SGPEvalsInterceptor
3738
from agentex.lib.core.tracing.tracing_processor_manager import shutdown_sync_tracing_processors
3839

3940
logger = make_logger(__name__)
@@ -274,7 +275,7 @@ async def run(
274275
build_id=str(uuid.uuid4()),
275276
debug_mode=debug_enabled, # Disable deadlock detection in debug mode
276277
# Temporal inherits client tracing before these business interceptors.
277-
interceptors=self.interceptors,
278+
interceptors=[*self.interceptors, SGPEvalsInterceptor()],
278279
)
279280

280281
logger.info(f"Starting workers for task queue: {self.task_queue}")

‎src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from scale_gp_beta.lib.tracing.span import Span as SGPSpan
1212

1313
from agentex.types.span import Span
14-
from agentex.lib.core.tracing import code_revision
14+
from agentex.lib.core.tracing import sgp_evals, code_revision
1515
from agentex.lib.types.tracing import SGPTracingProcessorConfig
1616
from agentex.lib.utils.logging import make_logger
1717
from agentex.lib.core.observability import tracing_metrics_recording as _metrics
@@ -71,7 +71,7 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None:
7171

7272

7373
def _sgp_metadata(span: Span) -> Any:
74-
"""Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA.
74+
"""Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA and evals run/row ids.
7575
7676
Returns a COPY rather than mutating ``span``. ``trace.py`` hands the same
7777
Span instance to every registered processor, so anything written onto
@@ -83,11 +83,14 @@ def _sgp_metadata(span: Span) -> Any:
8383
leak like that today. Left as-is: changing five long-shipped fields is not
8484
this change's business.)
8585
"""
86+
extra: dict[str, Any] = dict(sgp_evals.attrs_for_span(span))
8687
commit_sha = code_revision.commit_sha()
87-
if commit_sha is None:
88+
if commit_sha is not None:
89+
extra[code_revision.COMMIT_SHA_KEY] = commit_sha
90+
if not extra:
8891
return span.data
8992
if isinstance(span.data, dict):
90-
return {**span.data, code_revision.COMMIT_SHA_KEY: commit_sha}
93+
return {**span.data, **extra}
9194
# List-shaped data is an accepted `data` shape and has nowhere to put a
9295
# metadata key; leave it untouched rather than dropping the caller's data.
9396
return span.data
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Run/row attribution for spans of SGP evals generation-unit tasks.
2+
3+
The evals service tags each unit's task with ``task_metadata`` carrying
4+
``sgp_evals`` plus the run, row and attempt it belongs to. The ids are copied
5+
onto the SGP copy of every span as flat ``sgp_evals_*`` keys so a spans search
6+
on ``extra_metadata`` finds a unit's spans. Flat because sgp-traces treats
7+
dotted keys differently on ClickHouse and Postgres.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import threading
13+
from typing import Any
14+
from collections import OrderedDict
15+
16+
from agentex.types.span import Span
17+
18+
__all__ = ("MEMO_KEY", "register_task", "register_task_metadata", "span_attrs_from_task_metadata", "attrs_for_span")
19+
20+
TASK_METADATA_MARKER = "sgp_evals"
21+
SPAN_KEY_PREFIX = "sgp_evals_"
22+
_TASK_METADATA_KEYS = ("generation_run_id", "row_id", "attempt_idx")
23+
# Temporal workflow memo key the ACP server sets so the worker process can stamp the same attrs.
24+
MEMO_KEY = "sgp_evals_span_attrs"
25+
26+
# Only eval tasks are stored, so this stays tiny. The LRU bound caps a long-lived agent process.
27+
_MAX_TASKS = 10_000
28+
_attrs_by_task: OrderedDict[str, dict[str, Any]] = OrderedDict()
29+
_lock = threading.Lock()
30+
31+
32+
def span_attrs_from_task_metadata(task_metadata: Any) -> dict[str, Any] | None:
33+
"""The flat span attrs for an evals generation-unit task, or None for any other task."""
34+
if not isinstance(task_metadata, dict) or task_metadata.get(TASK_METADATA_MARKER) is None:
35+
return None
36+
attrs = {
37+
f"{SPAN_KEY_PREFIX}{key}": task_metadata[key] for key in _TASK_METADATA_KEYS if task_metadata.get(key) is not None
38+
}
39+
return attrs or None
40+
41+
42+
def register_task(task_id: str, attrs: dict[str, Any]) -> None:
43+
with _lock:
44+
_attrs_by_task[task_id] = attrs
45+
_attrs_by_task.move_to_end(task_id)
46+
while len(_attrs_by_task) > _MAX_TASKS:
47+
_attrs_by_task.popitem(last=False)
48+
49+
50+
def register_task_metadata(task_id: str, task_metadata: Any) -> dict[str, Any] | None:
51+
"""Remember an eval task's span attrs. No-op (and no lookup cost) for every other task."""
52+
attrs = span_attrs_from_task_metadata(task_metadata)
53+
if attrs is not None:
54+
register_task(task_id, attrs)
55+
return attrs
56+
57+
58+
def attrs_for_span(span: Span) -> dict[str, Any]:
59+
"""Attrs for the task a span belongs to, found by ``span.task_id`` then ``span.trace_id``."""
60+
if not _attrs_by_task:
61+
return {}
62+
with _lock:
63+
for key in (span.task_id, span.trace_id):
64+
if key and key in _attrs_by_task:
65+
return dict(_attrs_by_task[key])
66+
return {}
67+
68+
69+
def clear() -> None:
70+
"""Reset the registry (test isolation)."""
71+
with _lock:
72+
_attrs_by_task.clear()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Carry an eval task's span attrs from its Temporal workflow memo into the worker's activities.
2+
3+
Spans are emitted by activities in the worker process, which never sees the ACP
4+
server's task. The ACP server puts the attrs in the workflow memo, the outbound
5+
interceptor copies them onto each activity's headers, and the activity
6+
interceptor registers them for the task so the SGP processor can stamp spans.
7+
Non-eval workflows have no memo entry and get no header.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import Any, override
13+
14+
from temporalio import activity, workflow
15+
from temporalio.worker import (
16+
Interceptor,
17+
StartActivityInput,
18+
ExecuteActivityInput,
19+
ActivityInboundInterceptor,
20+
WorkflowInboundInterceptor,
21+
WorkflowOutboundInterceptor,
22+
)
23+
from temporalio.converter import default
24+
25+
from agentex.lib.utils.logging import make_logger
26+
from agentex.lib.core.tracing.sgp_evals import MEMO_KEY, register_task
27+
28+
logger = make_logger(__name__)
29+
30+
ATTRS_HEADER = "sgp-evals-span-attrs"
31+
_converter = default().payload_converter
32+
33+
34+
class SGPEvalsInterceptor(Interceptor):
35+
@override
36+
def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor:
37+
return _ActivityInbound(next)
38+
39+
@override
40+
def workflow_interceptor_class(self, input: Any) -> type[WorkflowInboundInterceptor] | None:
41+
return _WorkflowInbound
42+
43+
44+
class _WorkflowInbound(WorkflowInboundInterceptor):
45+
@override
46+
def init(self, outbound: WorkflowOutboundInterceptor) -> None:
47+
super().init(_WorkflowOutbound(outbound))
48+
49+
50+
class _WorkflowOutbound(WorkflowOutboundInterceptor):
51+
@override
52+
def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle[Any]:
53+
attrs = workflow.memo_value(MEMO_KEY, default=None)
54+
if isinstance(attrs, dict) and attrs:
55+
input.headers = {**input.headers, ATTRS_HEADER: _converter.to_payload(attrs)}
56+
return super().start_activity(input)
57+
58+
59+
class _ActivityInbound(ActivityInboundInterceptor):
60+
@override
61+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
62+
payload = input.headers.get(ATTRS_HEADER)
63+
# The workflow id is the task id (see TemporalTaskService.submit_task).
64+
task_id = activity.info().workflow_id
65+
if payload is not None and task_id:
66+
try:
67+
register_task(task_id, _converter.from_payload(payload, dict))
68+
except Exception:
69+
logger.warning("failed to read sgp evals span attrs from activity headers", exc_info=True)
70+
return await super().execute_activity(input)

‎src/agentex/lib/sdk/fastacp/base/base_acp_server.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
SendMessageParams,
2525
InterruptTaskParams,
2626
)
27+
from agentex.lib.core.tracing import sgp_evals
2728
from agentex.lib.utils.logging import make_logger, ctx_var_request_id
2829
from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse
2930
from agentex.lib.utils.model_utils import BaseModel
@@ -351,6 +352,10 @@ async def _handle_jsonrpc(self, request: Request):
351352
params_data["request"] = {"headers": custom_headers}
352353
params = params_model.model_validate(params_data)
353354

355+
task = getattr(params, "task", None)
356+
if task is not None:
357+
sgp_evals.register_task_metadata(task.id, task.task_metadata)
358+
354359
if method in RPC_SYNC_METHODS:
355360
handler = self._handlers[method]
356361
result = await handler(params)

0 commit comments

Comments
 (0)