feat(tasks): add task-stream lifecycle metrics - #387
Open
cyntwang99 wants to merge 7 commits into
Open
Conversation
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>
statsd increment/decrement submit a COUNT, so the active metric rendered as a rate of change rather than the live concurrency level. DogStatsD does not honor gauge deltas, so track a process-local running total and report it via statsd.gauge as an absolute value. The OTel UpDownCounter path already handled this natively and is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A process-local gauge is wrong under multiple workers: DogStatsD gauges are last-write-wins per flush, so N workers emitting their own counts make the metric flap between workers instead of summing to true concurrency. Remove the StatsD active gauge and its bookkeeping; the OTel UpDownCounter already sums correctly across per-instance series and remains the sole concurrency source. The additive StatsD emits (opened/closed counters, duration, stall) aggregate correctly across workers and are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move record_stream_opened() inside the try/finally and gate record_stream_closed on an `opened` flag, so a failure between marking the stream open and entering the try can no longer leave the active gauge permanently over-counted. Also tag the duration histogram with `outcome` (matching the closed counter) so stream lifetime can be sliced by completed / client_disconnect / error on both the OTel and StatsD paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines
+156
to
+165
| if _STATSD_ENABLED: | ||
| statsd.increment("agentex.task_stream.closed", tags=[f"outcome:{outcome}"]) | ||
| # Datadog histograms conventionally take milliseconds for durations. | ||
| statsd.histogram( | ||
| "agentex.task_stream.duration", | ||
| duration_seconds * 1000, | ||
| tags=[f"outcome:{outcome}"], | ||
| ) | ||
| # No StatsD "active" gauge — concurrency is OTel-only; a per-worker | ||
| # DogStatsD gauge would flap rather than sum (see record_stream_opened). |
Contributor
There was a problem hiding this comment.
Given that these are new metrics, and in the final state we want otel to emit to datadog instead of emitting to dd directly, i think we can remove the double emission here.
…2a-task-stream-lifecycle-metrics # Conflicts: # agentex/src/domain/use_cases/streams_use_case.py
These are new metrics and the target state routes OTel to Datadog through the collector, so the parallel DogStatsD emission was a redundant second copy of every point. Remove the StatsD path (and its os/datadog imports) and update the tests to cover the OTel-only and no-op paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stephen-wang24
approved these changes
Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds stream-lifecycle instrumentation for the task-event SSE stream (
GET /tasks/{task_id}/stream). HTTP request-level RED is already covered by auto-instrumentation — but a single long-lived SSE request emits exactly one duration sample at connection close, which says nothing about what happened during the stream's life. This fills that gap with five hand-instrumented series:agentex_task_stream_opened_total— streams openedagentex_task_stream_closed_total{outcome}— streams closed, taggedcompleted/client_disconnect/erroragentex_task_stream_duration_seconds— stream lifetime histogramagentex_task_stream_active— currently-open streams (gauge)agentex_task_stream_stall_total— streams that went idle (no event pushed) past a configurable thresholdLocal Testing:
Details
src/utils/stream_metrics.pymirrors the dual-emit pattern incache_metrics.py: records through the OpenTelemetry SDK when an OTLP endpoint is configured, emits StatsD when the Datadog Agent host is set, and is a cheap no-op otherwise. Every emit path is wrapped so an instrumentation fault can never disrupt the live SSE path.outcomelabel (no ids, nohttp_route), so cardinality stays flat.try/finallyinstream_task_eventsso the active gauge stays balanced even if setup raises. Stall detection tracks last data event separately from keepalive pings, so a persistently quiet stream still trips the stall signal while the connection stays alive. One stall episode increments the counter once (onset), not once per idle cycle.SSE_STREAM_STALL_THRESHOLD_SECONDS(default 30) controls the stall window.Test plan
make test FILE=tests/unit/utils/test_stream_metrics.py— no-op-when-unconfigured, error-swallowing, and StatsD/OTel emission assertionsoutcomedistribution (completed/client_disconnect/error) on a real stream close🤖 Generated with Claude Code
Greptile Summary
This PR adds five hand-instrumented OTel metrics for the task-event SSE stream lifecycle (
GET /tasks/{task_id}/stream), filling the gap left by HTTP-level auto-instrumentation which only emits a single duration sample at connection close. All emission paths are wrapped intry/exceptso instrumentation faults can never disrupt the live SSE path.stream_metrics.py: New OTel-only module exposingrecord_stream_opened,record_stream_closed, andrecord_stream_stall; instruments are lazily initialized and guarded behind null checks; only the boundedoutcomelabel is used (no entity IDs).streams_use_case.py: Integrates the metrics intostream_task_eventswithrecord_stream_openedas the first statement inside thetryblock andrecord_stream_closedin thefinally, keeping the active gauge balanced across all exit paths; stall detection trackslast_event_timeseparately fromlast_message_timeso keepalive pings don't mask quiet streams.environment_variables.py: AddsSSE_STREAM_STALL_THRESHOLD_SECONDS(default 30 s) above the 15 s ping interval.Confidence Score: 5/5
Safe to merge; instrumentation is fully isolated from the SSE path and all emit calls are wrapped in error-swallowing guards.
The change adds observability-only code that cannot affect task streaming correctness: every record_* function swallows its own exceptions, the open/close gauge is balanced by a try/finally, and the only label used (outcome) is bounded to three values. No data path logic was altered.
Files Needing Attention: No files require special attention.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant StreamsUseCase participant stream_metrics participant OTel Client->>StreamsUseCase: "GET /tasks/{id}/stream" StreamsUseCase->>stream_metrics: record_stream_opened() stream_metrics->>OTel: opened_counter.add(1), active_updown.add(+1) StreamsUseCase->>Client: "data: {type: connected}" loop While task is active StreamsUseCase->>StreamsUseCase: read_messages (Redis XREAD) alt Data event received StreamsUseCase->>Client: "data: {event}" Note over StreamsUseCase: last_event_time = now, stalled = False else Empty read cycle Note over StreamsUseCase: now - last_event_time >= stall_threshold? opt Stall onset StreamsUseCase->>stream_metrics: record_stream_stall() stream_metrics->>OTel: stall_counter.add(1) end opt Ping due StreamsUseCase->>Client: :ping end end end alt Normal completion Note over StreamsUseCase: outcome=completed else Client disconnect Note over StreamsUseCase: outcome=client_disconnect else Fatal error Note over StreamsUseCase: outcome=error end StreamsUseCase->>stream_metrics: record_stream_closed(outcome, duration_s) stream_metrics->>OTel: closed_counter, duration_histogram, active_updown.add(-1)Reviews (6): Last reviewed commit: "fix(tasks): drop direct StatsD emission ..." | Re-trigger Greptile