Skip to content

Commit 6fe03ed

Browse files
cyntwang99claude
andcommitted
feat(tasks): add task-stream lifecycle metrics
Instrument the task-event SSE stream lifecycle with opened/closed/active/ duration/stall metrics, dual-emitted via OpenTelemetry and StatsD. Fills the gap that request-level RED cannot express: concurrency, close outcome (completed vs client disconnect vs error), and quiet-stream stalls. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 32a9acc commit 6fe03ed

4 files changed

Lines changed: 402 additions & 20 deletions

File tree

agentex/src/config/environment_variables.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class EnvVarKeys(str, Enum):
5555
HTTPX_POOL_TIMEOUT = "HTTPX_POOL_TIMEOUT"
5656
HTTPX_STREAMING_READ_TIMEOUT = "HTTPX_STREAMING_READ_TIMEOUT"
5757
SSE_KEEPALIVE_PING_INTERVAL = "SSE_KEEPALIVE_PING_INTERVAL"
58+
SSE_STREAM_STALL_THRESHOLD_SECONDS = "SSE_STREAM_STALL_THRESHOLD_SECONDS"
5859
AGENTEX_SERVER_TASK_QUEUE = "AGENTEX_SERVER_TASK_QUEUE"
5960
ENABLE_HEALTH_CHECK_WORKFLOW = "ENABLE_HEALTH_CHECK_WORKFLOW"
6061
ENABLE_AGENT_RUN_SCHEDULES = "ENABLE_AGENT_RUN_SCHEDULES"
@@ -150,6 +151,10 @@ class EnvironmentVariables(BaseModel):
150151
300.0 # HTTPX streaming read timeout in seconds (5 minutes)
151152
)
152153
SSE_KEEPALIVE_PING_INTERVAL: int = 15 # SSE keepalive ping interval in seconds
154+
# An open stream is counted as stalled once this many seconds pass with no
155+
# data event pushed to the client. Kept above the keepalive interval so that
156+
# keepalive pings (which are not data events) don't mask a real stall.
157+
SSE_STREAM_STALL_THRESHOLD_SECONDS: int = 30
153158
AGENTEX_SERVER_TASK_QUEUE: str | None = None
154159
ENABLE_HEALTH_CHECK_WORKFLOW: bool = False
155160
# Gates the agent run schedules API. Off by default; enabled in development.
@@ -245,6 +250,9 @@ def refresh(cls, force_refresh: bool = False) -> EnvironmentVariables | None:
245250
SSE_KEEPALIVE_PING_INTERVAL=int(
246251
os.environ.get(EnvVarKeys.SSE_KEEPALIVE_PING_INTERVAL, "15")
247252
),
253+
SSE_STREAM_STALL_THRESHOLD_SECONDS=int(
254+
os.environ.get(EnvVarKeys.SSE_STREAM_STALL_THRESHOLD_SECONDS, "30")
255+
),
248256
AGENTEX_SERVER_TASK_QUEUE=os.environ.get(
249257
EnvVarKeys.AGENTEX_SERVER_TASK_QUEUE
250258
),

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
)
1717
from src.domain.services.task_service import DAgentTaskService
1818
from src.utils.logging import make_logger
19+
from src.utils.stream_metrics import (
20+
StreamOutcome,
21+
record_stream_closed,
22+
record_stream_opened,
23+
record_stream_stall,
24+
)
1925
from src.utils.stream_topics import get_task_event_stream_topic
2026

2127
logger = make_logger(__name__)
@@ -101,25 +107,48 @@ async def stream_task_events(
101107
task_id = task.id
102108

103109
stream_topic = get_task_event_stream_topic(task_id=task_id)
104-
# Snapshot the read cursor BEFORE yielding "connected". "connected" is
105-
# the client's cue to send its message, which makes the agent start
106-
# XADD-ing deltas. Snapshotting after the yield lets a congested relay
107-
# fall behind far enough that those deltas land before the snapshot and
108-
# are never read. Snapshotting first resolves to "0-0" (stream is empty
109-
# until the client sends), so we read from the beginning.
110-
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
111-
# Send initial connection data
112-
yield f"data: {TaskStreamConnectedEventEntity(type='connected', taskId=task_id).model_dump_json()}\n\n"
113-
last_message_time = asyncio.get_running_loop().time()
114-
ping_interval = float(
115-
self.environment_variables.SSE_KEEPALIVE_PING_INTERVAL
116-
) # Configurable keepalive ping interval
117-
# Track consecutive read failures so we can back off and avoid a
118-
# tight error loop. When the Redis pool is exhausted, every connected
119-
# client's read fails on each cycle; without backoff this turns into a
120-
# log-ingestion firehose (one failure per client per cycle, ~once/sec).
121-
consecutive_errors = 0
110+
111+
# A stream is "open" from here on. Pair this with exactly one
112+
# record_stream_closed (in the finally below) so the active-stream gauge
113+
# stays balanced even if setup — the tail snapshot or the first yield —
114+
# raises. Placed after task resolution so a bad task_name never counts
115+
# as an opened stream.
116+
record_stream_opened()
117+
stream_start_time = asyncio.get_running_loop().time()
118+
outcome: StreamOutcome = "completed"
122119
try:
120+
# Snapshot the read cursor BEFORE yielding "connected". "connected"
121+
# is the client's cue to send its message, which makes the agent
122+
# start XADD-ing deltas. Snapshotting after the yield lets a
123+
# congested relay fall behind far enough that those deltas land
124+
# before the snapshot and are never read. Snapshotting first resolves
125+
# to "0-0" (stream is empty until the client sends), so we read from
126+
# the beginning.
127+
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
128+
# Send initial connection data
129+
yield f"data: {TaskStreamConnectedEventEntity(type='connected', taskId=task_id).model_dump_json()}\n\n"
130+
now = asyncio.get_running_loop().time()
131+
# last_message_time drives the keepalive ping and is reset by pings;
132+
# last_event_time tracks only real data pushes (a ping is not a data
133+
# event) and drives stall detection, so a persistently quiet stream
134+
# still trips the stall signal even while keepalive pings flow.
135+
last_message_time = now
136+
last_event_time = now
137+
ping_interval = float(
138+
self.environment_variables.SSE_KEEPALIVE_PING_INTERVAL
139+
) # Configurable keepalive ping interval
140+
stall_threshold = float(
141+
self.environment_variables.SSE_STREAM_STALL_THRESHOLD_SECONDS
142+
)
143+
# Whether the current quiet spell has already been counted, so one
144+
# stall episode increments the counter once (measuring stall onsets)
145+
# rather than once per idle cycle. Reset when a data event flows.
146+
stalled = False
147+
# Track consecutive read failures so we can back off and avoid a
148+
# tight error loop. When the Redis pool is exhausted, every connected
149+
# client's read fails on each cycle; without backoff this turns into a
150+
# log-ingestion firehose (one failure per client per cycle, ~once/sec).
151+
consecutive_errors = 0
123152
# Application-level control loop
124153
while True:
125154
try:
@@ -135,7 +164,10 @@ async def stream_task_events(
135164
# Send the data to the client
136165
data_str = f"data: {data.model_dump_json()}\n\n"
137166
yield data_str
138-
last_message_time = asyncio.get_running_loop().time()
167+
now = asyncio.get_running_loop().time()
168+
last_message_time = now
169+
last_event_time = now
170+
stalled = False
139171
await asyncio.sleep(0.02)
140172

141173
# A read cycle completed without raising — the stream is
@@ -146,6 +178,16 @@ async def stream_task_events(
146178
# to prevent tight loops and send keepalive ping if needed
147179
if message_count == 0:
148180
current_time = asyncio.get_running_loop().time()
181+
# No data event pushed for the stall window: count the
182+
# onset once. Keepalive pings deliberately do not reset
183+
# last_event_time, so a persistently quiet stream is
184+
# visible even though the connection stays alive.
185+
if (
186+
current_time - last_event_time >= stall_threshold
187+
and not stalled
188+
):
189+
stalled = True
190+
record_stream_stall()
149191
if current_time - last_message_time >= ping_interval:
150192
yield ":ping\n\n"
151193
last_message_time = current_time
@@ -182,15 +224,20 @@ async def stream_task_events(
182224

183225
except asyncio.CancelledError:
184226
# Just exit the generator on cancellation
227+
outcome = "client_disconnect"
185228
logger.info(f"Client disconnected from SSE stream for task {task_id}")
186-
pass
187229
except Exception as e:
230+
outcome = "error"
188231
logger.error(
189232
f"Fatal error in SSE stream for task {task_id}: {e}", exc_info=True
190233
)
191234
yield f"data: {TaskStreamErrorEventEntity(type='error', message=str(e)).model_dump_json()}\n\n"
192235
finally:
193236
logger.info(f"SSE stream for task {task_id} has ended")
237+
record_stream_closed(
238+
outcome,
239+
asyncio.get_running_loop().time() - stream_start_time,
240+
)
194241
await self.cleanup_stream(stream_topic)
195242

196243

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
Metrics instrumentation for the task-event SSE stream lifecycle.
3+
4+
Mirrors the dual-emit pattern in ``src/utils/cache_metrics.py``:
5+
6+
- When an OTLP endpoint is configured (``OTEL_EXPORTER_OTLP_ENDPOINT``), the
7+
instruments are recorded through the OpenTelemetry SDK.
8+
- When the Datadog Agent is reachable (``DD_AGENT_HOST``), the same events are
9+
emitted as StatsD metrics.
10+
- When neither is configured, every function here is a cheap no-op.
11+
12+
**Why this exists (see AGX1-616/AGX1-618):** HTTP request-level RED for
13+
``GET /tasks/{task_id}/stream`` is already covered by auto-instrumentation — the
14+
route records into ``http_server_request_duration_seconds`` at connection close.
15+
But a single SSE request produces exactly one duration sample, which says nothing
16+
about what happened *during* the stream's life: how many were concurrently open,
17+
whether a stream ended normally vs. by client disconnect vs. by error, or whether
18+
a stream went quiet (no events pushed) for a stretch. Those are the stream-
19+
lifecycle signals request-level instrumentation cannot express, and they are what
20+
this module adds.
21+
22+
Instrument names are deliberately dotted (``agentex.task_stream.*``); the OTLP →
23+
Prometheus translation flattens dots to underscores, appends ``_total`` to
24+
monotonic counters, and appends the unit (``s`` → ``_seconds``) to the histogram,
25+
so the series land in Mimir as ``agentex_task_stream_opened_total``,
26+
``agentex_task_stream_closed_total``, ``agentex_task_stream_duration_seconds``,
27+
``agentex_task_stream_active`` and ``agentex_task_stream_stall_total``. These
28+
hand-instrumented series carry only bounded attributes (``outcome``) and no
29+
``http_route`` label — group by ``outcome``, not ``http_route``.
30+
"""
31+
32+
from __future__ import annotations
33+
34+
import os
35+
from typing import TYPE_CHECKING, Literal
36+
37+
from datadog import statsd
38+
39+
from src.utils.logging import make_logger
40+
from src.utils.otel_metrics import get_meter
41+
42+
if TYPE_CHECKING:
43+
from opentelemetry.metrics import Counter, Histogram, UpDownCounter
44+
45+
logger = make_logger(__name__)
46+
47+
# StatsD is only emitted if the Datadog Agent host is configured.
48+
_STATSD_ENABLED = bool(os.environ.get("DD_AGENT_HOST"))
49+
50+
# How a stream ended. "completed" = the generator returned normally;
51+
# "client_disconnect" = the client went away (asyncio.CancelledError);
52+
# "error" = the stream loop raised a fatal, non-recoverable exception. HTTP
53+
# status 200 at connection close cannot tell these three apart, which is why the
54+
# distinction lives here as a bounded label rather than on the request metric.
55+
StreamOutcome = Literal["completed", "client_disconnect", "error"]
56+
57+
# Lazily-created OTel instruments (created once, on first use).
58+
_opened_counter: Counter | None = None
59+
_closed_counter: Counter | None = None
60+
_duration_histogram: Histogram | None = None
61+
_active_updown: UpDownCounter | None = None
62+
_stall_counter: Counter | None = None
63+
_instruments_initialized = False
64+
65+
66+
def _ensure_instruments() -> None:
67+
"""Create the OTel instruments on first use. No-op if OTel is not configured."""
68+
global _opened_counter, _closed_counter, _duration_histogram
69+
global _active_updown, _stall_counter, _instruments_initialized
70+
71+
if _instruments_initialized:
72+
return
73+
_instruments_initialized = True
74+
75+
meter = get_meter("agentex.task_stream")
76+
if meter is None:
77+
# OTel not configured; OTel path stays disabled. StatsD may still emit.
78+
return
79+
80+
_opened_counter = meter.create_counter(
81+
name="agentex.task_stream.opened",
82+
description="Task-event SSE streams opened",
83+
unit="{stream}",
84+
)
85+
_closed_counter = meter.create_counter(
86+
name="agentex.task_stream.closed",
87+
description="Task-event SSE streams closed, tagged by outcome",
88+
unit="{stream}",
89+
)
90+
_duration_histogram = meter.create_histogram(
91+
name="agentex.task_stream.duration",
92+
description="Task-event SSE stream lifetime",
93+
unit="s",
94+
)
95+
_active_updown = meter.create_up_down_counter(
96+
name="agentex.task_stream.active",
97+
description="Currently open task-event SSE streams",
98+
unit="{stream}",
99+
)
100+
_stall_counter = meter.create_counter(
101+
name="agentex.task_stream.stall",
102+
description="Task-event SSE streams that went idle (no event pushed) past the stall threshold",
103+
unit="{stream}",
104+
)
105+
106+
107+
def record_stream_opened() -> None:
108+
"""
109+
Record a task-event stream opening: bumps the opened counter and the active gauge.
110+
111+
Pair every call with exactly one ``record_stream_closed`` so the active gauge
112+
stays balanced. Never raises: see ``record_stream_closed``.
113+
"""
114+
try:
115+
_ensure_instruments()
116+
117+
if _opened_counter is not None:
118+
_opened_counter.add(1)
119+
if _active_updown is not None:
120+
_active_updown.add(1)
121+
122+
if _STATSD_ENABLED:
123+
statsd.increment("agentex.task_stream.opened")
124+
statsd.increment("agentex.task_stream.active")
125+
except Exception:
126+
logger.debug("Failed to emit agentex.task_stream.opened metric", exc_info=True)
127+
128+
129+
def record_stream_closed(outcome: StreamOutcome, duration_seconds: float) -> None:
130+
"""
131+
Record a task-event stream closing: bumps the closed counter (tagged by
132+
outcome), records the stream lifetime, and decrements the active gauge.
133+
134+
Args:
135+
outcome: One of "completed", "client_disconnect", "error".
136+
duration_seconds: How long the stream was open.
137+
138+
Never raises: emission failures (e.g. a StatsD UDP socket error or an OTel
139+
SDK fault) are swallowed so instrumentation can never disrupt the SSE path.
140+
"""
141+
try:
142+
_ensure_instruments()
143+
144+
if _closed_counter is not None:
145+
_closed_counter.add(1, {"outcome": outcome})
146+
if _duration_histogram is not None:
147+
_duration_histogram.record(duration_seconds)
148+
if _active_updown is not None:
149+
_active_updown.add(-1)
150+
151+
if _STATSD_ENABLED:
152+
statsd.increment("agentex.task_stream.closed", tags=[f"outcome:{outcome}"])
153+
# Datadog histograms conventionally take milliseconds for durations.
154+
statsd.histogram("agentex.task_stream.duration", duration_seconds * 1000)
155+
statsd.decrement("agentex.task_stream.active")
156+
except Exception:
157+
logger.debug("Failed to emit agentex.task_stream.closed metric", exc_info=True)
158+
159+
160+
def record_stream_stall() -> None:
161+
"""
162+
Record that an open stream went idle — no event pushed to the client for
163+
longer than the configured stall threshold. Emit once per stall episode
164+
(the caller de-dupes) so the counter measures stall onsets, not idle cycles.
165+
166+
Never raises: see ``record_stream_closed``.
167+
"""
168+
try:
169+
_ensure_instruments()
170+
171+
if _stall_counter is not None:
172+
_stall_counter.add(1)
173+
174+
if _STATSD_ENABLED:
175+
statsd.increment("agentex.task_stream.stall")
176+
except Exception:
177+
logger.debug("Failed to emit agentex.task_stream.stall metric", exc_info=True)

0 commit comments

Comments
 (0)