fastapi-alertengine 1.0.0__tar.gz → 1.0.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-alertengine
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: FastAPI AlertEngine: fintech-grade request monitoring (p95 latency + error rate) without Prometheus.
5
5
  Author: Tandem Media
6
6
  Project-URL: Homepage, https://github.com/Tandem-Media/fastapi-alertengine
@@ -0,0 +1,7 @@
1
+ # fastapi_alertengine/__init__.py
2
+
3
+ from .engine import AlertEngine # or from .alert_engine import AlertEngine
4
+ from .middleware import RequestMetricsMiddleware
5
+ from .client import get_alert_engine
6
+
7
+ __all__ = ["AlertEngine", "RequestMetricsMiddleware", "get_alert_engine"]
@@ -0,0 +1,150 @@
1
+ import time
2
+ from typing import Callable
3
+
4
+ import redis
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+
9
+ app = FastAPI()
10
+
11
+
12
+ class RequestMetricsMiddleware(BaseHTTPMiddleware):
13
+ def __init__(self, app, redis_url: str = "redis://localhost:6379/0"):
14
+ super().__init__(app)
15
+ self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
16
+
17
+ async def dispatch(self, request: Request, call_next: Callable):
18
+ start = time.time()
19
+ try:
20
+ response = await call_next(request)
21
+ status_code = response.status_code
22
+ except Exception:
23
+ status_code = 500
24
+ response = JSONResponse({"detail": "Internal server error"}, status_code=500)
25
+
26
+ duration_ms = (time.time() - start) * 1000
27
+ # Write a minimal metric to Redis Stream
28
+ self.redis.xadd(
29
+ "request_metrics",
30
+ {
31
+ "path": request.url.path,
32
+ "status": status_code,
33
+ "duration_ms": f"{duration_ms:.2f}",
34
+ },
35
+ maxlen=1000,
36
+ )
37
+ return response
38
+
39
+
40
+ class AlertEngine:
41
+ """
42
+ AlertEngine Lite:
43
+ - Reads from Redis Stream `request_metrics`
44
+ - Tracks rolling error rate and P95 latency
45
+ - For now: prints alerts to console (no email/slack yet)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ redis_url: str = "redis://localhost:6379/0",
51
+ stream_key: str = "request_metrics",
52
+ group: str = "alert_engine",
53
+ consumer: str = "alert_engine_1",
54
+ error_rate_threshold: float = 0.05,
55
+ p95_latency_threshold_ms: float = 500.0,
56
+ window_size: int = 100,
57
+ ):
58
+ self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
59
+ self.stream_key = stream_key
60
+ self.group = group
61
+ self.consumer = consumer
62
+ self.error_rate_threshold = error_rate_threshold
63
+ self.p95_latency_threshold_ms = p95_latency_threshold_ms
64
+ self.window_size = window_size
65
+
66
+ # Create consumer group if it doesn't exist
67
+ try:
68
+ self.redis.xgroup_create(self.stream_key, self.group, id="$", mkstream=True)
69
+ except redis.ResponseError as e:
70
+ # Group already exists
71
+ if "BUSYGROUP" not in str(e):
72
+ raise
73
+
74
+ def _calculate_alerts(self, events: list[dict]) -> None:
75
+ if not events:
76
+ return
77
+
78
+ durations = []
79
+ errors = 0
80
+
81
+ for e in events:
82
+ fields = e["fields"]
83
+ status = int(fields.get("status", 500))
84
+ duration_ms = float(fields.get("duration_ms", 0.0))
85
+ durations.append(duration_ms)
86
+ if status >= 500:
87
+ errors += 1
88
+
89
+ error_rate = errors / len(events)
90
+ p95_latency = self._p95(durations)
91
+
92
+ if error_rate >= self.error_rate_threshold:
93
+ print(
94
+ f"[ALERT] High error rate: {error_rate:.2%} "
95
+ f"(threshold {self.error_rate_threshold:.2%})"
96
+ )
97
+
98
+ if p95_latency >= self.p95_latency_threshold_ms:
99
+ print(
100
+ f"[ALERT] High P95 latency: {p95_latency:.1f} ms "
101
+ f"(threshold {self.p95_latency_threshold_ms:.1f} ms)"
102
+ )
103
+
104
+ @staticmethod
105
+ def _p95(values: list[float]) -> float:
106
+ if not values:
107
+ return 0.0
108
+ values_sorted = sorted(values)
109
+ idx = int(0.95 * (len(values_sorted) - 1))
110
+ return values_sorted[idx]
111
+
112
+ def poll_once(self, count: int = 200, block_ms: int = 1000) -> None:
113
+ """
114
+ Polls Redis Streams once and prints alerts if thresholds are crossed.
115
+ Can be called in a loop or a background task.
116
+ """
117
+ resp = self.redis.xreadgroup(
118
+ groupname=self.group,
119
+ consumername=self.consumer,
120
+ streams={self.stream_key: ">"},
121
+ count=count,
122
+ block=block_ms,
123
+ )
124
+
125
+ if not resp:
126
+ return
127
+
128
+ # resp is a list of (stream, [ (id, fields), ... ])
129
+ events = []
130
+ ids_to_ack = []
131
+ for stream_name, entries in resp:
132
+ for entry_id, fields in entries:
133
+ events.append({"id": entry_id, "fields": fields})
134
+ ids_to_ack.append(entry_id)
135
+
136
+ # Use only the latest window_size events for alert calculations
137
+ events = events[-self.window_size :]
138
+ self._calculate_alerts(events)
139
+
140
+ if ids_to_ack:
141
+ self.redis.xack(self.stream_key, self.group, *ids_to_ack)
142
+
143
+
144
+ # Wire up middleware
145
+ app.add_middleware(RequestMetricsMiddleware)
146
+
147
+
148
+ @app.get("/health")
149
+ async def health():
150
+ return {"status": "ok"}
@@ -0,0 +1,17 @@
1
+ # fastapi_alertengine/client.py
2
+
3
+ from functools import lru_cache
4
+
5
+ from .engine import AlertEngine # or from .alert_engine import AlertEngine if you didn't rename
6
+ from .config import AlertConfig
7
+
8
+
9
+ @lru_cache(maxsize=1)
10
+ def get_alert_engine(config: AlertConfig | None = None) -> AlertEngine:
11
+ """
12
+ Return a singleton AlertEngine instance.
13
+
14
+ If a config is provided on first call, it is used to initialize the engine.
15
+ Subsequent calls ignore the config argument and return the same instance.
16
+ """
17
+ return AlertEngine(config=config)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-alertengine
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: FastAPI AlertEngine: fintech-grade request monitoring (p95 latency + error rate) without Prometheus.
5
5
  Author: Tandem Media
6
6
  Project-URL: Homepage, https://github.com/Tandem-Media/fastapi-alertengine
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.cfg
4
+ ./fastapi_alertengine/__init__.py
5
+ ./fastapi_alertengine/app_main.py
6
+ ./fastapi_alertengine/client.py
7
+ ./fastapi_alertengine/config.py
8
+ ./fastapi_alertengine/engine.py.py
9
+ ./fastapi_alertengine/middleware.py
10
+ ./fastapi_alertengine/storage.py.py
11
+ fastapi_alertengine/__init__.py
12
+ fastapi_alertengine/app_main.py
13
+ fastapi_alertengine/client.py
14
+ fastapi_alertengine/config.py
15
+ fastapi_alertengine/engine.py.py
16
+ fastapi_alertengine/middleware.py
17
+ fastapi_alertengine/storage.py.py
18
+ fastapi_alertengine.egg-info/PKG-INFO
19
+ fastapi_alertengine.egg-info/SOURCES.txt
20
+ fastapi_alertengine.egg-info/dependency_links.txt
21
+ fastapi_alertengine.egg-info/requires.txt
22
+ fastapi_alertengine.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ fastapi_alertengine
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-alertengine"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "FastAPI AlertEngine: fintech-grade request monitoring (p95 latency + error rate) without Prometheus."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,16 @@
1
+ [metadata]
2
+ name = fastapi-alertengine
3
+ version = "1.0.2"
4
+
5
+ [options]
6
+ packages = find:
7
+ package_dir =
8
+ = .
9
+
10
+ [options.packages.find]
11
+ where = .
12
+
13
+ [egg_info]
14
+ tag_build =
15
+ tag_date = 0
16
+
@@ -1,7 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- fastapi_alertengine.egg-info/PKG-INFO
4
- fastapi_alertengine.egg-info/SOURCES.txt
5
- fastapi_alertengine.egg-info/dependency_links.txt
6
- fastapi_alertengine.egg-info/requires.txt
7
- fastapi_alertengine.egg-info/top_level.txt
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-