fastapi-calf 0.1.1__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.
- fastapi_calf-0.1.1/PKG-INFO +106 -0
- fastapi_calf-0.1.1/README.md +97 -0
- fastapi_calf-0.1.1/fastapi_calf/__init__.py +4 -0
- fastapi_calf-0.1.1/fastapi_calf/calf.py +45 -0
- fastapi_calf-0.1.1/fastapi_calf/cleanup.py +11 -0
- fastapi_calf-0.1.1/fastapi_calf/config.py +6 -0
- fastapi_calf-0.1.1/fastapi_calf/daemon.py +327 -0
- fastapi_calf-0.1.1/fastapi_calf/decorator.py +56 -0
- fastapi_calf-0.1.1/fastapi_calf/emitter.py +21 -0
- fastapi_calf-0.1.1/fastapi_calf/history/__init__.py +1 -0
- fastapi_calf-0.1.1/fastapi_calf/history/history.py +10 -0
- fastapi_calf-0.1.1/fastapi_calf/metrics/__init__.py +1 -0
- fastapi_calf-0.1.1/fastapi_calf/metrics/metrics.py +32 -0
- fastapi_calf-0.1.1/fastapi_calf/sparkline/__init__.py +1 -0
- fastapi_calf-0.1.1/fastapi_calf/sparkline/sparkline.py +26 -0
- fastapi_calf-0.1.1/fastapi_calf/system/__init__.py +1 -0
- fastapi_calf-0.1.1/fastapi_calf/system/cpu.py +37 -0
- fastapi_calf-0.1.1/fastapi_calf/system/gpu.py +0 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/PKG-INFO +106 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/SOURCES.txt +25 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/dependency_links.txt +1 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/entry_points.txt +2 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/requires.txt +2 -0
- fastapi_calf-0.1.1/fastapi_calf.egg-info/top_level.txt +1 -0
- fastapi_calf-0.1.1/pyproject.toml +21 -0
- fastapi_calf-0.1.1/setup.cfg +4 -0
- fastapi_calf-0.1.1/tests/test.py +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-calf
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A lightweight real-time terminal monitor for FastAPI servers
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: rich
|
|
8
|
+
Requires-Dist: psutil
|
|
9
|
+
|
|
10
|
+
# 🐮 fastapi-calf
|
|
11
|
+
|
|
12
|
+
**A lightweight, real-time observability for FastAPI.**
|
|
13
|
+
|
|
14
|
+
`fastapi-calf` watches decorated API routes and presents request activity,
|
|
15
|
+
latency, throughput, failures, CPU, memory, and worker information in a compact
|
|
16
|
+
terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
|
|
17
|
+
available, your API continues serving requests normally.
|
|
18
|
+
|
|
19
|
+
## Highlights
|
|
20
|
+
|
|
21
|
+
- ⚡ Live requests-per-second and latency sparklines
|
|
22
|
+
- 🧭 Per-route request, failure, status, and response-time statistics
|
|
23
|
+
- 🖥️ CPU and memory usage across multiple application workers
|
|
24
|
+
- 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
|
|
25
|
+
- 🛡️ Short-timeout event delivery that never takes down the application
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Install the runtime dependencies from the repository root:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
python -m pip install fastapi uvicorn rich psutil
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Start the monitoring dashboard on port `8005`:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python -m fastapi_calf.daemon --port 8005
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In another terminal, start the included example API with four workers:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
python -m uvicorn server.app:app --port 8000 --workers 4
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
|
|
48
|
+
dashboard updates in real time.
|
|
49
|
+
|
|
50
|
+
## Instrumenting an API
|
|
51
|
+
|
|
52
|
+
Configure the monitor once, then decorate the routes you want to observe:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import FastAPI, Request
|
|
56
|
+
from fastapi_calf import Calf, lookout
|
|
57
|
+
|
|
58
|
+
app = FastAPI()
|
|
59
|
+
Calf.listen(port=8005)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/health")
|
|
63
|
+
@lookout
|
|
64
|
+
def health(request: Request):
|
|
65
|
+
return {"status": "healthy"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.post("/jobs")
|
|
69
|
+
@lookout
|
|
70
|
+
async def create_job(request: Request):
|
|
71
|
+
payload = await request.json()
|
|
72
|
+
return {"accepted": True, "job": payload}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `Request` parameter allows `@lookout` to capture the method, path, query
|
|
76
|
+
parameters, request size, status, and timing without changing the response.
|
|
77
|
+
|
|
78
|
+
## Tests and traffic simulation
|
|
79
|
+
|
|
80
|
+
Install the testing tools and run the correctness suite:
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
python -m pip install -r requirements-test.txt
|
|
84
|
+
python -m pytest tests/test_api.py -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The load runner starts four Uvicorn workers, verifies every sample endpoint,
|
|
88
|
+
and pushes **100 concurrent API calls** in a short burst:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
python -m tests.load_test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Customize a run with `--requests`, `--concurrency`, and `--workers`. See
|
|
95
|
+
[TESTING.md](TESTING.md) for additional examples.
|
|
96
|
+
|
|
97
|
+
## Project layout
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
fastapi_calf/ monitoring library and terminal dashboard
|
|
101
|
+
server/ instrumented example FastAPI application
|
|
102
|
+
tests/ endpoint and concurrent load tests
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
|
|
106
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 🐮 fastapi-calf
|
|
2
|
+
|
|
3
|
+
**A lightweight, real-time observability for FastAPI.**
|
|
4
|
+
|
|
5
|
+
`fastapi-calf` watches decorated API routes and presents request activity,
|
|
6
|
+
latency, throughput, failures, CPU, memory, and worker information in a compact
|
|
7
|
+
terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
|
|
8
|
+
available, your API continues serving requests normally.
|
|
9
|
+
|
|
10
|
+
## Highlights
|
|
11
|
+
|
|
12
|
+
- ⚡ Live requests-per-second and latency sparklines
|
|
13
|
+
- 🧭 Per-route request, failure, status, and response-time statistics
|
|
14
|
+
- 🖥️ CPU and memory usage across multiple application workers
|
|
15
|
+
- 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
|
|
16
|
+
- 🛡️ Short-timeout event delivery that never takes down the application
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
Install the runtime dependencies from the repository root:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install fastapi uvicorn rich psutil
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Start the monitoring dashboard on port `8005`:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
python -m fastapi_calf.daemon --port 8005
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
In another terminal, start the included example API with four workers:
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
python -m uvicorn server.app:app --port 8000 --workers 4
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
|
|
39
|
+
dashboard updates in real time.
|
|
40
|
+
|
|
41
|
+
## Instrumenting an API
|
|
42
|
+
|
|
43
|
+
Configure the monitor once, then decorate the routes you want to observe:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from fastapi import FastAPI, Request
|
|
47
|
+
from fastapi_calf import Calf, lookout
|
|
48
|
+
|
|
49
|
+
app = FastAPI()
|
|
50
|
+
Calf.listen(port=8005)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.get("/health")
|
|
54
|
+
@lookout
|
|
55
|
+
def health(request: Request):
|
|
56
|
+
return {"status": "healthy"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.post("/jobs")
|
|
60
|
+
@lookout
|
|
61
|
+
async def create_job(request: Request):
|
|
62
|
+
payload = await request.json()
|
|
63
|
+
return {"accepted": True, "job": payload}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The `Request` parameter allows `@lookout` to capture the method, path, query
|
|
67
|
+
parameters, request size, status, and timing without changing the response.
|
|
68
|
+
|
|
69
|
+
## Tests and traffic simulation
|
|
70
|
+
|
|
71
|
+
Install the testing tools and run the correctness suite:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
python -m pip install -r requirements-test.txt
|
|
75
|
+
python -m pytest tests/test_api.py -q
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The load runner starts four Uvicorn workers, verifies every sample endpoint,
|
|
79
|
+
and pushes **100 concurrent API calls** in a short burst:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
python -m tests.load_test
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Customize a run with `--requests`, `--concurrency`, and `--workers`. See
|
|
86
|
+
[TESTING.md](TESTING.md) for additional examples.
|
|
87
|
+
|
|
88
|
+
## Project layout
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
fastapi_calf/ monitoring library and terminal dashboard
|
|
92
|
+
server/ instrumented example FastAPI application
|
|
93
|
+
tests/ endpoint and concurrent load tests
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
|
|
97
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from .config import config
|
|
5
|
+
from .emitter import emit_event
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Calf:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def listen(app, port: int, host: str = "127.0.0.1"):
|
|
11
|
+
config.host = host
|
|
12
|
+
config.port = port
|
|
13
|
+
|
|
14
|
+
cuda_devices = os.getenv("CUDA_VISIBLE_DEVICES")
|
|
15
|
+
|
|
16
|
+
emit_event({
|
|
17
|
+
"pid": os.getpid(),
|
|
18
|
+
"cuda_visible_devices": cuda_devices,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
@app.middleware("http")
|
|
22
|
+
async def calf_middleware(request, call_next):
|
|
23
|
+
start = time.perf_counter()
|
|
24
|
+
|
|
25
|
+
status = 500 # defaults to failure
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
response = await call_next(request)
|
|
29
|
+
|
|
30
|
+
status = response.status_code
|
|
31
|
+
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
finally:
|
|
35
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
36
|
+
|
|
37
|
+
emit_event({
|
|
38
|
+
"pid": os.getpid(),
|
|
39
|
+
"cuda_visible_devices": cuda_devices,
|
|
40
|
+
"method": request.method,
|
|
41
|
+
"path": request.url.path,
|
|
42
|
+
"status": status,
|
|
43
|
+
"latency_ms": latency_ms,
|
|
44
|
+
"last_called": time.time(),
|
|
45
|
+
})
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from rich.live import Live
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.align import Align
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
|
|
12
|
+
from .system import register_process, get_process_stats
|
|
13
|
+
from .history import cpu_history, latency_history, rps_history
|
|
14
|
+
from .metrics import record_request, get_rps, get_window_latency
|
|
15
|
+
from .sparkline import sparkline
|
|
16
|
+
from .cleanup import cleanup_workers
|
|
17
|
+
|
|
18
|
+
workers = {
|
|
19
|
+
# pid: {
|
|
20
|
+
# "requests": 0,
|
|
21
|
+
# "cuda_visible_devices": None,
|
|
22
|
+
# }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
stats = {}
|
|
26
|
+
|
|
27
|
+
start_time = time.time()
|
|
28
|
+
|
|
29
|
+
last_history_sample = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def process_event(event):
|
|
33
|
+
pid = event.get("pid")
|
|
34
|
+
|
|
35
|
+
if pid is not None:
|
|
36
|
+
if pid not in workers:
|
|
37
|
+
register_process(pid)
|
|
38
|
+
|
|
39
|
+
workers[pid] = {
|
|
40
|
+
"requests": 0,
|
|
41
|
+
"cuda_visible_devices": event.get("cuda_visible_devices"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Update metadata if supplied again
|
|
45
|
+
if "cuda_visible_devices" in event:
|
|
46
|
+
workers[pid]["cuda_visible_devices"] = event["cuda_visible_devices"]
|
|
47
|
+
|
|
48
|
+
# This was only a process/system event
|
|
49
|
+
if "method" not in event or "path" not in event:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if pid is not None:
|
|
53
|
+
workers[pid]["requests"] += 1
|
|
54
|
+
|
|
55
|
+
key = (event["method"], event["path"])
|
|
56
|
+
|
|
57
|
+
record_request(event["latency_ms"])
|
|
58
|
+
|
|
59
|
+
if key not in stats:
|
|
60
|
+
stats[key] = {
|
|
61
|
+
"method": event["method"],
|
|
62
|
+
"path": event["path"],
|
|
63
|
+
"requests": 0,
|
|
64
|
+
"failed": 0,
|
|
65
|
+
"total_latency": 0,
|
|
66
|
+
"last_latency": 0,
|
|
67
|
+
"total_bytes": 0,
|
|
68
|
+
"last_called": 0,
|
|
69
|
+
"last_status": 200,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
item = stats[key]
|
|
73
|
+
|
|
74
|
+
item["requests"] += 1
|
|
75
|
+
item["total_latency"] += event["latency_ms"]
|
|
76
|
+
item["last_latency"] = event["latency_ms"]
|
|
77
|
+
item["last_called"] = time.time()
|
|
78
|
+
item["last_status"] = event["status"]
|
|
79
|
+
#
|
|
80
|
+
# NOTE: Disabled due to future changes &
|
|
81
|
+
# due to removal of `request: Request`
|
|
82
|
+
# item["total_bytes"] += event["request_size"]
|
|
83
|
+
#
|
|
84
|
+
item["total_bytes"] += event.get("request_size", 0)
|
|
85
|
+
|
|
86
|
+
# Count failed requests
|
|
87
|
+
if event["status"] >= 400:
|
|
88
|
+
item["failed"] += 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sample_history(process_stats):
|
|
92
|
+
global last_history_sample
|
|
93
|
+
|
|
94
|
+
now = time.time()
|
|
95
|
+
|
|
96
|
+
if now - last_history_sample < 1:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if process_stats:
|
|
100
|
+
cpu_history.append(process_stats["cpu"])
|
|
101
|
+
|
|
102
|
+
latency_history.append(get_window_latency())
|
|
103
|
+
|
|
104
|
+
rps_history.append(get_rps())
|
|
105
|
+
|
|
106
|
+
last_history_sample = now
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def latest(values, default=0):
|
|
110
|
+
return values[-1] if values else default
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_history_panel():
|
|
114
|
+
return Group(
|
|
115
|
+
Text(
|
|
116
|
+
f"CPU "
|
|
117
|
+
f"{latest(cpu_history):5.1f}% "
|
|
118
|
+
f"{sparkline(cpu_history, 0, 100)}"
|
|
119
|
+
),
|
|
120
|
+
Text(
|
|
121
|
+
f"Latency "
|
|
122
|
+
f"{latest(latency_history):5.1f}ms "
|
|
123
|
+
f"{sparkline(latency_history)}"
|
|
124
|
+
),
|
|
125
|
+
Text(
|
|
126
|
+
f"RPS "
|
|
127
|
+
f"{latest(rps_history):5.1f} "
|
|
128
|
+
f"{sparkline(rps_history)}"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_total_process_stats():
|
|
134
|
+
total_cpu = 0
|
|
135
|
+
total_ram = 0
|
|
136
|
+
count = 0
|
|
137
|
+
|
|
138
|
+
for pid in workers:
|
|
139
|
+
process_stats = get_process_stats(pid)
|
|
140
|
+
|
|
141
|
+
if process_stats is None:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
total_cpu += process_stats["cpu"]
|
|
145
|
+
total_ram += process_stats["ram_mb"]
|
|
146
|
+
count += 1
|
|
147
|
+
|
|
148
|
+
if count == 0:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"cpu": total_cpu,
|
|
153
|
+
"ram_mb": total_ram,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_table():
|
|
158
|
+
table = Table(expand=True)
|
|
159
|
+
|
|
160
|
+
table.add_column("Endpoint")
|
|
161
|
+
table.add_column("Method")
|
|
162
|
+
table.add_column("Activity")
|
|
163
|
+
table.add_column("Requests", justify="right")
|
|
164
|
+
table.add_column("Failed", justify="right")
|
|
165
|
+
table.add_column("Avg latency", justify="right")
|
|
166
|
+
table.add_column("Last", justify="right")
|
|
167
|
+
table.add_column("Status", justify="right")
|
|
168
|
+
|
|
169
|
+
now = time.time()
|
|
170
|
+
|
|
171
|
+
for item in stats.values():
|
|
172
|
+
avg_latency = (item["total_latency"] / item["requests"] if item["requests"] > 0 else 0)
|
|
173
|
+
|
|
174
|
+
active = ("● ● ●" if now - item["last_called"] < 0.5 else "")
|
|
175
|
+
|
|
176
|
+
failed_display = (f"[red]{item['failed']}[/red]" if item["failed"] > 0 else "0")
|
|
177
|
+
|
|
178
|
+
style = ("bold red" if item["last_status"] >= 400 else None)
|
|
179
|
+
|
|
180
|
+
table.add_row(
|
|
181
|
+
item["path"],
|
|
182
|
+
item["method"],
|
|
183
|
+
active,
|
|
184
|
+
str(item["requests"]),
|
|
185
|
+
failed_display,
|
|
186
|
+
f"{avg_latency:.2f} ms",
|
|
187
|
+
f"{item['last_latency']:.2f} ms",
|
|
188
|
+
str(item["last_status"]),
|
|
189
|
+
style=style,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
process_stats = get_total_process_stats()
|
|
193
|
+
|
|
194
|
+
if process_stats:
|
|
195
|
+
system_text = (
|
|
196
|
+
f"CPU {process_stats['cpu']:.0f}%"
|
|
197
|
+
f" Memory {process_stats['ram_mb']:.0f} MB"
|
|
198
|
+
f" Workers {len(workers)}"
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
system_text = "CPU -- Memory -- Workers --"
|
|
202
|
+
|
|
203
|
+
sample_history(process_stats)
|
|
204
|
+
|
|
205
|
+
workers_table = create_workers_table()
|
|
206
|
+
|
|
207
|
+
return Group(
|
|
208
|
+
Align.center(Text("🐮 fastapi-calf 🐮", style="bold")),
|
|
209
|
+
Align.center(Text(system_text)),
|
|
210
|
+
Text(""),
|
|
211
|
+
build_history_panel(),
|
|
212
|
+
Text(""),
|
|
213
|
+
workers_table,
|
|
214
|
+
Text(""),
|
|
215
|
+
table,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def create_workers_table():
|
|
220
|
+
table = Table(title="Workers", expand=True)
|
|
221
|
+
|
|
222
|
+
table.add_column("PID")
|
|
223
|
+
table.add_column("CPU", justify="right")
|
|
224
|
+
table.add_column("Memory", justify="right")
|
|
225
|
+
table.add_column("Requests", justify="right")
|
|
226
|
+
table.add_column("GPU", justify="right")
|
|
227
|
+
|
|
228
|
+
dead_workers = []
|
|
229
|
+
|
|
230
|
+
now = time.time()
|
|
231
|
+
workers_to_remove = []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
for pid, worker in workers.items():
|
|
235
|
+
process_stats = get_process_stats(pid)
|
|
236
|
+
|
|
237
|
+
if process_stats is None:
|
|
238
|
+
if worker["dead_since"] is None:
|
|
239
|
+
worker["dead_since"] = now
|
|
240
|
+
|
|
241
|
+
# Remove only after 5 seconds
|
|
242
|
+
if now - worker["dead_since"] >= 5:
|
|
243
|
+
workers_to_remove.append(pid)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
table.add_row(
|
|
247
|
+
str(pid),
|
|
248
|
+
"--",
|
|
249
|
+
"--",
|
|
250
|
+
str(worker["requests"]),
|
|
251
|
+
"none"
|
|
252
|
+
if worker["cuda_visible_devices"] is None
|
|
253
|
+
else str(worker["cuda_visible_devices"]),
|
|
254
|
+
style="dim",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
worker["dead_since"] = None
|
|
260
|
+
|
|
261
|
+
table.add_row(
|
|
262
|
+
str(pid),
|
|
263
|
+
f"{process_stats['cpu']:.1f}%",
|
|
264
|
+
f"{process_stats['ram_mb']:.1f} MB",
|
|
265
|
+
str(worker["requests"]),
|
|
266
|
+
"none"
|
|
267
|
+
if worker["cuda_visible_devices"] is None
|
|
268
|
+
else str(worker["cuda_visible_devices"]),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
for pid in dead_workers:
|
|
272
|
+
workers.pop(pid, None)
|
|
273
|
+
|
|
274
|
+
return table
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def handle_client(reader, writer):
|
|
278
|
+
data = await reader.readline()
|
|
279
|
+
|
|
280
|
+
if data:
|
|
281
|
+
try:
|
|
282
|
+
event = json.loads(data)
|
|
283
|
+
process_event(event)
|
|
284
|
+
except json.JSONDecodeError:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
writer.close()
|
|
288
|
+
await writer.wait_closed()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def run_server(port: int):
|
|
292
|
+
server = await asyncio.start_server(handle_client, "127.0.0.1", port)
|
|
293
|
+
async with server:
|
|
294
|
+
await server.serve_forever()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def run_ui():
|
|
298
|
+
with Live(create_table(), refresh_per_second=10, screen=True) as live:
|
|
299
|
+
while True:
|
|
300
|
+
# NOTE: ---------------------------------------------------------------------------------------
|
|
301
|
+
# When uncommented, cleanup_workers will instantly remove the dead workers off the table
|
|
302
|
+
# During testing if u wanna observe dead workers, comment below line `cleanup_workers(workers)`
|
|
303
|
+
cleanup_workers(workers)
|
|
304
|
+
# ---------------------------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
live.update(create_table())
|
|
307
|
+
await asyncio.sleep(0.1)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def run(port: int):
|
|
311
|
+
await asyncio.gather(run_server(port), run_ui())
|
|
312
|
+
|
|
313
|
+
def main():
|
|
314
|
+
try:
|
|
315
|
+
parser = argparse.ArgumentParser()
|
|
316
|
+
parser.add_argument("-p", "--port", type=int, default=8765, help="Port for fastapi-calf")
|
|
317
|
+
|
|
318
|
+
args = parser.parse_args()
|
|
319
|
+
|
|
320
|
+
asyncio.run(run(args.port))
|
|
321
|
+
|
|
322
|
+
except KeyboardInterrupt:
|
|
323
|
+
print("fastapi-calf stopped.")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
from .emitter import emit_event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def lookout(func):
|
|
12
|
+
@wraps(func)
|
|
13
|
+
async def wrapper(*args, **kwargs):
|
|
14
|
+
request = kwargs.get("request")
|
|
15
|
+
|
|
16
|
+
start_timestamp = datetime.now(timezone.utc)
|
|
17
|
+
start_timer = time.perf_counter()
|
|
18
|
+
|
|
19
|
+
status_code = 200
|
|
20
|
+
error = None
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
if inspect.iscoroutinefunction(func):
|
|
24
|
+
response = await func(*args, **kwargs)
|
|
25
|
+
else:
|
|
26
|
+
response = func(*args, **kwargs)
|
|
27
|
+
return response
|
|
28
|
+
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
status_code = 500
|
|
31
|
+
error = str(exc)
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
finally:
|
|
35
|
+
end_timer = time.perf_counter()
|
|
36
|
+
latency_ms = (end_timer - start_timer) * 1000
|
|
37
|
+
|
|
38
|
+
if request:
|
|
39
|
+
body = await request.body()
|
|
40
|
+
|
|
41
|
+
event = {
|
|
42
|
+
"timestamp": start_timestamp.isoformat(),
|
|
43
|
+
"function": func.__name__,
|
|
44
|
+
"method": request.method,
|
|
45
|
+
"path": request.url.path,
|
|
46
|
+
"query": dict(request.query_params),
|
|
47
|
+
"request_size": len(body),
|
|
48
|
+
"latency_ms": latency_ms,
|
|
49
|
+
"status": status_code,
|
|
50
|
+
"error": error,
|
|
51
|
+
"pid": os.getpid(),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
emit_event(event)
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
from .config import config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def emit_event(event: dict):
|
|
8
|
+
try:
|
|
9
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
10
|
+
sock.settimeout(0.05)
|
|
11
|
+
|
|
12
|
+
sock.connect((config.host, config.port))
|
|
13
|
+
|
|
14
|
+
payload = json.dumps(event).encode("utf-8") + b"\n"
|
|
15
|
+
|
|
16
|
+
sock.sendall(payload)
|
|
17
|
+
|
|
18
|
+
except (ConnectionRefusedError, TimeoutError, OSError) as error:
|
|
19
|
+
# Monitoring should NEVER crash the API
|
|
20
|
+
print("Calf emitter error:", error)
|
|
21
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .history import cpu_history, latency_history, rps_history
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .metrics import record_request, get_rps, get_window_latency
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import deque
|
|
3
|
+
|
|
4
|
+
from ..history.history import request_times
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
recent_latencies = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def record_request(latency_ms):
|
|
11
|
+
request_times.append(time.time())
|
|
12
|
+
recent_latencies.append(latency_ms)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_rps():
|
|
16
|
+
now = time.time()
|
|
17
|
+
|
|
18
|
+
while request_times and request_times[0] < now - 1:
|
|
19
|
+
request_times.popleft()
|
|
20
|
+
|
|
21
|
+
return len(request_times)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_window_latency():
|
|
25
|
+
if not recent_latencies:
|
|
26
|
+
return 0.0
|
|
27
|
+
|
|
28
|
+
avg = sum(recent_latencies) / len(recent_latencies)
|
|
29
|
+
|
|
30
|
+
recent_latencies.clear()
|
|
31
|
+
|
|
32
|
+
return avg
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .sparkline import sparkline
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
SPARK_CHARS = "▁▂▃▄▅▆▇█"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def sparkline(values, minimum=None, maximum=None):
|
|
5
|
+
if not values:
|
|
6
|
+
return ""
|
|
7
|
+
|
|
8
|
+
min_value = minimum if minimum is not None else min(values)
|
|
9
|
+
max_value = maximum if maximum is not None else max(values)
|
|
10
|
+
|
|
11
|
+
if max_value == min_value:
|
|
12
|
+
return SPARK_CHARS[0] * len(values)
|
|
13
|
+
|
|
14
|
+
result = []
|
|
15
|
+
|
|
16
|
+
for value in values:
|
|
17
|
+
value = max(min_value, min(value, max_value))
|
|
18
|
+
|
|
19
|
+
normalized = (value - min_value) / (max_value - min_value)
|
|
20
|
+
|
|
21
|
+
index = int(normalized * (len(SPARK_CHARS) - 1))
|
|
22
|
+
|
|
23
|
+
result.append(SPARK_CHARS[index])
|
|
24
|
+
|
|
25
|
+
return "".join(result)
|
|
26
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .cpu import register_process, get_process_stats
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import psutil
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_processes = {}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_process(pid: int):
|
|
8
|
+
if pid in _processes:
|
|
9
|
+
return
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
process = psutil.Process(pid)
|
|
13
|
+
|
|
14
|
+
# Prime cpu_percent
|
|
15
|
+
process.cpu_percent(interval=None)
|
|
16
|
+
|
|
17
|
+
_processes[pid] = process
|
|
18
|
+
|
|
19
|
+
except psutil.NoSuchProcess:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_process_stats(pid: int):
|
|
24
|
+
process = _processes.get(pid)
|
|
25
|
+
|
|
26
|
+
if process is None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return {
|
|
31
|
+
"cpu": process.cpu_percent(interval=None),
|
|
32
|
+
"ram_mb": process.memory_info().rss / 1024 / 1024,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
except psutil.NoSuchProcess:
|
|
36
|
+
_processes.pop(pid, None)
|
|
37
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-calf
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A lightweight real-time terminal monitor for FastAPI servers
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: rich
|
|
8
|
+
Requires-Dist: psutil
|
|
9
|
+
|
|
10
|
+
# 🐮 fastapi-calf
|
|
11
|
+
|
|
12
|
+
**A lightweight, real-time observability for FastAPI.**
|
|
13
|
+
|
|
14
|
+
`fastapi-calf` watches decorated API routes and presents request activity,
|
|
15
|
+
latency, throughput, failures, CPU, memory, and worker information in a compact
|
|
16
|
+
terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
|
|
17
|
+
available, your API continues serving requests normally.
|
|
18
|
+
|
|
19
|
+
## Highlights
|
|
20
|
+
|
|
21
|
+
- ⚡ Live requests-per-second and latency sparklines
|
|
22
|
+
- 🧭 Per-route request, failure, status, and response-time statistics
|
|
23
|
+
- 🖥️ CPU and memory usage across multiple application workers
|
|
24
|
+
- 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
|
|
25
|
+
- 🛡️ Short-timeout event delivery that never takes down the application
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Install the runtime dependencies from the repository root:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
python -m pip install fastapi uvicorn rich psutil
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Start the monitoring dashboard on port `8005`:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python -m fastapi_calf.daemon --port 8005
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In another terminal, start the included example API with four workers:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
python -m uvicorn server.app:app --port 8000 --workers 4
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
|
|
48
|
+
dashboard updates in real time.
|
|
49
|
+
|
|
50
|
+
## Instrumenting an API
|
|
51
|
+
|
|
52
|
+
Configure the monitor once, then decorate the routes you want to observe:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import FastAPI, Request
|
|
56
|
+
from fastapi_calf import Calf, lookout
|
|
57
|
+
|
|
58
|
+
app = FastAPI()
|
|
59
|
+
Calf.listen(port=8005)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/health")
|
|
63
|
+
@lookout
|
|
64
|
+
def health(request: Request):
|
|
65
|
+
return {"status": "healthy"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.post("/jobs")
|
|
69
|
+
@lookout
|
|
70
|
+
async def create_job(request: Request):
|
|
71
|
+
payload = await request.json()
|
|
72
|
+
return {"accepted": True, "job": payload}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `Request` parameter allows `@lookout` to capture the method, path, query
|
|
76
|
+
parameters, request size, status, and timing without changing the response.
|
|
77
|
+
|
|
78
|
+
## Tests and traffic simulation
|
|
79
|
+
|
|
80
|
+
Install the testing tools and run the correctness suite:
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
python -m pip install -r requirements-test.txt
|
|
84
|
+
python -m pytest tests/test_api.py -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The load runner starts four Uvicorn workers, verifies every sample endpoint,
|
|
88
|
+
and pushes **100 concurrent API calls** in a short burst:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
python -m tests.load_test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Customize a run with `--requests`, `--concurrency`, and `--workers`. See
|
|
95
|
+
[TESTING.md](TESTING.md) for additional examples.
|
|
96
|
+
|
|
97
|
+
## Project layout
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
fastapi_calf/ monitoring library and terminal dashboard
|
|
101
|
+
server/ instrumented example FastAPI application
|
|
102
|
+
tests/ endpoint and concurrent load tests
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
|
|
106
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
fastapi_calf/__init__.py
|
|
4
|
+
fastapi_calf/calf.py
|
|
5
|
+
fastapi_calf/cleanup.py
|
|
6
|
+
fastapi_calf/config.py
|
|
7
|
+
fastapi_calf/daemon.py
|
|
8
|
+
fastapi_calf/decorator.py
|
|
9
|
+
fastapi_calf/emitter.py
|
|
10
|
+
fastapi_calf.egg-info/PKG-INFO
|
|
11
|
+
fastapi_calf.egg-info/SOURCES.txt
|
|
12
|
+
fastapi_calf.egg-info/dependency_links.txt
|
|
13
|
+
fastapi_calf.egg-info/entry_points.txt
|
|
14
|
+
fastapi_calf.egg-info/requires.txt
|
|
15
|
+
fastapi_calf.egg-info/top_level.txt
|
|
16
|
+
fastapi_calf/history/__init__.py
|
|
17
|
+
fastapi_calf/history/history.py
|
|
18
|
+
fastapi_calf/metrics/__init__.py
|
|
19
|
+
fastapi_calf/metrics/metrics.py
|
|
20
|
+
fastapi_calf/sparkline/__init__.py
|
|
21
|
+
fastapi_calf/sparkline/sparkline.py
|
|
22
|
+
fastapi_calf/system/__init__.py
|
|
23
|
+
fastapi_calf/system/cpu.py
|
|
24
|
+
fastapi_calf/system/gpu.py
|
|
25
|
+
tests/test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_calf
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastapi-calf"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A lightweight real-time terminal monitor for FastAPI servers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
"rich",
|
|
14
|
+
"psutil",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
calf = "fastapi_calf.daemon:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["fastapi_calf*"]
|
|
File without changes
|