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
1010from __future__ import annotations
1717
1818@pytest .mark .unit
1919def 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
3635def 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
11453def 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+
14292class _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