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/__init__.py +543 -0
- prismiq/api.py +1889 -0
- prismiq/auth.py +108 -0
- prismiq/cache.py +527 -0
- prismiq/calculated_field_processor.py +231 -0
- prismiq/calculated_fields.py +819 -0
- prismiq/dashboard_store.py +1219 -0
- prismiq/dashboards.py +374 -0
- prismiq/dates.py +247 -0
- prismiq/engine.py +1315 -0
- prismiq/executor.py +345 -0
- prismiq/filter_merge.py +397 -0
- prismiq/formatting.py +298 -0
- prismiq/logging.py +489 -0
- prismiq/metrics.py +536 -0
- prismiq/middleware.py +346 -0
- prismiq/permissions.py +87 -0
- prismiq/persistence/__init__.py +45 -0
- prismiq/persistence/models.py +208 -0
- prismiq/persistence/postgres_store.py +1119 -0
- prismiq/persistence/saved_query_store.py +336 -0
- prismiq/persistence/schema.sql +95 -0
- prismiq/persistence/setup.py +222 -0
- prismiq/persistence/tables.py +76 -0
- prismiq/pins.py +72 -0
- prismiq/py.typed +0 -0
- prismiq/query.py +1233 -0
- prismiq/schema.py +333 -0
- prismiq/schema_config.py +354 -0
- prismiq/sql_utils.py +147 -0
- prismiq/sql_validator.py +219 -0
- prismiq/sqlalchemy_builder.py +577 -0
- prismiq/timeseries.py +410 -0
- prismiq/transforms.py +471 -0
- prismiq/trends.py +573 -0
- prismiq/types.py +688 -0
- prismiq-0.1.0.dist-info/METADATA +109 -0
- prismiq-0.1.0.dist-info/RECORD +39 -0
- prismiq-0.1.0.dist-info/WHEEL +4 -0
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
|