@@ -75,3 +75,100 @@ def test_json_formatter_emits_one_object(self) -> None:
7575 assert out ["level" ] == "INFO"
7676 assert out ["request_id" ] == "r1"
7777 assert out ["message" ] == "hello"
78+
79+
80+ class TestMetrics :
81+ def test_metrics_endpoint_exposes_prometheus_text (self , client : TestClient ) -> None :
82+ # generate some traffic first
83+ client .get ("/healthz" )
84+ res = client .get ("/metrics" )
85+ assert res .status_code == 200
86+ assert "text/plain" in res .headers ["content-type" ]
87+ body = res .text
88+ assert "paw_http_requests_total" in body
89+ assert "# TYPE paw_http_requests_total counter" in body
90+
91+ def test_http_requests_counted_by_status (self , client : TestClient ) -> None :
92+ from app .infra .metrics import get_registry
93+
94+ get_registry ().reset ()
95+ client .get ("/healthz" ) # 200
96+ client .get ("/auth/me" ) # 401
97+ body = client .get ("/metrics" ).text
98+ assert 'paw_http_requests_total{method="GET",status="200"}' in body
99+ # the 401 from /auth/me is counted too
100+ assert 'status="401"' in body
101+
102+ def test_counter_and_histogram_render (self ) -> None :
103+ from app .infra .metrics import MetricsRegistry
104+
105+ r = MetricsRegistry ()
106+ r .counter ("things_total" , 3 , labels = {"kind" : "a" })
107+ r .observe ("lat_seconds" , 0.2 )
108+ out = r .render ()
109+ assert 'things_total{kind="a"} 3.0' in out
110+ assert "lat_seconds_bucket" in out
111+ assert "lat_seconds_count" in out
112+ assert "lat_seconds_sum" in out
113+
114+ def test_method_label_is_bounded (self ) -> None :
115+ from app .infra .metrics import normalize_method
116+
117+ assert normalize_method ("get" ) == "GET"
118+ assert normalize_method ("POST" ) == "POST"
119+ # an arbitrary/garbage verb collapses to a single bucket so it
120+ # cannot create unbounded metric series
121+ assert normalize_method ("BREW" ) == "other"
122+ assert normalize_method ("../../etc" ) == "other"
123+
124+ def test_garbage_method_does_not_grow_series (self , client : TestClient ) -> None :
125+ from app .infra .metrics import get_registry
126+
127+ get_registry ().reset ()
128+ # a request with an unusual method must not mint a new label value
129+ client .request ("BREW" , "/healthz" )
130+ body = client .get ("/metrics" ).text
131+ assert 'method="other"' in body
132+ assert 'method="BREW"' not in body
133+
134+
135+ class TestTracing :
136+ def test_span_is_noop_without_tracer (self ) -> None :
137+ from app .infra .metrics import span
138+
139+ # no tracer registered: must not raise, just runs the block
140+ ran = []
141+ with span ("work" , {"k" : "v" }):
142+ ran .append (1 )
143+ assert ran == [1 ]
144+
145+ def test_span_delegates_to_registered_tracer (self ) -> None :
146+ from contextlib import contextmanager
147+
148+ from app .infra .metrics import set_tracer , span
149+
150+ calls = []
151+
152+ class FakeTracer :
153+ @contextmanager
154+ def span (self , name , attributes = None ):
155+ calls .append ((name , attributes ))
156+ yield
157+
158+ set_tracer (FakeTracer ())
159+ try :
160+ with span ("run.exec" , {"run_id" : "r1" }):
161+ pass
162+ assert calls == [("run.exec" , {"run_id" : "r1" })]
163+ finally :
164+ set_tracer (None ) # restore no-op for other tests
165+
166+ def test_span_records_duration_metric (self ) -> None :
167+ from app .infra .metrics import get_registry , span
168+
169+ get_registry ().reset ()
170+ with span ("timed.block" ):
171+ pass
172+ out = get_registry ().render ()
173+ assert 'paw_span_duration_seconds' in out
174+ assert 'span="timed.block"' in out
0 commit comments