fastapi-loopguard 0.3.1__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.
- fastapi_loopguard/__init__.py +62 -0
- fastapi_loopguard/config.py +88 -0
- fastapi_loopguard/context.py +210 -0
- fastapi_loopguard/logging.py +100 -0
- fastapi_loopguard/metrics.py +204 -0
- fastapi_loopguard/middleware.py +236 -0
- fastapi_loopguard/monitor.py +412 -0
- fastapi_loopguard/py.typed +0 -0
- fastapi_loopguard/pytest_plugin.py +135 -0
- fastapi_loopguard-0.3.1.dist-info/METADATA +154 -0
- fastapi_loopguard-0.3.1.dist-info/RECORD +14 -0
- fastapi_loopguard-0.3.1.dist-info/WHEEL +4 -0
- fastapi_loopguard-0.3.1.dist-info/entry_points.txt +2 -0
- fastapi_loopguard-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""FastAPI LoopGuard - Detect event-loop blocking with per-request attribution.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi_loopguard import LoopGuardMiddleware, LoopGuardConfig
|
|
6
|
+
|
|
7
|
+
app = FastAPI()
|
|
8
|
+
|
|
9
|
+
# Basic usage with defaults
|
|
10
|
+
app.add_middleware(LoopGuardMiddleware)
|
|
11
|
+
|
|
12
|
+
# Or with custom config
|
|
13
|
+
config = LoopGuardConfig(
|
|
14
|
+
dev_mode=True, # Enable X-Blocking-* headers
|
|
15
|
+
prometheus_enabled=True, # Enable Prometheus metrics
|
|
16
|
+
)
|
|
17
|
+
app.add_middleware(LoopGuardMiddleware, config=config)
|
|
18
|
+
|
|
19
|
+
v0.2.0 Changes:
|
|
20
|
+
- Pure ASGI middleware (no BaseHTTPMiddleware)
|
|
21
|
+
- Concurrent request tracking with RequestRegistry
|
|
22
|
+
- Background calibration (first request not blocked)
|
|
23
|
+
- Proper lifecycle management via ASGI lifespan
|
|
24
|
+
|
|
25
|
+
v0.3.0 Changes:
|
|
26
|
+
- PEP 561 py.typed marker for type stub discovery
|
|
27
|
+
- Adaptive thresholds for high-concurrency environments
|
|
28
|
+
- Improved test coverage (logging, metrics, pytest plugin)
|
|
29
|
+
- High-concurrency configuration documentation
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from .config import LoopGuardConfig
|
|
33
|
+
from .context import (
|
|
34
|
+
RequestContext,
|
|
35
|
+
RequestRegistry,
|
|
36
|
+
get_active_requests,
|
|
37
|
+
get_current_request,
|
|
38
|
+
get_registry,
|
|
39
|
+
register_request,
|
|
40
|
+
unregister_request,
|
|
41
|
+
)
|
|
42
|
+
from .middleware import LoopGuardMiddleware
|
|
43
|
+
from .monitor import SentinelMonitor
|
|
44
|
+
|
|
45
|
+
__version__ = "0.3.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Core classes
|
|
49
|
+
"LoopGuardConfig",
|
|
50
|
+
"LoopGuardMiddleware",
|
|
51
|
+
"SentinelMonitor",
|
|
52
|
+
# Context tracking
|
|
53
|
+
"RequestContext",
|
|
54
|
+
"RequestRegistry",
|
|
55
|
+
"get_registry",
|
|
56
|
+
"register_request",
|
|
57
|
+
"unregister_request",
|
|
58
|
+
"get_active_requests",
|
|
59
|
+
"get_current_request", # Backward compat
|
|
60
|
+
# Version
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Configuration for LoopGuard middleware."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class LoopGuardConfig:
|
|
8
|
+
"""Configuration for the LoopGuard middleware.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
enabled: Whether monitoring is active. Set False to disable entirely.
|
|
12
|
+
monitor_interval_ms: How often the sentinel checks for blocking (milliseconds).
|
|
13
|
+
Default 10ms detects blocking >50ms with ~0.002% CPU overhead.
|
|
14
|
+
The sleep is non-blocking (cooperative), so it doesn't affect throughput.
|
|
15
|
+
threshold_multiplier: Blocking detected when lag > baseline × multiplier.
|
|
16
|
+
calibration_iterations: Number of samples during startup calibration.
|
|
17
|
+
fallback_threshold_ms: Used if calibration produces unreliable results.
|
|
18
|
+
dev_mode: Enable response headers with lag information.
|
|
19
|
+
log_blocking_events: Log when blocking is detected.
|
|
20
|
+
prometheus_enabled: Expose Prometheus metrics.
|
|
21
|
+
adaptive_threshold: Enable adaptive threshold based on sliding window.
|
|
22
|
+
adaptive_window_size: Number of samples in the sliding window.
|
|
23
|
+
adaptive_percentile: Percentile (0.0-1.0) for baseline calculation.
|
|
24
|
+
adaptive_min_samples: Minimum samples before adaptive mode activates.
|
|
25
|
+
adaptive_update_interval_ms: How often to recalculate threshold.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
monitor_interval_ms: float = 10.0
|
|
30
|
+
threshold_multiplier: float = 5.0
|
|
31
|
+
calibration_iterations: int = 100
|
|
32
|
+
fallback_threshold_ms: float = 50.0
|
|
33
|
+
dev_mode: bool = False
|
|
34
|
+
log_blocking_events: bool = True
|
|
35
|
+
prometheus_enabled: bool = False
|
|
36
|
+
|
|
37
|
+
# Adaptive threshold settings
|
|
38
|
+
adaptive_threshold: bool = False
|
|
39
|
+
adaptive_window_size: int = 1000
|
|
40
|
+
adaptive_percentile: float = 0.95
|
|
41
|
+
adaptive_min_samples: int = 100
|
|
42
|
+
adaptive_update_interval_ms: float = 1000.0
|
|
43
|
+
|
|
44
|
+
# Cumulative blocking detection
|
|
45
|
+
cumulative_blocking_enabled: bool = False
|
|
46
|
+
cumulative_blocking_threshold_ms: float = 200.0
|
|
47
|
+
cumulative_window_ms: float = 1000.0
|
|
48
|
+
|
|
49
|
+
# Internal: paths to exclude from monitoring (e.g., health checks)
|
|
50
|
+
exclude_paths: frozenset[str] = field(
|
|
51
|
+
default_factory=lambda: frozenset({"/health", "/healthz", "/ready", "/metrics"})
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
"""Validate configuration values."""
|
|
56
|
+
if self.monitor_interval_ms <= 0:
|
|
57
|
+
raise ValueError("monitor_interval_ms must be positive")
|
|
58
|
+
if self.threshold_multiplier <= 1:
|
|
59
|
+
raise ValueError("threshold_multiplier must be greater than 1")
|
|
60
|
+
if self.calibration_iterations < 10:
|
|
61
|
+
raise ValueError("calibration_iterations must be at least 10")
|
|
62
|
+
if self.fallback_threshold_ms <= 0:
|
|
63
|
+
raise ValueError("fallback_threshold_ms must be positive")
|
|
64
|
+
# Adaptive threshold validation
|
|
65
|
+
if self.adaptive_window_size < 100:
|
|
66
|
+
raise ValueError("adaptive_window_size must be at least 100")
|
|
67
|
+
if not 0.5 <= self.adaptive_percentile <= 0.99:
|
|
68
|
+
raise ValueError("adaptive_percentile must be between 0.5 and 0.99")
|
|
69
|
+
if self.adaptive_min_samples < 10:
|
|
70
|
+
raise ValueError("adaptive_min_samples must be at least 10")
|
|
71
|
+
if self.adaptive_min_samples > self.adaptive_window_size:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"adaptive_min_samples cannot be greater than adaptive_window_size"
|
|
74
|
+
)
|
|
75
|
+
if self.adaptive_update_interval_ms <= 0:
|
|
76
|
+
raise ValueError("adaptive_update_interval_ms must be positive")
|
|
77
|
+
# Cumulative blocking validation
|
|
78
|
+
if self.cumulative_blocking_threshold_ms <= 0:
|
|
79
|
+
raise ValueError("cumulative_blocking_threshold_ms must be positive")
|
|
80
|
+
if self.cumulative_window_ms <= 0:
|
|
81
|
+
raise ValueError("cumulative_window_ms must be positive")
|
|
82
|
+
if (
|
|
83
|
+
self.cumulative_blocking_enabled
|
|
84
|
+
and self.cumulative_window_ms < self.monitor_interval_ms
|
|
85
|
+
):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"cumulative_window_ms cannot be less than monitor_interval_ms"
|
|
88
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Context tracking for per-request attribution.
|
|
2
|
+
|
|
3
|
+
This module provides a registry-based approach for tracking multiple concurrent
|
|
4
|
+
requests. Unlike the previous single-slot design, this handles concurrent requests
|
|
5
|
+
correctly by storing contexts in a dict keyed by request_id.
|
|
6
|
+
|
|
7
|
+
Since asyncio is single-threaded, no locks are needed for the registry operations.
|
|
8
|
+
The monitor iterates all active contexts when blocking is detected.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class RequestContext:
|
|
20
|
+
"""Context information for a single request.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
request_id: Unique identifier for this request.
|
|
24
|
+
path: The request path (e.g., "/api/users").
|
|
25
|
+
method: HTTP method (GET, POST, etc.).
|
|
26
|
+
start_time: Monotonic timestamp when request started.
|
|
27
|
+
blocking_events: List of (lag_ms, timestamp) tuples for blocking detected.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
request_id: str
|
|
31
|
+
path: str
|
|
32
|
+
method: str
|
|
33
|
+
start_time: float = field(default_factory=time.monotonic)
|
|
34
|
+
blocking_events: list[tuple[float, float]] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def record_blocking(self, lag_ms: float) -> None:
|
|
37
|
+
"""Record a blocking event for this request."""
|
|
38
|
+
self.blocking_events.append((lag_ms, time.monotonic()))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def total_blocking_ms(self) -> float:
|
|
42
|
+
"""Sum of all blocking event durations."""
|
|
43
|
+
return sum(lag for lag, _ in self.blocking_events)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def blocking_count(self) -> int:
|
|
47
|
+
"""Number of blocking events detected."""
|
|
48
|
+
return len(self.blocking_events)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RequestRegistry:
|
|
52
|
+
"""Registry of active request contexts.
|
|
53
|
+
|
|
54
|
+
This class manages multiple concurrent request contexts, storing them
|
|
55
|
+
in a dict keyed by request_id. This allows the monitor to correctly
|
|
56
|
+
attribute blocking events to all active requests.
|
|
57
|
+
|
|
58
|
+
No locks are needed since asyncio is single-threaded - all operations
|
|
59
|
+
happen on the same thread within the event loop.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_contexts",)
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
"""Initialize an empty registry."""
|
|
66
|
+
self._contexts: dict[str, RequestContext] = {}
|
|
67
|
+
|
|
68
|
+
def register(self, ctx: RequestContext) -> None:
|
|
69
|
+
"""Register a new active request context.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
ctx: The request context to register.
|
|
73
|
+
"""
|
|
74
|
+
self._contexts[ctx.request_id] = ctx
|
|
75
|
+
|
|
76
|
+
def unregister(self, request_id: str) -> RequestContext | None:
|
|
77
|
+
"""Remove a request context when the request completes.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
request_id: The ID of the request to unregister.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The removed context, or None if not found.
|
|
84
|
+
"""
|
|
85
|
+
return self._contexts.pop(request_id, None)
|
|
86
|
+
|
|
87
|
+
def get(self, request_id: str) -> RequestContext | None:
|
|
88
|
+
"""Get a specific request context by ID.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
request_id: The ID of the request to retrieve.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The request context, or None if not found.
|
|
95
|
+
"""
|
|
96
|
+
return self._contexts.get(request_id)
|
|
97
|
+
|
|
98
|
+
def get_all_active(self) -> Iterator[RequestContext]:
|
|
99
|
+
"""Iterate all currently active request contexts.
|
|
100
|
+
|
|
101
|
+
This is used by the monitor to attribute blocking events
|
|
102
|
+
to all requests that were active when blocking occurred.
|
|
103
|
+
|
|
104
|
+
Yields:
|
|
105
|
+
Each active RequestContext.
|
|
106
|
+
"""
|
|
107
|
+
yield from self._contexts.values()
|
|
108
|
+
|
|
109
|
+
def active_count(self) -> int:
|
|
110
|
+
"""Get the number of currently active requests.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The count of active requests.
|
|
114
|
+
"""
|
|
115
|
+
return len(self._contexts)
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
"""Clear all contexts.
|
|
119
|
+
|
|
120
|
+
Used for testing and shutdown cleanup.
|
|
121
|
+
"""
|
|
122
|
+
self._contexts.clear()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Global registry instance
|
|
126
|
+
_registry = RequestRegistry()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_registry() -> RequestRegistry:
|
|
130
|
+
"""Get the global request registry.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The global RequestRegistry instance.
|
|
134
|
+
"""
|
|
135
|
+
return _registry
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register_request(ctx: RequestContext) -> None:
|
|
139
|
+
"""Register a request context in the global registry.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
ctx: The request context to register.
|
|
143
|
+
"""
|
|
144
|
+
_registry.register(ctx)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unregister_request(request_id: str) -> RequestContext | None:
|
|
148
|
+
"""Unregister a request context from the global registry.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
request_id: The ID of the request to unregister.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The removed context, or None if not found.
|
|
155
|
+
"""
|
|
156
|
+
return _registry.unregister(request_id)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_active_requests() -> Iterator[RequestContext]:
|
|
160
|
+
"""Get all active request contexts.
|
|
161
|
+
|
|
162
|
+
Yields:
|
|
163
|
+
Each active RequestContext.
|
|
164
|
+
"""
|
|
165
|
+
return _registry.get_all_active()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Backward compatibility aliases
|
|
169
|
+
# These maintain the old API but now work correctly with concurrent requests
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_current_request() -> RequestContext | None:
|
|
173
|
+
"""Get any active request context.
|
|
174
|
+
|
|
175
|
+
Note: With multiple concurrent requests, this returns an arbitrary
|
|
176
|
+
active request. For correct attribution, use get_active_requests()
|
|
177
|
+
to iterate all active contexts.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
An active RequestContext, or None if no requests are active.
|
|
181
|
+
"""
|
|
182
|
+
for ctx in _registry.get_all_active():
|
|
183
|
+
return ctx
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def set_current_request(ctx: RequestContext) -> str:
|
|
188
|
+
"""Register a request context and return its ID for cleanup.
|
|
189
|
+
|
|
190
|
+
This is a compatibility wrapper around register_request().
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
ctx: The request context to register.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The request_id (used as token for reset_current_request).
|
|
197
|
+
"""
|
|
198
|
+
_registry.register(ctx)
|
|
199
|
+
return ctx.request_id
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def reset_current_request(request_id: str) -> None:
|
|
203
|
+
"""Unregister a request context by its ID.
|
|
204
|
+
|
|
205
|
+
This is a compatibility wrapper around unregister_request().
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
request_id: The request ID returned by set_current_request.
|
|
209
|
+
"""
|
|
210
|
+
_registry.unregister(request_id)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Structured logging for LoopGuard events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("fastapi_loopguard")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StructuredFormatter(logging.Formatter):
|
|
15
|
+
"""JSON formatter for structured logging."""
|
|
16
|
+
|
|
17
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
18
|
+
"""Format a log record as JSON."""
|
|
19
|
+
log_data: dict[str, Any] = {
|
|
20
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
21
|
+
"level": record.levelname,
|
|
22
|
+
"logger": record.name,
|
|
23
|
+
"message": record.getMessage(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Add extra fields if present
|
|
27
|
+
if hasattr(record, "path"):
|
|
28
|
+
log_data["path"] = record.path
|
|
29
|
+
if hasattr(record, "method"):
|
|
30
|
+
log_data["method"] = record.method
|
|
31
|
+
if hasattr(record, "lag_ms"):
|
|
32
|
+
log_data["lag_ms"] = record.lag_ms
|
|
33
|
+
if hasattr(record, "request_id"):
|
|
34
|
+
log_data["request_id"] = record.request_id
|
|
35
|
+
if hasattr(record, "blocking_count"):
|
|
36
|
+
log_data["blocking_count"] = record.blocking_count
|
|
37
|
+
|
|
38
|
+
return json.dumps(log_data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure_logging(
|
|
42
|
+
level: int = logging.INFO,
|
|
43
|
+
structured: bool = False,
|
|
44
|
+
stream: Any = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Configure logging for LoopGuard.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
level: The logging level (default INFO).
|
|
50
|
+
structured: If True, use JSON formatting.
|
|
51
|
+
stream: Output stream (default stderr).
|
|
52
|
+
"""
|
|
53
|
+
handler = logging.StreamHandler(stream or sys.stderr)
|
|
54
|
+
|
|
55
|
+
if structured:
|
|
56
|
+
handler.setFormatter(StructuredFormatter())
|
|
57
|
+
else:
|
|
58
|
+
handler.setFormatter(
|
|
59
|
+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
logger.addHandler(handler)
|
|
63
|
+
logger.setLevel(level)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def log_blocking_event(
|
|
67
|
+
lag_ms: float,
|
|
68
|
+
path: str | None = None,
|
|
69
|
+
method: str | None = None,
|
|
70
|
+
request_id: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Log a blocking event with structured data.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
lag_ms: The blocking duration in milliseconds.
|
|
76
|
+
path: The request path (if available).
|
|
77
|
+
method: The HTTP method (if available).
|
|
78
|
+
request_id: The request ID (if available).
|
|
79
|
+
"""
|
|
80
|
+
extra = {
|
|
81
|
+
"lag_ms": lag_ms,
|
|
82
|
+
"path": path,
|
|
83
|
+
"method": method,
|
|
84
|
+
"request_id": request_id,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if path:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"Event loop blocked for %.2fms during %s %s",
|
|
90
|
+
lag_ms,
|
|
91
|
+
method,
|
|
92
|
+
path,
|
|
93
|
+
extra=extra,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"Event loop blocked for %.2fms (no active request)",
|
|
98
|
+
lag_ms,
|
|
99
|
+
extra=extra,
|
|
100
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Prometheus metrics for LoopGuard.
|
|
2
|
+
|
|
3
|
+
Supports custom registries for test isolation via the registry parameter.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_prometheus() -> tuple[type, type, type, Any] | None:
|
|
12
|
+
"""Try to import prometheus_client with registry support."""
|
|
13
|
+
try:
|
|
14
|
+
from prometheus_client import REGISTRY, Counter, Gauge, Histogram
|
|
15
|
+
|
|
16
|
+
return Counter, Histogram, Gauge, REGISTRY
|
|
17
|
+
except ImportError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoopGuardMetrics:
|
|
22
|
+
"""Prometheus metrics for loop-lag monitoring.
|
|
23
|
+
|
|
24
|
+
Metrics exposed:
|
|
25
|
+
- loopguard_blocking_total: Counter of blocking events
|
|
26
|
+
- loopguard_lag_seconds: Histogram of lag durations
|
|
27
|
+
- loopguard_requests_monitored_total: Counter of monitored requests
|
|
28
|
+
- loopguard_threshold_seconds: Gauge of current threshold
|
|
29
|
+
|
|
30
|
+
Supports custom registries for test isolation.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__slots__ = (
|
|
34
|
+
"_prefix",
|
|
35
|
+
"_registry",
|
|
36
|
+
"_blocking_total",
|
|
37
|
+
"_lag_histogram",
|
|
38
|
+
"_requests_total",
|
|
39
|
+
"_threshold_gauge",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
prefix: str = "loopguard",
|
|
45
|
+
registry: Any = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize metrics.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
prefix: Prefix for all metric names.
|
|
51
|
+
registry: Prometheus registry to use. If None, uses default REGISTRY.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
RuntimeError: If prometheus_client is not installed.
|
|
55
|
+
"""
|
|
56
|
+
prometheus = _get_prometheus()
|
|
57
|
+
if prometheus is None:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"prometheus_client is not installed. "
|
|
60
|
+
"Install with: pip install fastapi-loopguard[prometheus]"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
counter_cls, histogram_cls, gauge_cls, default_registry = prometheus
|
|
64
|
+
self._prefix = prefix
|
|
65
|
+
self._registry = registry if registry is not None else default_registry
|
|
66
|
+
|
|
67
|
+
# Create metrics with explicit registry
|
|
68
|
+
self._blocking_total: Any = counter_cls(
|
|
69
|
+
f"{prefix}_blocking_total",
|
|
70
|
+
"Total number of blocking events detected",
|
|
71
|
+
["path", "method"],
|
|
72
|
+
registry=self._registry,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self._lag_histogram: Any = histogram_cls(
|
|
76
|
+
f"{prefix}_lag_seconds",
|
|
77
|
+
"Histogram of event loop lag durations",
|
|
78
|
+
["path", "method"],
|
|
79
|
+
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
|
|
80
|
+
registry=self._registry,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self._requests_total: Any = counter_cls(
|
|
84
|
+
f"{prefix}_requests_monitored_total",
|
|
85
|
+
"Total number of requests monitored",
|
|
86
|
+
["path", "method"],
|
|
87
|
+
registry=self._registry,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
self._threshold_gauge: Any = gauge_cls(
|
|
91
|
+
f"{prefix}_threshold_seconds",
|
|
92
|
+
"Current blocking detection threshold",
|
|
93
|
+
registry=self._registry,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def prefix(self) -> str:
|
|
98
|
+
"""The metric name prefix."""
|
|
99
|
+
return self._prefix
|
|
100
|
+
|
|
101
|
+
def record_blocking(
|
|
102
|
+
self,
|
|
103
|
+
lag_seconds: float,
|
|
104
|
+
path: str | None = None,
|
|
105
|
+
method: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Record a blocking event.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
lag_seconds: The blocking duration in seconds.
|
|
111
|
+
path: The request path.
|
|
112
|
+
method: The HTTP method.
|
|
113
|
+
"""
|
|
114
|
+
labels = {
|
|
115
|
+
"path": path or "unknown",
|
|
116
|
+
"method": method or "unknown",
|
|
117
|
+
}
|
|
118
|
+
self._blocking_total.labels(**labels).inc()
|
|
119
|
+
self._lag_histogram.labels(**labels).observe(lag_seconds)
|
|
120
|
+
|
|
121
|
+
def record_request(self, path: str, method: str) -> None:
|
|
122
|
+
"""Record a monitored request.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: The request path.
|
|
126
|
+
method: The HTTP method.
|
|
127
|
+
"""
|
|
128
|
+
self._requests_total.labels(path=path, method=method).inc()
|
|
129
|
+
|
|
130
|
+
def set_threshold(self, threshold_seconds: float) -> None:
|
|
131
|
+
"""Set the current threshold gauge.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
threshold_seconds: The current threshold in seconds.
|
|
135
|
+
"""
|
|
136
|
+
self._threshold_gauge.set(threshold_seconds)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Instance management - use regular dict since __slots__ prevents weak refs
|
|
140
|
+
_instances: dict[str, LoopGuardMetrics] = {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_metrics(prefix: str = "loopguard") -> LoopGuardMetrics | None:
|
|
144
|
+
"""Get existing metrics instance by prefix.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
prefix: The prefix used when creating the metrics.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The metrics instance, or None if not found.
|
|
151
|
+
"""
|
|
152
|
+
return _instances.get(prefix)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_metrics(
|
|
156
|
+
prefix: str = "loopguard",
|
|
157
|
+
registry: Any = None,
|
|
158
|
+
) -> LoopGuardMetrics:
|
|
159
|
+
"""Create or get a metrics instance.
|
|
160
|
+
|
|
161
|
+
For testing, pass a custom registry to avoid pollution.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
prefix: Prefix for all metric names.
|
|
165
|
+
registry: Optional Prometheus registry for test isolation.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The metrics instance.
|
|
169
|
+
"""
|
|
170
|
+
# Use registry id to allow different registries with same prefix
|
|
171
|
+
key = f"{prefix}:{id(registry)}"
|
|
172
|
+
if key not in _instances:
|
|
173
|
+
metrics = LoopGuardMetrics(prefix, registry)
|
|
174
|
+
_instances[key] = metrics
|
|
175
|
+
return _instances[key]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def reset_metrics() -> None:
|
|
179
|
+
"""Reset all metrics instances.
|
|
180
|
+
|
|
181
|
+
For testing only - clears the instance cache.
|
|
182
|
+
"""
|
|
183
|
+
_instances.clear()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Backward compatibility aliases
|
|
187
|
+
_metrics_instance: LoopGuardMetrics | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def init_metrics(prefix: str = "loopguard") -> LoopGuardMetrics:
|
|
191
|
+
"""Initialize the global metrics instance.
|
|
192
|
+
|
|
193
|
+
Deprecated: Use create_metrics() for new code.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
prefix: Prefix for all metric names.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The initialized metrics instance.
|
|
200
|
+
"""
|
|
201
|
+
global _metrics_instance
|
|
202
|
+
if _metrics_instance is None:
|
|
203
|
+
_metrics_instance = create_metrics(prefix)
|
|
204
|
+
return _metrics_instance
|