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.
@@ -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