Skip to content

Commit e95c2ca

Browse files
NiteshDhanpalclaude
andcommitted
feat(observability): record true event-delivery outcome (agentex.events.delivery)
event/send is accepted and acked by the ACP server before the Temporal signal is attempted, so an HTTP success does not mean the event reached a live workflow. Add an agentex.events.delivery counter tagged with an outcome label (delivered / no_live_workflow / error) and record it around send_signal in TemporalTaskService.send_event. no_live_workflow (RPCError NOT_FOUND) captures events that arrived after the task completed or idle-timed-out. Recording is best-effort and gated on AGENTEX_EVENT_METRICS; behaviour is otherwise unchanged (send_event still raises). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2338408 commit e95c2ca

2 files changed

Lines changed: 127 additions & 10 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""OTel metrics for event delivery (task/event_send -> live workflow).
2+
3+
Records whether an EVENT_SEND signal actually reached a *running* Temporal
4+
workflow, vs. only being accepted by the ACP server. ``event/send`` is async:
5+
the ACP server returns a 200 ack before the signal is even attempted (see
6+
``base_acp_server._handle_jsonrpc`` background branch), so HTTP status is not a
7+
delivery signal — this counter is. Under load, tasks that have already
8+
completed or idle-timed-out have no workflow to receive the event, which
9+
Temporal surfaces as an ``RPCError`` with status ``NOT_FOUND``.
10+
11+
The meter is no-op when the application hasn't configured a ``MeterProvider``,
12+
so importing this module is safe for runtimes that don't use OTel. Instruments
13+
are created lazily on first ``get_event_metrics()`` call so a ``MeterProvider``
14+
configured *after* this module is imported still binds correctly.
15+
16+
Recording is gated on ``AGENTEX_EVENT_METRICS`` (default on) and every record
17+
is best-effort — it never raises into the business path.
18+
19+
Cardinality is bounded: the only attribute is ``outcome``, drawn from a small
20+
fixed set (see the ``OUTCOME_*`` constants). Resource attributes
21+
(``service.name``, ``k8s.*``, etc.) come from the application's OTel resource
22+
configuration and are added to every series automatically.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import os
28+
from typing import Optional
29+
30+
from opentelemetry import metrics
31+
32+
# Outcome label values (bounded cardinality — do NOT add task IDs etc.).
33+
OUTCOME_DELIVERED = "delivered"
34+
OUTCOME_NO_LIVE_WORKFLOW = "no_live_workflow"
35+
OUTCOME_ERROR = "error"
36+
37+
38+
class EventMetrics:
39+
"""Lazily-created OTel instruments for event-delivery telemetry."""
40+
41+
def __init__(self) -> None:
42+
meter = metrics.get_meter("agentex.events")
43+
self.delivery = meter.create_counter(
44+
name="agentex.events.delivery",
45+
unit="1",
46+
description=(
47+
"task/event_send deliveries tagged with outcome (delivered / "
48+
"no_live_workflow / error). 'no_live_workflow' means the signal "
49+
"had no running workflow to receive it (task already completed "
50+
"or idle-timed-out). Use delivered / (delivered + "
51+
"no_live_workflow) as the true event-delivery rate."
52+
),
53+
)
54+
55+
56+
_event_metrics: Optional[EventMetrics] = None
57+
58+
59+
def get_event_metrics() -> EventMetrics:
60+
"""Return the event metrics singleton, creating it on first use."""
61+
global _event_metrics
62+
if _event_metrics is None:
63+
_event_metrics = EventMetrics()
64+
return _event_metrics
65+
66+
67+
_metrics_enabled: Optional[bool] = None
68+
69+
70+
def is_event_metrics_enabled() -> bool:
71+
"""Whether event-delivery metric recording is enabled (AGENTEX_EVENT_METRICS)."""
72+
global _metrics_enabled
73+
if _metrics_enabled is None:
74+
raw = os.environ.get("AGENTEX_EVENT_METRICS", "1").strip().lower()
75+
_metrics_enabled = raw not in ("0", "false", "no", "off")
76+
return _metrics_enabled
77+
78+
79+
def record_event_delivery(outcome: str) -> None:
80+
"""Best-effort bump of the event-delivery counter for the given outcome.
81+
82+
``outcome`` should be one of the ``OUTCOME_*`` constants.
83+
"""
84+
if not is_event_metrics_enabled():
85+
return
86+
try:
87+
get_event_metrics().delivery.add(1, {"outcome": outcome})
88+
except Exception:
89+
pass

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

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from typing import Any
44
from datetime import timedelta
55

6+
from temporalio.service import RPCError, RPCStatusCode
7+
68
from agentex.types.task import Task
79
from agentex.types.agent import Agent
810
from agentex.types.event import Event
@@ -11,6 +13,12 @@
1113
from agentex.lib.core.clients.temporal.types import WorkflowState
1214
from agentex.lib.core.temporal.types.workflow import SignalName
1315
from agentex.lib.core.clients.temporal.temporal_client import TemporalClient
16+
from agentex.lib.core.observability.event_metrics import (
17+
OUTCOME_DELIVERED,
18+
OUTCOME_ERROR,
19+
OUTCOME_NO_LIVE_WORKFLOW,
20+
record_event_delivery,
21+
)
1422

1523

1624
class TemporalTaskService:
@@ -63,16 +71,36 @@ async def get_state(self, task_id: str) -> WorkflowState:
6371
)
6472

6573
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-
)
74+
# event/send is accepted+acked by the ACP server before the signal is
75+
# attempted, so the only place we learn whether the event actually
76+
# reached a *running* workflow is here. Record the true delivery outcome
77+
# (see agentex.events.delivery). Behaviour is unchanged — we still raise.
78+
try:
79+
result = await self._temporal_client.send_signal(
80+
workflow_id=task.id,
81+
signal=SignalName.RECEIVE_EVENT.value,
82+
payload=SendEventParams(
83+
agent=agent,
84+
task=task,
85+
event=event,
86+
request=request,
87+
).model_dump(),
88+
)
89+
except RPCError as e:
90+
# NOT_FOUND == no running workflow to receive the event (task already
91+
# completed or idle-timed-out). Distinct from unexpected errors so we
92+
# can measure the "arrived too late" rate separately.
93+
record_event_delivery(
94+
OUTCOME_NO_LIVE_WORKFLOW
95+
if e.status == RPCStatusCode.NOT_FOUND
96+
else OUTCOME_ERROR
97+
)
98+
raise
99+
except Exception:
100+
record_event_delivery(OUTCOME_ERROR)
101+
raise
102+
record_event_delivery(OUTCOME_DELIVERED)
103+
return result
76104

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

0 commit comments

Comments
 (0)