Skip to content

Commit 133f0bb

Browse files
cyntwang99claude
andcommitted
fix(tasks): drop direct StatsD emission from stream metrics, OTel-only
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>
1 parent 017dc06 commit 133f0bb

2 files changed

Lines changed: 47 additions & 111 deletions

File tree

agentex/src/utils/stream_metrics.py

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""
22
Metrics instrumentation for the task-event SSE stream lifecycle.
33
4-
Mirrors the dual-emit pattern in ``src/utils/cache_metrics.py``:
4+
Emission is OpenTelemetry-only:
55
66
- When an OTLP endpoint is configured (``OTEL_EXPORTER_OTLP_ENDPOINT``), the
77
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.
8+
- When it is not configured, every function here is a cheap no-op.
9+
10+
Unlike the older ``cache_metrics.py`` dual-emit path, there is no direct
11+
StatsD/DogStatsD emission here. These are new metrics, and the target state
12+
routes OTel to Datadog through the collector rather than emitting to the Datadog
13+
Agent directly — so a second, DogStatsD-native copy of every point would just be
14+
redundant.
1115
1216
**Why this exists (see AGX1-616/AGX1-618):** HTTP request-level RED for
1317
``GET /tasks/{task_id}/stream`` is already covered by auto-instrumentation — the
@@ -31,11 +35,8 @@
3135

3236
from __future__ import annotations
3337

34-
import os
3538
from typing import TYPE_CHECKING, Literal
3639

37-
from datadog import statsd
38-
3940
from src.utils.logging import make_logger
4041
from src.utils.otel_metrics import get_meter
4142

@@ -44,9 +45,6 @@
4445

4546
logger = make_logger(__name__)
4647

47-
# StatsD is only emitted if the Datadog Agent host is configured.
48-
_STATSD_ENABLED = bool(os.environ.get("DD_AGENT_HOST"))
49-
5048
# How a stream ended. "completed" = the generator returned normally;
5149
# "client_disconnect" = the client went away (asyncio.CancelledError);
5250
# "error" = the stream loop raised a fatal, non-recoverable exception. HTTP
@@ -74,7 +72,7 @@ def _ensure_instruments() -> None:
7472

7573
meter = get_meter("agentex.task_stream")
7674
if meter is None:
77-
# OTel not configured; OTel path stays disabled. StatsD may still emit.
75+
# OTel not configured; every record_* call stays a no-op.
7876
return
7977

8078
_opened_counter = meter.create_counter(
@@ -118,14 +116,6 @@ def record_stream_opened() -> None:
118116
_opened_counter.add(1)
119117
if _active_updown is not None:
120118
_active_updown.add(1)
121-
122-
if _STATSD_ENABLED:
123-
statsd.increment("agentex.task_stream.opened")
124-
# No StatsD "active" gauge: concurrency is OTel-only. DogStatsD
125-
# gauges are last-write-wins per flush, so N workers each emitting a
126-
# process-local count would make the gauge flap between workers
127-
# rather than sum to the true concurrency. The OTel UpDownCounter
128-
# sums correctly across per-instance series at query time.
129119
except Exception:
130120
logger.debug("Failed to emit agentex.task_stream.opened metric", exc_info=True)
131121

@@ -140,8 +130,8 @@ def record_stream_closed(outcome: StreamOutcome, duration_seconds: float) -> Non
140130
outcome: One of "completed", "client_disconnect", "error".
141131
duration_seconds: How long the stream was open.
142132
143-
Never raises: emission failures (e.g. a StatsD UDP socket error or an OTel
144-
SDK fault) are swallowed so instrumentation can never disrupt the SSE path.
133+
Never raises: emission failures (e.g. an OTel SDK fault) are swallowed so
134+
instrumentation can never disrupt the SSE path.
145135
"""
146136
try:
147137
_ensure_instruments()
@@ -152,17 +142,6 @@ def record_stream_closed(outcome: StreamOutcome, duration_seconds: float) -> Non
152142
_duration_histogram.record(duration_seconds, {"outcome": outcome})
153143
if _active_updown is not None:
154144
_active_updown.add(-1)
155-
156-
if _STATSD_ENABLED:
157-
statsd.increment("agentex.task_stream.closed", tags=[f"outcome:{outcome}"])
158-
# Datadog histograms conventionally take milliseconds for durations.
159-
statsd.histogram(
160-
"agentex.task_stream.duration",
161-
duration_seconds * 1000,
162-
tags=[f"outcome:{outcome}"],
163-
)
164-
# No StatsD "active" gauge — concurrency is OTel-only; a per-worker
165-
# DogStatsD gauge would flap rather than sum (see record_stream_opened).
166145
except Exception:
167146
logger.debug("Failed to emit agentex.task_stream.closed metric", exc_info=True)
168147

@@ -180,8 +159,5 @@ def record_stream_stall() -> None:
180159

181160
if _stall_counter is not None:
182161
_stall_counter.add(1)
183-
184-
if _STATSD_ENABLED:
185-
statsd.increment("agentex.task_stream.stall")
186162
except Exception:
187163
logger.debug("Failed to emit agentex.task_stream.stall metric", exc_info=True)
Lines changed: 36 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Tests for the task-stream lifecycle metrics emitter.
22
3-
Covers the two paths that matter operationally: the no-op path (neither OTel nor
4-
StatsD configured, which is the default in tests and local dev) must never
5-
raise, and the StatsD path must emit each metric with the expected name and
6-
tags. The SSE stream path must never be disrupted by an instrumentation fault,
7-
so emission errors must be swallowed.
3+
Emission is OpenTelemetry-only. Two paths matter operationally: the no-op path
4+
(OTel not configured, which is the default in tests and local dev) must never
5+
raise, and the OTel path must record each instrument with the expected value and
6+
bounded attributes. The SSE stream path must never be disrupted by an
7+
instrumentation fault, so emission errors must be swallowed.
88
"""
99

1010
from __future__ import annotations
@@ -17,9 +17,8 @@
1717

1818
@pytest.mark.unit
1919
def test_record_functions_are_noop_when_unconfigured():
20-
# With no OTLP endpoint and no DD_AGENT_HOST, every call must be harmless.
20+
# With no OTLP endpoint the instruments stay None, so every call is harmless.
2121
with (
22-
patch.object(stream_metrics, "_STATSD_ENABLED", False),
2322
patch.object(stream_metrics, "_opened_counter", None),
2423
patch.object(stream_metrics, "_closed_counter", None),
2524
patch.object(stream_metrics, "_duration_histogram", None),
@@ -34,82 +33,22 @@ def test_record_functions_are_noop_when_unconfigured():
3433

3534
@pytest.mark.unit
3635
def test_record_functions_swallow_emission_errors():
37-
# A failing backend must never propagate to the caller (live SSE path).
36+
# A failing instrument must never propagate to the caller (live SSE path).
37+
exploding = _ExplodingInstrument()
3838
with (
39-
patch.object(stream_metrics, "_STATSD_ENABLED", True),
4039
patch.object(stream_metrics, "_instruments_initialized", True),
41-
patch.object(stream_metrics, "_opened_counter", None),
42-
patch.object(stream_metrics, "_closed_counter", None),
43-
patch.object(stream_metrics, "_duration_histogram", None),
44-
patch.object(stream_metrics, "_active_updown", None),
45-
patch.object(stream_metrics, "_stall_counter", None),
46-
patch.object(stream_metrics, "statsd") as mock_statsd,
40+
patch.object(stream_metrics, "_opened_counter", exploding),
41+
patch.object(stream_metrics, "_closed_counter", exploding),
42+
patch.object(stream_metrics, "_duration_histogram", exploding),
43+
patch.object(stream_metrics, "_active_updown", exploding),
44+
patch.object(stream_metrics, "_stall_counter", exploding),
4745
):
48-
mock_statsd.increment.side_effect = OSError("socket in a bad state")
49-
mock_statsd.histogram.side_effect = OSError("socket in a bad state")
50-
51-
# None of these should raise despite the backend blowing up.
46+
# None of these should raise despite the instrument blowing up.
5247
stream_metrics.record_stream_opened()
5348
stream_metrics.record_stream_closed("error", 2.0)
5449
stream_metrics.record_stream_stall()
5550

5651

57-
@pytest.mark.unit
58-
def test_record_stream_opened_emits_statsd_when_enabled():
59-
with (
60-
patch.object(stream_metrics, "_STATSD_ENABLED", True),
61-
patch.object(stream_metrics, "_instruments_initialized", True),
62-
patch.object(stream_metrics, "_opened_counter", None),
63-
patch.object(stream_metrics, "_active_updown", None),
64-
patch.object(stream_metrics, "statsd") as mock_statsd,
65-
):
66-
stream_metrics.record_stream_opened()
67-
68-
# Opening bumps the opened counter. Concurrency ("active") is deliberately
69-
# OTel-only, so the StatsD path emits no gauge (see module rationale).
70-
mock_statsd.increment.assert_called_once_with("agentex.task_stream.opened")
71-
mock_statsd.gauge.assert_not_called()
72-
73-
74-
@pytest.mark.unit
75-
def test_record_stream_closed_emits_statsd_when_enabled():
76-
with (
77-
patch.object(stream_metrics, "_STATSD_ENABLED", True),
78-
patch.object(stream_metrics, "_instruments_initialized", True),
79-
patch.object(stream_metrics, "_closed_counter", None),
80-
patch.object(stream_metrics, "_duration_histogram", None),
81-
patch.object(stream_metrics, "_active_updown", None),
82-
patch.object(stream_metrics, "statsd") as mock_statsd,
83-
):
84-
stream_metrics.record_stream_closed("client_disconnect", 3.25)
85-
86-
mock_statsd.increment.assert_called_once_with(
87-
"agentex.task_stream.closed",
88-
tags=["outcome:client_disconnect"],
89-
)
90-
# Duration is reported to StatsD in milliseconds, tagged by outcome.
91-
mock_statsd.histogram.assert_called_once_with(
92-
"agentex.task_stream.duration",
93-
3250.0,
94-
tags=["outcome:client_disconnect"],
95-
)
96-
# Concurrency ("active") is OTel-only, so closing emits no StatsD gauge.
97-
mock_statsd.gauge.assert_not_called()
98-
99-
100-
@pytest.mark.unit
101-
def test_record_stream_stall_emits_statsd_when_enabled():
102-
with (
103-
patch.object(stream_metrics, "_STATSD_ENABLED", True),
104-
patch.object(stream_metrics, "_instruments_initialized", True),
105-
patch.object(stream_metrics, "_stall_counter", None),
106-
patch.object(stream_metrics, "statsd") as mock_statsd,
107-
):
108-
stream_metrics.record_stream_stall()
109-
110-
mock_statsd.increment.assert_called_once_with("agentex.task_stream.stall")
111-
112-
11352
@pytest.mark.unit
11453
def test_closed_records_otel_instruments_with_outcome():
11554
# The OTel path records the outcome as a bounded attribute (not an id).
@@ -121,7 +60,6 @@ def test_closed_records_otel_instruments_with_outcome():
12160
_FakeInstrument(),
12261
)
12362
with (
124-
patch.object(stream_metrics, "_STATSD_ENABLED", False),
12563
patch.object(stream_metrics, "_instruments_initialized", True),
12664
patch.object(stream_metrics, "_opened_counter", opened),
12765
patch.object(stream_metrics, "_closed_counter", closed),
@@ -139,6 +77,18 @@ def test_closed_records_otel_instruments_with_outcome():
13977
assert active.calls == [(1, None), (-1, None)]
14078

14179

80+
@pytest.mark.unit
81+
def test_stall_records_otel_counter():
82+
stall = _FakeInstrument()
83+
with (
84+
patch.object(stream_metrics, "_instruments_initialized", True),
85+
patch.object(stream_metrics, "_stall_counter", stall),
86+
):
87+
stream_metrics.record_stream_stall()
88+
89+
assert stall.calls == [(1, None)]
90+
91+
14292
class _FakeInstrument:
14393
"""Records (value, attributes) for add()/record() so tests can assert on them."""
14494

@@ -150,3 +100,13 @@ def add(self, value, attributes=None):
150100

151101
def record(self, value, attributes=None):
152102
self.calls.append((value, attributes))
103+
104+
105+
class _ExplodingInstrument:
106+
"""Raises on every emission to prove the record_* functions swallow faults."""
107+
108+
def add(self, value, attributes=None):
109+
raise OSError("instrument in a bad state")
110+
111+
def record(self, value, attributes=None):
112+
raise OSError("instrument in a bad state")

0 commit comments

Comments
 (0)