Coverage for src / taipanstack / utils / metrics.py: 100%
130 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-23 14:54 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-23 14:54 +0000
1"""
2Metrics collection and monitoring utilities.
4Provides lightweight metrics collection for monitoring
5application performance and health. Compatible with any
6Python framework.
7"""
9import functools
10import logging
11import threading
12import time
13from collections import defaultdict
14from collections.abc import Callable
15from dataclasses import dataclass
16from typing import ParamSpec, TypedDict, TypeVar
18P = ParamSpec("P")
19R = TypeVar("R")
21logger = logging.getLogger("taipanstack.utils.metrics")
24@dataclass
25class TimingStats:
26 """Statistics for timing measurements."""
28 count: int = 0
29 total_time: float = 0.0
30 min_time: float = float("inf")
31 max_time: float = 0.0
33 @property
34 def avg_time(self) -> float:
35 """Calculate average time."""
36 return self.total_time / self.count if self.count > 0 else 0.0
38 def record(self, duration: float) -> None:
39 """Record a timing measurement."""
40 self.count += 1
41 self.total_time += duration
42 self.min_time = min(self.min_time, duration)
43 self.max_time = max(self.max_time, duration)
46class TimerStats(TypedDict):
47 """Statistics dictionary for timing measurements."""
49 count: int
50 avg: float
51 min: float
52 max: float
53 total: float
56class MetricsSnapshot(TypedDict):
57 """Snapshot of all collected metrics."""
59 counters: dict[str, int]
60 timers: dict[str, TimerStats]
61 gauges: dict[str, float]
64class Counter:
65 """Simple counter metric."""
67 def __init__(self) -> None:
68 """Initialize the counter."""
69 self.value: int = 0
70 self.lock: threading.Lock = threading.Lock()
72 def increment(self, amount: int = 1) -> int:
73 """Increment counter and return new value."""
74 with self.lock:
75 self.value += amount
76 return self.value
78 def decrement(self, amount: int = 1) -> int:
79 """Decrement counter and return new value."""
80 with self.lock:
81 self.value -= amount
82 return self.value
84 def reset(self) -> None:
85 """Reset counter to zero."""
86 with self.lock:
87 self.value = 0
90class MetricsCollector:
91 """Centralized metrics collection.
93 Thread-safe collector for various metric types including
94 counters, timers, and gauges.
96 Example:
97 >>> metrics = MetricsCollector()
98 >>> metrics.increment("requests_total")
99 >>> with metrics.timer("request_duration"):
100 ... process_request()
102 """
104 _instance: "MetricsCollector | None" = None
105 _lock = threading.Lock()
106 _initialized: bool
108 def __new__(cls) -> "MetricsCollector":
109 """Singleton pattern for global metrics access."""
110 if cls._instance is None:
111 with cls._lock:
112 if cls._instance is None: # pragma: no branch
113 cls._instance = super().__new__(cls)
114 cls._instance._initialized = False
115 return cls._instance
117 def __init__(self) -> None:
118 """Initialize metrics collector."""
119 if self._initialized:
120 return
122 self._counters: dict[str, Counter] = defaultdict(Counter)
123 self._timers: dict[str, TimingStats] = defaultdict(TimingStats)
124 self._gauges: dict[str, float] = {}
125 self._data_lock = threading.Lock()
126 self._initialized = True
128 def increment(self, name: str, amount: int = 1) -> int:
129 """Increment a counter metric."""
130 return self._counters[name].increment(amount)
132 def decrement(self, name: str, amount: int = 1) -> int:
133 """Decrement a counter metric."""
134 return self._counters[name].decrement(amount)
136 def gauge(self, name: str, value: float) -> None:
137 """Set a gauge metric value."""
138 with self._data_lock:
139 self._gauges[name] = value
141 def get_gauge(self, name: str) -> float | None:
142 """Get a gauge metric value."""
143 with self._data_lock:
144 return self._gauges.get(name)
146 def record_time(self, name: str, duration: float) -> None:
147 """Record a timing measurement."""
148 self._timers[name].record(duration)
150 def timer(self, name: str) -> "Timer":
151 """Create a context manager timer."""
152 return Timer(name, self)
154 def get_counter(self, name: str) -> int:
155 """Get current counter value."""
156 return self._counters[name].value
158 def get_timer_stats(self, name: str) -> TimingStats | None:
159 """Get timing statistics for a named timer."""
160 return self._timers.get(name)
162 def get_all_metrics(self) -> MetricsSnapshot:
163 """Get all metrics as a dictionary."""
164 with self._data_lock:
165 return {
166 "counters": {k: v.value for k, v in self._counters.items()},
167 "timers": {
168 k: {
169 "count": v.count,
170 "avg": v.avg_time,
171 "min": v.min_time if v.count > 0 else 0,
172 "max": v.max_time,
173 "total": v.total_time,
174 }
175 for k, v in self._timers.items()
176 },
177 "gauges": dict(self._gauges),
178 }
180 def reset(self) -> None:
181 """Reset all metrics."""
182 with self._data_lock:
183 self._counters.clear()
184 self._timers.clear()
185 self._gauges.clear()
188class Timer:
189 """Context manager for timing code blocks."""
191 def __init__(self, name: str, collector: MetricsCollector) -> None:
192 """Initialize timer.
194 Args:
195 name: Name of the timer metric.
196 collector: MetricsCollector to record to.
198 """
199 self.name = name
200 self.collector = collector
201 self.start_time: float = 0.0
203 def __enter__(self) -> "Timer":
204 """Start the timer."""
205 self.start_time = time.perf_counter()
206 return self
208 def __exit__(self, *args: object) -> None:
209 """Stop timer and record duration."""
210 duration = time.perf_counter() - self.start_time
211 self.collector.record_time(self.name, duration)
214def timed(
215 name: str | None = None,
216 *,
217 collector: MetricsCollector | None = None,
218) -> Callable[[Callable[P, R]], Callable[P, R]]:
219 """Decorator to time function execution.
221 Args:
222 name: Optional metric name (defaults to function name).
223 collector: Optional MetricsCollector instance.
225 Returns:
226 Decorated function that records execution time.
228 Example:
229 >>> @timed("api_call_duration")
230 ... def call_api(endpoint: str) -> dict:
231 ... return requests.get(endpoint, timeout=10).json()
233 """
235 def decorator(func: Callable[P, R]) -> Callable[P, R]:
236 metric_name = name or func.__name__
237 metrics = collector or MetricsCollector()
239 @functools.wraps(func)
240 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
241 start = time.perf_counter()
242 try:
243 return func(*args, **kwargs)
244 finally:
245 duration = time.perf_counter() - start
246 metrics.record_time(metric_name, duration)
248 return wrapper
250 return decorator
253def counted(
254 name: str | None = None,
255 *,
256 collector: MetricsCollector | None = None,
257) -> Callable[[Callable[P, R]], Callable[P, R]]:
258 """Decorator to count function calls.
260 Args:
261 name: Optional metric name (defaults to function name).
262 collector: Optional MetricsCollector instance.
264 Returns:
265 Decorated function that counts calls.
267 Example:
268 >>> @counted("login_attempts")
269 ... def login(username: str, password: str) -> bool:
270 ... return authenticate(username, password)
272 """
274 def decorator(func: Callable[P, R]) -> Callable[P, R]:
275 metric_name = name or f"{func.__name__}_calls"
276 metrics = collector or MetricsCollector()
278 @functools.wraps(func)
279 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
280 metrics.increment(metric_name)
281 return func(*args, **kwargs)
283 return wrapper
285 return decorator
288# Global metrics instance
289metrics = MetricsCollector()