prismiq 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
prismiq/metrics.py ADDED
@@ -0,0 +1,536 @@
1
+ """Prometheus-compatible metrics for Prismiq.
2
+
3
+ This module provides a metrics collection system compatible with
4
+ Prometheus exposition format for monitoring and observability.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import time
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from fastapi import APIRouter
16
+ from fastapi.responses import PlainTextResponse
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+
22
+ # ============================================================================
23
+ # Histogram Buckets
24
+ # ============================================================================
25
+
26
+ # Default histogram buckets for response times (in milliseconds)
27
+ DEFAULT_BUCKETS = (5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, float("inf"))
28
+
29
+
30
+ # ============================================================================
31
+ # Metric Types
32
+ # ============================================================================
33
+
34
+
35
+ @dataclass
36
+ class MetricValue:
37
+ """A single metric value with labels."""
38
+
39
+ name: str
40
+ """Metric name."""
41
+
42
+ value: float
43
+ """Metric value."""
44
+
45
+ labels: dict[str, str] = field(default_factory=dict)
46
+ """Labels for this metric."""
47
+
48
+ metric_type: str = "gauge"
49
+ """Type: 'counter', 'gauge', or 'histogram'."""
50
+
51
+
52
+ @dataclass
53
+ class HistogramValue:
54
+ """Histogram metric with bucket counts."""
55
+
56
+ sum: float = 0.0
57
+ """Sum of all observed values."""
58
+
59
+ count: int = 0
60
+ """Number of observations."""
61
+
62
+ buckets: dict[float, int] = field(default_factory=dict)
63
+ """Bucket counts (upper bound -> count)."""
64
+
65
+
66
+ # ============================================================================
67
+ # Metrics Collector
68
+ # ============================================================================
69
+
70
+
71
+ class Metrics:
72
+ """Prometheus-compatible metrics collector.
73
+
74
+ Supports counters, gauges, and histograms with labels.
75
+ Thread-safe through atomic operations on primitive types.
76
+
77
+ Example:
78
+ >>> metrics = Metrics()
79
+ >>> metrics.inc_counter("http_requests_total", method="GET", status="200")
80
+ >>> metrics.set_gauge("active_connections", 42)
81
+ >>> metrics.observe_histogram("request_duration_ms", 125.5, endpoint="/api")
82
+ >>> print(metrics.format_prometheus())
83
+ """
84
+
85
+ def __init__(self, prefix: str = "prismiq") -> None:
86
+ """Initialize metrics collector.
87
+
88
+ Args:
89
+ prefix: Prefix for all metric names.
90
+ """
91
+ self._prefix = prefix
92
+ self._counters: dict[str, float] = defaultdict(float)
93
+ self._gauges: dict[str, float] = {}
94
+ self._histograms: dict[str, HistogramValue] = {}
95
+ self._histogram_buckets: dict[str, tuple[float, ...]] = {}
96
+
97
+ # Track metric metadata for exposition
98
+ self._counter_help: dict[str, str] = {}
99
+ self._gauge_help: dict[str, str] = {}
100
+ self._histogram_help: dict[str, str] = {}
101
+
102
+ def _make_key(self, name: str, labels: dict[str, str]) -> str:
103
+ """Create a unique key from name and labels."""
104
+ if not labels:
105
+ return name
106
+
107
+ sorted_labels = sorted(labels.items())
108
+ label_str = ",".join(f'{k}="{v}"' for k, v in sorted_labels)
109
+ return f"{name}{{{label_str}}}"
110
+
111
+ def _parse_key(self, key: str) -> tuple[str, str]:
112
+ """Parse a key back into name and label string."""
113
+ if "{" not in key:
114
+ return key, ""
115
+
116
+ name = key.split("{")[0]
117
+ labels_part = key[key.index("{") + 1 : key.rindex("}")]
118
+ return name, labels_part
119
+
120
+ # ========================================================================
121
+ # Counter Operations
122
+ # ========================================================================
123
+
124
+ def register_counter(self, name: str, help_text: str = "") -> None:
125
+ """Register a counter metric.
126
+
127
+ Args:
128
+ name: Metric name (without prefix).
129
+ help_text: Description for the HELP line.
130
+ """
131
+ full_name = f"{self._prefix}_{name}"
132
+ self._counter_help[full_name] = help_text
133
+
134
+ def inc_counter(self, name: str, value: float = 1.0, **labels: str) -> None:
135
+ """Increment a counter.
136
+
137
+ Args:
138
+ name: Counter name (without prefix).
139
+ value: Amount to increment (must be positive).
140
+ **labels: Label key-value pairs.
141
+ """
142
+ if value < 0:
143
+ raise ValueError("Counter increment must be non-negative")
144
+
145
+ full_name = f"{self._prefix}_{name}"
146
+ key = self._make_key(full_name, labels)
147
+ self._counters[key] += value
148
+
149
+ def get_counter(self, name: str, **labels: str) -> float:
150
+ """Get current counter value.
151
+
152
+ Args:
153
+ name: Counter name (without prefix).
154
+ **labels: Label key-value pairs.
155
+
156
+ Returns:
157
+ Current counter value (0 if not set).
158
+ """
159
+ full_name = f"{self._prefix}_{name}"
160
+ key = self._make_key(full_name, labels)
161
+ return self._counters.get(key, 0.0)
162
+
163
+ # ========================================================================
164
+ # Gauge Operations
165
+ # ========================================================================
166
+
167
+ def register_gauge(self, name: str, help_text: str = "") -> None:
168
+ """Register a gauge metric.
169
+
170
+ Args:
171
+ name: Metric name (without prefix).
172
+ help_text: Description for the HELP line.
173
+ """
174
+ full_name = f"{self._prefix}_{name}"
175
+ self._gauge_help[full_name] = help_text
176
+
177
+ def set_gauge(self, name: str, value: float, **labels: str) -> None:
178
+ """Set a gauge value.
179
+
180
+ Args:
181
+ name: Gauge name (without prefix).
182
+ value: Value to set.
183
+ **labels: Label key-value pairs.
184
+ """
185
+ full_name = f"{self._prefix}_{name}"
186
+ key = self._make_key(full_name, labels)
187
+ self._gauges[key] = value
188
+
189
+ def get_gauge(self, name: str, **labels: str) -> float | None:
190
+ """Get current gauge value.
191
+
192
+ Args:
193
+ name: Gauge name (without prefix).
194
+ **labels: Label key-value pairs.
195
+
196
+ Returns:
197
+ Current gauge value, or None if not set.
198
+ """
199
+ full_name = f"{self._prefix}_{name}"
200
+ key = self._make_key(full_name, labels)
201
+ return self._gauges.get(key)
202
+
203
+ def inc_gauge(self, name: str, value: float = 1.0, **labels: str) -> None:
204
+ """Increment a gauge.
205
+
206
+ Args:
207
+ name: Gauge name (without prefix).
208
+ value: Amount to increment.
209
+ **labels: Label key-value pairs.
210
+ """
211
+ full_name = f"{self._prefix}_{name}"
212
+ key = self._make_key(full_name, labels)
213
+ self._gauges[key] = self._gauges.get(key, 0.0) + value
214
+
215
+ def dec_gauge(self, name: str, value: float = 1.0, **labels: str) -> None:
216
+ """Decrement a gauge.
217
+
218
+ Args:
219
+ name: Gauge name (without prefix).
220
+ value: Amount to decrement.
221
+ **labels: Label key-value pairs.
222
+ """
223
+ self.inc_gauge(name, -value, **labels)
224
+
225
+ # ========================================================================
226
+ # Histogram Operations
227
+ # ========================================================================
228
+
229
+ def register_histogram(
230
+ self,
231
+ name: str,
232
+ help_text: str = "",
233
+ buckets: tuple[float, ...] = DEFAULT_BUCKETS,
234
+ ) -> None:
235
+ """Register a histogram metric.
236
+
237
+ Args:
238
+ name: Metric name (without prefix).
239
+ help_text: Description for the HELP line.
240
+ buckets: Bucket boundaries (must include +Inf).
241
+ """
242
+ full_name = f"{self._prefix}_{name}"
243
+ self._histogram_help[full_name] = help_text
244
+
245
+ # Ensure +Inf is included
246
+ if float("inf") not in buckets:
247
+ buckets = (*buckets, float("inf"))
248
+
249
+ self._histogram_buckets[full_name] = buckets
250
+
251
+ def observe_histogram(self, name: str, value: float, **labels: str) -> None:
252
+ """Record a histogram observation.
253
+
254
+ Args:
255
+ name: Histogram name (without prefix).
256
+ value: Observed value.
257
+ **labels: Label key-value pairs.
258
+ """
259
+ full_name = f"{self._prefix}_{name}"
260
+ key = self._make_key(full_name, labels)
261
+
262
+ if key not in self._histograms:
263
+ buckets = self._histogram_buckets.get(full_name, DEFAULT_BUCKETS)
264
+ self._histograms[key] = HistogramValue(
265
+ buckets={b: 0 for b in buckets},
266
+ )
267
+
268
+ hist = self._histograms[key]
269
+ hist.sum += value
270
+ hist.count += 1
271
+
272
+ # Update bucket counts
273
+ for bucket in hist.buckets:
274
+ if value <= bucket:
275
+ hist.buckets[bucket] += 1
276
+
277
+ def get_histogram(self, name: str, **labels: str) -> HistogramValue | None:
278
+ """Get histogram data.
279
+
280
+ Args:
281
+ name: Histogram name (without prefix).
282
+ **labels: Label key-value pairs.
283
+
284
+ Returns:
285
+ HistogramValue or None if not recorded.
286
+ """
287
+ full_name = f"{self._prefix}_{name}"
288
+ key = self._make_key(full_name, labels)
289
+ return self._histograms.get(key)
290
+
291
+ # ========================================================================
292
+ # Prometheus Format Output
293
+ # ========================================================================
294
+
295
+ def format_prometheus(self) -> str:
296
+ """Format all metrics in Prometheus exposition format.
297
+
298
+ Returns:
299
+ Metrics in Prometheus text format.
300
+ """
301
+ lines: list[str] = []
302
+
303
+ # Group metrics by name for TYPE and HELP lines
304
+ counter_names: set[str] = set()
305
+ gauge_names: set[str] = set()
306
+ histogram_names: set[str] = set()
307
+
308
+ # Counters
309
+ for key in sorted(self._counters.keys()):
310
+ name, labels = self._parse_key(key)
311
+
312
+ if name not in counter_names:
313
+ counter_names.add(name)
314
+ if name in self._counter_help:
315
+ lines.append(f"# HELP {name} {self._counter_help[name]}")
316
+ lines.append(f"# TYPE {name} counter")
317
+
318
+ value = self._counters[key]
319
+ if labels:
320
+ lines.append(f"{name}{{{labels}}} {self._format_value(value)}")
321
+ else:
322
+ lines.append(f"{name} {self._format_value(value)}")
323
+
324
+ # Gauges
325
+ for key in sorted(self._gauges.keys()):
326
+ name, labels = self._parse_key(key)
327
+
328
+ if name not in gauge_names:
329
+ gauge_names.add(name)
330
+ if name in self._gauge_help:
331
+ lines.append(f"# HELP {name} {self._gauge_help[name]}")
332
+ lines.append(f"# TYPE {name} gauge")
333
+
334
+ value = self._gauges[key]
335
+ if labels:
336
+ lines.append(f"{name}{{{labels}}} {self._format_value(value)}")
337
+ else:
338
+ lines.append(f"{name} {self._format_value(value)}")
339
+
340
+ # Histograms
341
+ for key in sorted(self._histograms.keys()):
342
+ name, labels = self._parse_key(key)
343
+
344
+ if name not in histogram_names:
345
+ histogram_names.add(name)
346
+ if name in self._histogram_help:
347
+ lines.append(f"# HELP {name} {self._histogram_help[name]}")
348
+ lines.append(f"# TYPE {name} histogram")
349
+
350
+ hist = self._histograms[key]
351
+
352
+ # Bucket lines
353
+ for bucket in sorted(hist.buckets.keys()):
354
+ count = hist.buckets[bucket]
355
+ bucket_str = self._format_bucket_value(bucket)
356
+ if labels:
357
+ lines.append(f'{name}_bucket{{{labels},le="{bucket_str}"}} {count}')
358
+ else:
359
+ lines.append(f'{name}_bucket{{le="{bucket_str}"}} {count}')
360
+
361
+ # Sum and count lines
362
+ if labels:
363
+ lines.append(f"{name}_sum{{{labels}}} {self._format_value(hist.sum)}")
364
+ lines.append(f"{name}_count{{{labels}}} {hist.count}")
365
+ else:
366
+ lines.append(f"{name}_sum {self._format_value(hist.sum)}")
367
+ lines.append(f"{name}_count {hist.count}")
368
+
369
+ return "\n".join(lines)
370
+
371
+ def _format_value(self, value: float) -> str:
372
+ """Format a numeric value for Prometheus output."""
373
+ if math.isinf(value):
374
+ return "+Inf" if value > 0 else "-Inf"
375
+ if math.isnan(value):
376
+ return "NaN"
377
+ if value == int(value):
378
+ return str(int(value))
379
+ return str(value)
380
+
381
+ def _format_bucket_value(self, value: float) -> str:
382
+ """Format a bucket boundary value for Prometheus output."""
383
+ if math.isinf(value):
384
+ return "+Inf"
385
+ if value == int(value):
386
+ return str(int(value))
387
+ return str(value)
388
+
389
+ # ========================================================================
390
+ # Reset
391
+ # ========================================================================
392
+
393
+ def reset(self) -> None:
394
+ """Reset all metrics to their initial state."""
395
+ self._counters.clear()
396
+ self._gauges.clear()
397
+ self._histograms.clear()
398
+
399
+
400
+ # ============================================================================
401
+ # Global Metrics Instance
402
+ # ============================================================================
403
+
404
+ # Global metrics instance for the application
405
+ metrics = Metrics()
406
+
407
+ # Register default metrics
408
+ metrics.register_counter("queries_total", "Total number of queries executed")
409
+ metrics.register_counter("cache_total", "Cache hit/miss counts")
410
+ metrics.register_counter("requests_total", "Total HTTP requests")
411
+ metrics.register_histogram("query_duration_ms", "Query execution time in milliseconds")
412
+ metrics.register_histogram("request_duration_ms", "HTTP request duration in milliseconds")
413
+ metrics.register_gauge("active_connections", "Number of active database connections")
414
+
415
+
416
+ # ============================================================================
417
+ # Convenience Functions
418
+ # ============================================================================
419
+
420
+
421
+ def record_query_execution(duration_ms: float, status: str = "success") -> None:
422
+ """Record a query execution metric.
423
+
424
+ Args:
425
+ duration_ms: Query execution time in milliseconds.
426
+ status: Query status ('success' or 'error').
427
+ """
428
+ metrics.inc_counter("queries_total", status=status)
429
+ metrics.observe_histogram("query_duration_ms", duration_ms, status=status)
430
+
431
+
432
+ def record_cache_hit(hit: bool) -> None:
433
+ """Record cache hit/miss.
434
+
435
+ Args:
436
+ hit: True if cache hit, False if miss.
437
+ """
438
+ result = "hit" if hit else "miss"
439
+ metrics.inc_counter("cache_total", result=result)
440
+
441
+
442
+ def record_request(
443
+ endpoint: str,
444
+ method: str,
445
+ status_code: int,
446
+ duration_ms: float,
447
+ ) -> None:
448
+ """Record an HTTP request metric.
449
+
450
+ Args:
451
+ endpoint: Request endpoint path.
452
+ method: HTTP method (GET, POST, etc.).
453
+ status_code: Response status code.
454
+ duration_ms: Request duration in milliseconds.
455
+ """
456
+ status = str(status_code)
457
+ metrics.inc_counter(
458
+ "requests_total",
459
+ endpoint=endpoint,
460
+ method=method,
461
+ status=status,
462
+ )
463
+ metrics.observe_histogram(
464
+ "request_duration_ms",
465
+ duration_ms,
466
+ endpoint=endpoint,
467
+ method=method,
468
+ )
469
+
470
+
471
+ def set_active_connections(count: int) -> None:
472
+ """Set the active database connections gauge.
473
+
474
+ Args:
475
+ count: Number of active connections.
476
+ """
477
+ metrics.set_gauge("active_connections", float(count))
478
+
479
+
480
+ # ============================================================================
481
+ # Router Factory
482
+ # ============================================================================
483
+
484
+
485
+ def create_metrics_router() -> APIRouter:
486
+ """Create a FastAPI router for the metrics endpoint.
487
+
488
+ Returns:
489
+ APIRouter with /metrics endpoint.
490
+
491
+ Example:
492
+ >>> from prismiq.metrics import create_metrics_router
493
+ >>> app.include_router(create_metrics_router())
494
+ """
495
+ router = APIRouter(tags=["metrics"])
496
+
497
+ @router.get("/metrics", response_class=PlainTextResponse)
498
+ async def get_metrics() -> str:
499
+ """Prometheus metrics endpoint.
500
+
501
+ Returns metrics in Prometheus exposition format.
502
+ """
503
+ return metrics.format_prometheus()
504
+
505
+ return router
506
+
507
+
508
+ # ============================================================================
509
+ # Metrics Context Manager
510
+ # ============================================================================
511
+
512
+
513
+ class Timer:
514
+ """Context manager for timing operations.
515
+
516
+ Example:
517
+ >>> with Timer() as t:
518
+ ... do_something()
519
+ >>> print(f"Took {t.duration_ms:.2f}ms")
520
+ """
521
+
522
+ def __init__(self) -> None:
523
+ self._start: float = 0.0
524
+ self._end: float = 0.0
525
+
526
+ def __enter__(self) -> Timer:
527
+ self._start = time.perf_counter()
528
+ return self
529
+
530
+ def __exit__(self, *args: Any) -> None:
531
+ self._end = time.perf_counter()
532
+
533
+ @property
534
+ def duration_ms(self) -> float:
535
+ """Get duration in milliseconds."""
536
+ return (self._end - self._start) * 1000