Skip to content

Commit c95995c

Browse files
authored
Create metrics.py
1 parent 5fd27c0 commit c95995c

1 file changed

Lines changed: 182 additions & 0 deletions

File tree

‎app/infra/metrics.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""In-process metrics registry + tracing hooks.
2+
3+
Dependency-free by design: a small counter/histogram registry that
4+
renders Prometheus text exposition at ``/metrics``, plus a ``span``
5+
tracing hook that is a no-op until a tracer is registered (so wiring
6+
OpenTelemetry later is a one-liner, and nothing is required today).
7+
8+
Not a full metrics client -- it is the minimum that makes runs, tokens,
9+
and HTTP observable by a scraper, without pulling in a backend. Labels
10+
are supported as sorted key/value tuples so a metric can be sliced
11+
(e.g. http_requests_total by method+status).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import threading
17+
import time
18+
from collections.abc import Iterator
19+
from contextlib import contextmanager
20+
from typing import Any, Protocol
21+
22+
_Labels = tuple[tuple[str, str], ...]
23+
24+
25+
def _labelset(labels: dict[str, str] | None) -> _Labels:
26+
if not labels:
27+
return ()
28+
return tuple(sorted(labels.items()))
29+
30+
31+
# Prometheus default histogram buckets (seconds), fine for request/run latency.
32+
_DEFAULT_BUCKETS = (0.005, 0.025, 0.1, 0.5, 1.0, 5.0, 30.0, 120.0)
33+
34+
35+
class MetricsRegistry:
36+
"""Thread-safe counters and histograms with optional labels."""
37+
38+
def __init__(self) -> None:
39+
self._lock = threading.Lock()
40+
self._counters: dict[tuple[str, _Labels], float] = {}
41+
self._hist_sum: dict[tuple[str, _Labels], float] = {}
42+
self._hist_count: dict[tuple[str, _Labels], int] = {}
43+
self._hist_buckets: dict[tuple[str, _Labels], list[int]] = {}
44+
self._help: dict[str, str] = {}
45+
46+
def counter(
47+
self, name: str, value: float = 1.0, labels: dict[str, str] | None = None, help: str = ""
48+
) -> None:
49+
key = (name, _labelset(labels))
50+
with self._lock:
51+
self._counters[key] = self._counters.get(key, 0.0) + value
52+
if help and name not in self._help:
53+
self._help[name] = help
54+
55+
def observe(
56+
self, name: str, value: float, labels: dict[str, str] | None = None, help: str = ""
57+
) -> None:
58+
key = (name, _labelset(labels))
59+
with self._lock:
60+
self._hist_sum[key] = self._hist_sum.get(key, 0.0) + value
61+
self._hist_count[key] = self._hist_count.get(key, 0) + 1
62+
buckets = self._hist_buckets.get(key)
63+
if buckets is None:
64+
buckets = [0] * len(_DEFAULT_BUCKETS)
65+
self._hist_buckets[key] = buckets
66+
for i, edge in enumerate(_DEFAULT_BUCKETS):
67+
if value <= edge:
68+
buckets[i] += 1
69+
if help and name not in self._help:
70+
self._help[name] = help
71+
72+
def reset(self) -> None:
73+
with self._lock:
74+
self._counters.clear()
75+
self._hist_sum.clear()
76+
self._hist_count.clear()
77+
self._hist_buckets.clear()
78+
79+
def render(self) -> str:
80+
"""Prometheus text exposition format."""
81+
lines: list[str] = []
82+
with self._lock:
83+
for name in sorted({n for (n, _) in self._counters}):
84+
if name in self._help:
85+
lines.append(f"# HELP {name} {self._help[name]}")
86+
lines.append(f"# TYPE {name} counter")
87+
for (n, labels), val in self._counters.items():
88+
if n != name:
89+
continue
90+
lines.append(f"{name}{_fmt_labels(labels)} {val}")
91+
for name in sorted({n for (n, _) in self._hist_count}):
92+
if name in self._help:
93+
lines.append(f"# HELP {name} {self._help[name]}")
94+
lines.append(f"# TYPE {name} histogram")
95+
for (n, labels), buckets in self._hist_buckets.items():
96+
if n != name:
97+
continue
98+
cumulative = 0
99+
for i, edge in enumerate(_DEFAULT_BUCKETS):
100+
cumulative += buckets[i]
101+
le = _fmt_labels(labels, extra=("le", _fmt_float(edge)))
102+
lines.append(f"{name}_bucket{le} {cumulative}")
103+
inf = _fmt_labels(labels, extra=("le", "+Inf"))
104+
lines.append(f"{name}_bucket{inf} {self._hist_count[(n, labels)]}")
105+
lines.append(f"{name}_sum{_fmt_labels(labels)} {self._hist_sum[(n, labels)]}")
106+
lines.append(
107+
f"{name}_count{_fmt_labels(labels)} {self._hist_count[(n, labels)]}"
108+
)
109+
return "\n".join(lines) + "\n"
110+
111+
112+
def _fmt_float(v: float) -> str:
113+
return repr(v)
114+
115+
116+
def _fmt_labels(labels: _Labels, extra: tuple[str, str] | None = None) -> str:
117+
items = list(labels)
118+
if extra is not None:
119+
items = items + [extra]
120+
if not items:
121+
return ""
122+
inner = ",".join(f'{k}="{v}"' for k, v in items)
123+
return "{" + inner + "}"
124+
125+
126+
_registry = MetricsRegistry()
127+
128+
129+
def get_registry() -> MetricsRegistry:
130+
return _registry
131+
132+
133+
# Bounded set of HTTP methods for the method label -- an arbitrary or
134+
# garbage verb must not create a new permanent metric series (unbounded
135+
# label cardinality is a memory-growth DoS). Anything else is "other".
136+
_KNOWN_METHODS = frozenset(
137+
{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
138+
)
139+
140+
141+
def normalize_method(method: str) -> str:
142+
"""Map an HTTP method to a bounded label value."""
143+
return method.upper() if method.upper() in _KNOWN_METHODS else "other"
144+
145+
146+
# -- tracing hooks --------------------------------------------------------
147+
148+
149+
class Tracer(Protocol):
150+
@contextmanager
151+
def span(self, name: str, attributes: dict[str, Any] | None = None) -> Iterator[None]: ...
152+
153+
154+
_tracer: Tracer | None = None
155+
156+
157+
def set_tracer(tracer: Tracer | None) -> None:
158+
"""Register a tracer (e.g. an OpenTelemetry adapter). ``None``
159+
restores the no-op behavior."""
160+
global _tracer
161+
_tracer = tracer
162+
163+
164+
@contextmanager
165+
def span(name: str, attributes: dict[str, Any] | None = None) -> Iterator[None]:
166+
"""Trace a block of work.
167+
168+
A no-op (zero overhead beyond the context manager) unless a tracer
169+
is registered via ``set_tracer`` -- so run/request spans are already
170+
marked in the code and become real spans the moment a backend is
171+
wired, with no call-site changes. Also records a duration histogram
172+
so timing is visible even without a tracer.
173+
"""
174+
start = time.perf_counter()
175+
if _tracer is not None:
176+
with _tracer.span(name, attributes):
177+
yield
178+
else:
179+
yield
180+
_registry.observe(
181+
"paw_span_duration_seconds", time.perf_counter() - start, labels={"span": name}
182+
)

0 commit comments

Comments
 (0)