fastapi-calf 0.1.2__tar.gz → 0.2.0__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.
Files changed (42) hide show
  1. fastapi_calf-0.2.0/PKG-INFO +196 -0
  2. fastapi_calf-0.2.0/README.md +185 -0
  3. fastapi_calf-0.2.0/fastapi_calf/autostart.py +101 -0
  4. fastapi_calf-0.2.0/fastapi_calf/blocking.py +126 -0
  5. fastapi_calf-0.2.0/fastapi_calf/calf.py +70 -0
  6. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/config.py +4 -3
  7. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/daemon.py +215 -43
  8. fastapi_calf-0.2.0/fastapi_calf/dashboard.py +201 -0
  9. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/decorator.py +9 -7
  10. fastapi_calf-0.2.0/fastapi_calf/emitter.py +38 -0
  11. fastapi_calf-0.2.0/fastapi_calf/lifecycle.py +231 -0
  12. fastapi_calf-0.2.0/fastapi_calf.egg-info/PKG-INFO +196 -0
  13. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/SOURCES.txt +9 -1
  14. fastapi_calf-0.2.0/fastapi_calf.egg-info/requires.txt +4 -0
  15. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/pyproject.toml +23 -21
  16. fastapi_calf-0.2.0/tests/test_api.py +41 -0
  17. fastapi_calf-0.2.0/tests/test_autostart.py +32 -0
  18. fastapi_calf-0.2.0/tests/test_blocking.py +74 -0
  19. fastapi_calf-0.2.0/tests/test_dashboard.py +95 -0
  20. fastapi_calf-0.2.0/tests/test_lifecycle.py +94 -0
  21. fastapi_calf-0.1.2/PKG-INFO +0 -234
  22. fastapi_calf-0.1.2/README.md +0 -225
  23. fastapi_calf-0.1.2/fastapi_calf/calf.py +0 -45
  24. fastapi_calf-0.1.2/fastapi_calf/emitter.py +0 -21
  25. fastapi_calf-0.1.2/fastapi_calf.egg-info/PKG-INFO +0 -234
  26. fastapi_calf-0.1.2/fastapi_calf.egg-info/requires.txt +0 -2
  27. fastapi_calf-0.1.2/tests/test.py +0 -0
  28. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/__init__.py +0 -0
  29. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/cleanup.py +0 -0
  30. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/history/__init__.py +0 -0
  31. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/history/history.py +0 -0
  32. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/metrics/__init__.py +0 -0
  33. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/metrics/metrics.py +0 -0
  34. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/sparkline/__init__.py +0 -0
  35. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/sparkline/sparkline.py +0 -0
  36. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/__init__.py +0 -0
  37. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/cpu.py +0 -0
  38. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf/system/gpu.py +0 -0
  39. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/dependency_links.txt +0 -0
  40. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/entry_points.txt +0 -0
  41. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/fastapi_calf.egg-info/top_level.txt +0 -0
  42. {fastapi_calf-0.1.2 → fastapi_calf-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-calf
3
+ Version: 0.2.0
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: fastapi
8
+ Requires-Dist: rich
9
+ Requires-Dist: psutil
10
+ Requires-Dist: uvicorn
11
+
12
+ # 🐮 fastapi-calf
13
+
14
+ **A lightweight, real-time observability for FastAPI.**
15
+
16
+ `fastapi-calf` watches decorated API routes and presents request activity,
17
+ latency, throughput, failures, CPU, memory, and worker information in a compact
18
+ terminal UI. Monitoring is intentionally best-effort: if the dashboard is not
19
+ available, your API continues serving requests normally.
20
+
21
+ ## Highlights
22
+
23
+ - ⚡ Live requests-per-second and latency sparklines
24
+ - 🧭 Per-route request, failure, status, and response-time statistics
25
+ - 🖥️ CPU and memory usage across multiple application workers
26
+ - 🎮 `CUDA_VISIBLE_DEVICES` reporting for GPU-aware deployments
27
+ - 🛡️ Short-timeout event delivery that never takes down the application
28
+
29
+ ## Quick start
30
+
31
+ Install the runtime dependencies from the repository root:
32
+
33
+ ```powershell
34
+ python -m pip install fastapi uvicorn rich psutil
35
+ ```
36
+
37
+ Start the included example API with four workers:
38
+
39
+ ```powershell
40
+ python -m uvicorn server.app:app --port 8000 --workers 4
41
+ ```
42
+
43
+ `Calf.listen()` detects whether its collector is running on port `8765`. If it
44
+ is missing, the first application worker starts it automatically; the other
45
+ workers reuse it. The auto-started collector exits after the application
46
+ workers stop.
47
+
48
+ The `port` passed to `Calf.listen()` is the Calf collector/dashboard port. The
49
+ FastAPI application port remains whatever you pass to Uvicorn (`8000` above).
50
+
51
+ Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
52
+ dashboard updates in real time.
53
+
54
+ Open `http://127.0.0.1:8000/calf` to redirect to the aggregated dashboard at
55
+ `http://127.0.0.1:8765/`. It refreshes once per second and combines route
56
+ traffic, failures, latency, event-loop stalls, and lifecycle timing from every
57
+ Uvicorn worker.
58
+
59
+ The automatically started collector serves the web dashboard without a terminal
60
+ UI. To use the terminal view, start the collector explicitly before Uvicorn:
61
+
62
+ ```powershell
63
+ python -m fastapi_calf.daemon --port 8765
64
+ ```
65
+
66
+ The web dashboard is enabled by default and can be moved or disabled:
67
+
68
+ ```python
69
+ Calf.listen(app, port=8765, server="jobs_api", dashboard_path="/observability")
70
+ # Or: Calf.listen(..., dashboard=False)
71
+ # Or manage the collector yourself: Calf.listen(..., auto_start_collector=False)
72
+ ```
73
+
74
+ Ignore health checks, metrics collectors, or other noisy paths with exact paths
75
+ or shell-style wildcards. Calf ignores `/favicon.ico` and its own dashboard
76
+ routes automatically.
77
+
78
+ ```python
79
+ Calf.listen(
80
+ app,
81
+ port=8005,
82
+ server="jobs_api",
83
+ ignore_paths=("/health", "/ready", "/metrics", "/internal/*"),
84
+ )
85
+ ```
86
+
87
+ Workers send best-effort HTTP events to the collector's `/api/events` endpoint.
88
+ The collector exposes aggregate JSON at `/api/stats`; both the web and terminal
89
+ views read the same in-memory process-wide aggregation.
90
+
91
+ ## Instrumenting an API
92
+
93
+ Configure the monitor once, then decorate the routes you want to observe:
94
+
95
+ ```python
96
+ from fastapi import FastAPI, Request
97
+ from fastapi_calf import Calf, lookout
98
+
99
+ app = FastAPI()
100
+ Calf.listen(app, port=8005, server="jobs_api")
101
+
102
+
103
+ @app.get("/health")
104
+ @lookout
105
+ def health(request: Request):
106
+ return {"status": "healthy"}
107
+
108
+
109
+ @app.post("/jobs")
110
+ @lookout
111
+ async def create_job(request: Request):
112
+ payload = await request.json()
113
+ return {"accepted": True, "job": payload}
114
+ ```
115
+
116
+ The `Request` parameter allows `@lookout` to capture the method, path, query
117
+ parameters, request size, status, and timing without changing the response.
118
+ Metrics are kept in memory by the live dashboard and reset when it stops.
119
+
120
+ ### Event-loop blocking detection
121
+
122
+ Calf watches each worker's asyncio event loop and warns when it cannot run for
123
+ more than 100 ms. This catches blocking calls such as `time.sleep()`, synchronous
124
+ HTTP clients, filesystem operations, and CPU-heavy work used inside `async def`
125
+ endpoints. Overlapping requests are shown as affected rather than definitively
126
+ identified as the cause.
127
+
128
+ Tune or disable the detector when configuring the application:
129
+
130
+ ```python
131
+ Calf.listen(
132
+ app,
133
+ port=8005,
134
+ server="jobs_api",
135
+ detect_blocking=True,
136
+ block_threshold=100,
137
+ block_check_interval=50,
138
+ )
139
+ ```
140
+
141
+ Both values are milliseconds. `block_check_interval` controls how often the
142
+ watchdog asks the event loop to run. If it wakes at least `block_threshold`
143
+ milliseconds later than expected, Calf records a blocking event. A smaller
144
+ check interval detects stalls more precisely but wakes the watchdog more often.
145
+
146
+ The example server exposes `/block-event-loop` as an intentional blocking case
147
+ and `/cooperative-sleep` as its non-blocking comparison.
148
+
149
+ ### Request lifecycle timing
150
+
151
+ Every HTTP event includes a `lifecycle` breakdown in milliseconds for request
152
+ body parsing, dependency injection and validation, endpoint execution, response
153
+ serialization, middleware/framework overhead, and background tasks. The live
154
+ dashboard shows the most recent breakdown for each route.
155
+
156
+ `middleware` is the residual request time after the directly measured phases;
157
+ it includes routing, middleware work, response construction, and response
158
+ transport. Streaming generator work is also included there because it occurs
159
+ after the endpoint has returned its iterator.
160
+
161
+ Try all lifecycle phases with the example endpoint:
162
+
163
+ ```powershell
164
+ curl -X POST http://127.0.0.1:8000/lifecycle `
165
+ -H "Content-Type: application/json" `
166
+ -d '{"message":"hello"}'
167
+ ```
168
+
169
+ ## Tests and traffic simulation
170
+
171
+ Install the testing tools and run the correctness suite:
172
+
173
+ ```powershell
174
+ python -m pip install -r requirements-test.txt
175
+ python -m pytest tests/test_api.py -q
176
+ ```
177
+
178
+ The load runner starts four Uvicorn workers, verifies every sample endpoint,
179
+ and pushes **100 concurrent API calls** in a short burst:
180
+
181
+ ```powershell
182
+ python -m tests.load_test
183
+ ```
184
+
185
+ Customize a run with `--requests`, `--concurrency`, and `--workers`. See
186
+ [TESTING.md](TESTING.md) for additional examples.
187
+
188
+ ## Project layout
189
+
190
+ ```text
191
+ fastapi_calf/ monitoring library and terminal dashboard
192
+ server/ instrumented example FastAPI application
193
+ tests/ endpoint and concurrent load tests
194
+ ```
195
+
196
+ > **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
@@ -0,0 +1,185 @@
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 included example API with four workers:
27
+
28
+ ```powershell
29
+ python -m uvicorn server.app:app --port 8000 --workers 4
30
+ ```
31
+
32
+ `Calf.listen()` detects whether its collector is running on port `8765`. If it
33
+ is missing, the first application worker starts it automatically; the other
34
+ workers reuse it. The auto-started collector exits after the application
35
+ workers stop.
36
+
37
+ The `port` passed to `Calf.listen()` is the Calf collector/dashboard port. The
38
+ FastAPI application port remains whatever you pass to Uvicorn (`8000` above).
39
+
40
+ Visit `http://127.0.0.1:8000/docs` to explore the API while the terminal
41
+ dashboard updates in real time.
42
+
43
+ Open `http://127.0.0.1:8000/calf` to redirect to the aggregated dashboard at
44
+ `http://127.0.0.1:8765/`. It refreshes once per second and combines route
45
+ traffic, failures, latency, event-loop stalls, and lifecycle timing from every
46
+ Uvicorn worker.
47
+
48
+ The automatically started collector serves the web dashboard without a terminal
49
+ UI. To use the terminal view, start the collector explicitly before Uvicorn:
50
+
51
+ ```powershell
52
+ python -m fastapi_calf.daemon --port 8765
53
+ ```
54
+
55
+ The web dashboard is enabled by default and can be moved or disabled:
56
+
57
+ ```python
58
+ Calf.listen(app, port=8765, server="jobs_api", dashboard_path="/observability")
59
+ # Or: Calf.listen(..., dashboard=False)
60
+ # Or manage the collector yourself: Calf.listen(..., auto_start_collector=False)
61
+ ```
62
+
63
+ Ignore health checks, metrics collectors, or other noisy paths with exact paths
64
+ or shell-style wildcards. Calf ignores `/favicon.ico` and its own dashboard
65
+ routes automatically.
66
+
67
+ ```python
68
+ Calf.listen(
69
+ app,
70
+ port=8005,
71
+ server="jobs_api",
72
+ ignore_paths=("/health", "/ready", "/metrics", "/internal/*"),
73
+ )
74
+ ```
75
+
76
+ Workers send best-effort HTTP events to the collector's `/api/events` endpoint.
77
+ The collector exposes aggregate JSON at `/api/stats`; both the web and terminal
78
+ views read the same in-memory process-wide aggregation.
79
+
80
+ ## Instrumenting an API
81
+
82
+ Configure the monitor once, then decorate the routes you want to observe:
83
+
84
+ ```python
85
+ from fastapi import FastAPI, Request
86
+ from fastapi_calf import Calf, lookout
87
+
88
+ app = FastAPI()
89
+ Calf.listen(app, port=8005, server="jobs_api")
90
+
91
+
92
+ @app.get("/health")
93
+ @lookout
94
+ def health(request: Request):
95
+ return {"status": "healthy"}
96
+
97
+
98
+ @app.post("/jobs")
99
+ @lookout
100
+ async def create_job(request: Request):
101
+ payload = await request.json()
102
+ return {"accepted": True, "job": payload}
103
+ ```
104
+
105
+ The `Request` parameter allows `@lookout` to capture the method, path, query
106
+ parameters, request size, status, and timing without changing the response.
107
+ Metrics are kept in memory by the live dashboard and reset when it stops.
108
+
109
+ ### Event-loop blocking detection
110
+
111
+ Calf watches each worker's asyncio event loop and warns when it cannot run for
112
+ more than 100 ms. This catches blocking calls such as `time.sleep()`, synchronous
113
+ HTTP clients, filesystem operations, and CPU-heavy work used inside `async def`
114
+ endpoints. Overlapping requests are shown as affected rather than definitively
115
+ identified as the cause.
116
+
117
+ Tune or disable the detector when configuring the application:
118
+
119
+ ```python
120
+ Calf.listen(
121
+ app,
122
+ port=8005,
123
+ server="jobs_api",
124
+ detect_blocking=True,
125
+ block_threshold=100,
126
+ block_check_interval=50,
127
+ )
128
+ ```
129
+
130
+ Both values are milliseconds. `block_check_interval` controls how often the
131
+ watchdog asks the event loop to run. If it wakes at least `block_threshold`
132
+ milliseconds later than expected, Calf records a blocking event. A smaller
133
+ check interval detects stalls more precisely but wakes the watchdog more often.
134
+
135
+ The example server exposes `/block-event-loop` as an intentional blocking case
136
+ and `/cooperative-sleep` as its non-blocking comparison.
137
+
138
+ ### Request lifecycle timing
139
+
140
+ Every HTTP event includes a `lifecycle` breakdown in milliseconds for request
141
+ body parsing, dependency injection and validation, endpoint execution, response
142
+ serialization, middleware/framework overhead, and background tasks. The live
143
+ dashboard shows the most recent breakdown for each route.
144
+
145
+ `middleware` is the residual request time after the directly measured phases;
146
+ it includes routing, middleware work, response construction, and response
147
+ transport. Streaming generator work is also included there because it occurs
148
+ after the endpoint has returned its iterator.
149
+
150
+ Try all lifecycle phases with the example endpoint:
151
+
152
+ ```powershell
153
+ curl -X POST http://127.0.0.1:8000/lifecycle `
154
+ -H "Content-Type: application/json" `
155
+ -d '{"message":"hello"}'
156
+ ```
157
+
158
+ ## Tests and traffic simulation
159
+
160
+ Install the testing tools and run the correctness suite:
161
+
162
+ ```powershell
163
+ python -m pip install -r requirements-test.txt
164
+ python -m pytest tests/test_api.py -q
165
+ ```
166
+
167
+ The load runner starts four Uvicorn workers, verifies every sample endpoint,
168
+ and pushes **100 concurrent API calls** in a short burst:
169
+
170
+ ```powershell
171
+ python -m tests.load_test
172
+ ```
173
+
174
+ Customize a run with `--requests`, `--concurrency`, and `--workers`. See
175
+ [TESTING.md](TESTING.md) for additional examples.
176
+
177
+ ## Project layout
178
+
179
+ ```text
180
+ fastapi_calf/ monitoring library and terminal dashboard
181
+ server/ instrumented example FastAPI application
182
+ tests/ endpoint and concurrent load tests
183
+ ```
184
+
185
+ > **Status:** This is an early-stage project intended for experimentations with ML pipelines and POCs.
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
13
+
14
+
15
+ def collector_is_running(host: str, port: int, timeout: float = 0.15) -> bool:
16
+ connection = None
17
+ try:
18
+ connection = http.client.HTTPConnection(host, port, timeout=timeout)
19
+ connection.request("GET", "/api/health")
20
+ response = connection.getresponse()
21
+ response.read()
22
+ return response.status == 200
23
+ except (OSError, TimeoutError, http.client.HTTPException):
24
+ return False
25
+ finally:
26
+ if connection is not None:
27
+ connection.close()
28
+
29
+
30
+ def _wait_for_collector(host: str, port: int, timeout: float = 4.0) -> bool:
31
+ deadline = time.monotonic() + timeout
32
+ while time.monotonic() < deadline:
33
+ if collector_is_running(host, port):
34
+ return True
35
+ time.sleep(0.05)
36
+ return False
37
+
38
+
39
+ def _spawn_collector(host: str, port: int) -> None:
40
+ command = [
41
+ sys.executable,
42
+ "-m",
43
+ "fastapi_calf.daemon",
44
+ "--host",
45
+ host,
46
+ "--port",
47
+ str(port),
48
+ "--no-terminal",
49
+ "--auto-started",
50
+ ]
51
+ options = {
52
+ "stdin": subprocess.DEVNULL,
53
+ "stdout": subprocess.DEVNULL,
54
+ "stderr": subprocess.DEVNULL,
55
+ "close_fds": True,
56
+ }
57
+ if os.name == "nt":
58
+ options["creationflags"] = (
59
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
60
+ )
61
+ else:
62
+ options["start_new_session"] = True
63
+ subprocess.Popen(command, **options)
64
+
65
+
66
+ def ensure_collector(host: str, port: int) -> bool:
67
+ """Start one local collector when none is already listening."""
68
+ if collector_is_running(host, port):
69
+ return True
70
+ if host not in LOCAL_HOSTS:
71
+ return False
72
+
73
+ lock_path = Path(tempfile.gettempdir()) / f"fastapi-calf-{port}.starting"
74
+ owns_lock = False
75
+ try:
76
+ try:
77
+ lock_path.mkdir()
78
+ owns_lock = True
79
+ except FileExistsError:
80
+ # Another Uvicorn worker is starting the collector.
81
+ if _wait_for_collector(host, port):
82
+ return True
83
+ # Recover a lock left behind by a worker that was force-killed.
84
+ try:
85
+ if time.time() - lock_path.stat().st_mtime > 4:
86
+ lock_path.rmdir()
87
+ return ensure_collector(host, port)
88
+ except OSError:
89
+ pass
90
+ return False
91
+
92
+ if collector_is_running(host, port):
93
+ return True
94
+ _spawn_collector(host, port)
95
+ return _wait_for_collector(host, port)
96
+ finally:
97
+ if owns_lock:
98
+ try:
99
+ lock_path.rmdir()
100
+ except OSError:
101
+ pass
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ import uuid
7
+ from collections import deque
8
+ from dataclasses import asdict, dataclass
9
+ from typing import Awaitable, Callable
10
+
11
+
12
+ @dataclass
13
+ class ActiveRequest:
14
+ request_id: str
15
+ method: str
16
+ path: str
17
+ started_at: float
18
+ task_name: str | None
19
+ ended_at: float | None = None
20
+
21
+
22
+ class BlockingDetector:
23
+ """Detect periods during which an asyncio event loop cannot run.
24
+
25
+ Event-loop lag proves that the worker was stalled, but when requests overlap
26
+ it cannot prove which request caused the stall. Events therefore contain all
27
+ requests that overlapped the blocked interval as ``affected_requests``.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ emit: Callable[[dict], Awaitable[None]],
33
+ *,
34
+ interval: float = 0.05,
35
+ threshold: float = 0.10,
36
+ ) -> None:
37
+ if interval <= 0:
38
+ raise ValueError("interval must be greater than zero")
39
+ if threshold <= 0:
40
+ raise ValueError("threshold must be greater than zero")
41
+
42
+ self.emit = emit
43
+ self.interval = interval
44
+ self.threshold = threshold
45
+ self.active: dict[str, ActiveRequest] = {}
46
+ self.recently_finished: deque[ActiveRequest] = deque()
47
+ self._task: asyncio.Task | None = None
48
+
49
+ def register(self, method: str, path: str) -> str:
50
+ request_id = uuid.uuid4().hex
51
+ task = asyncio.current_task()
52
+ self.active[request_id] = ActiveRequest(
53
+ request_id=request_id,
54
+ method=method,
55
+ path=path,
56
+ started_at=time.perf_counter(),
57
+ task_name=task.get_name() if task else None,
58
+ )
59
+ return request_id
60
+
61
+ def unregister(self, request_id: str) -> None:
62
+ request = self.active.pop(request_id, None)
63
+ if request is not None:
64
+ request.ended_at = time.perf_counter()
65
+ self.recently_finished.append(request)
66
+
67
+ def start(self) -> None:
68
+ if self._task is None or self._task.done():
69
+ self._task = asyncio.create_task(
70
+ self._watch(), name="fastapi-calf-loop-watchdog"
71
+ )
72
+
73
+ async def stop(self) -> None:
74
+ if self._task is None:
75
+ return
76
+
77
+ self._task.cancel()
78
+ try:
79
+ await self._task
80
+ except asyncio.CancelledError:
81
+ pass
82
+ self._task = None
83
+
84
+ def _requests_overlapping(self, blocked_since: float) -> list[dict]:
85
+ while (
86
+ self.recently_finished
87
+ and self.recently_finished[0].ended_at is not None
88
+ and self.recently_finished[0].ended_at < blocked_since
89
+ ):
90
+ self.recently_finished.popleft()
91
+
92
+ requests = [
93
+ request
94
+ for request in (*self.active.values(), *self.recently_finished)
95
+ if request.started_at <= time.perf_counter()
96
+ and (request.ended_at is None or request.ended_at >= blocked_since)
97
+ ]
98
+ return [asdict(request) for request in requests]
99
+
100
+ async def _watch(self) -> None:
101
+ loop = asyncio.get_running_loop()
102
+ expected = loop.time() + self.interval
103
+
104
+ while True:
105
+ await asyncio.sleep(self.interval)
106
+ now = loop.time()
107
+ lag = max(0.0, now - expected)
108
+ blocked_since = time.perf_counter() - lag
109
+ expected = now + self.interval
110
+
111
+ if lag < self.threshold:
112
+ self._requests_overlapping(time.perf_counter())
113
+ continue
114
+
115
+ await self.emit(
116
+ {
117
+ "type": "loop_blocked",
118
+ "pid": os.getpid(),
119
+ "blocked_ms": lag * 1000,
120
+ "affected_requests": self._requests_overlapping(blocked_since),
121
+ "timestamp": time.time(),
122
+ }
123
+ )
124
+ # Event delivery is deliberately outside the measured interval. A
125
+ # slow or unavailable dashboard must not look like application lag.
126
+ expected = loop.time() + self.interval
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+ import os
3
+ from .config import config
4
+ from .blocking import BlockingDetector
5
+ from .emitter import emit_event_async
6
+ from .lifecycle import CalfLifecycleMiddleware, install_lifecycle_hooks
7
+ from .dashboard import install_dashboard_redirect
8
+ from .autostart import ensure_collector
9
+
10
+
11
+ class Calf:
12
+ @staticmethod
13
+ def listen(
14
+ app,
15
+ port: int,
16
+ server: str,
17
+ host: str = "127.0.0.1",
18
+ *,
19
+ detect_blocking: bool = True,
20
+ block_threshold: float = 100,
21
+ block_check_interval: float = 50,
22
+ dashboard: bool = True,
23
+ dashboard_path: str = "/calf",
24
+ ignore_paths: tuple[str, ...] | list[str] = (),
25
+ auto_start_collector: bool = True,
26
+ ):
27
+ config.host = host
28
+ config.port = port
29
+ config.server = server
30
+
31
+ cuda_devices = os.getenv("CUDA_VISIBLE_DEVICES")
32
+
33
+ detector = None
34
+ if detect_blocking:
35
+ detector = BlockingDetector(
36
+ emit_event_async,
37
+ interval=block_check_interval / 1000,
38
+ threshold=block_threshold / 1000,
39
+ )
40
+
41
+ @app.on_event("shutdown")
42
+ async def stop_blocking_detector():
43
+ await detector.stop()
44
+
45
+ @app.on_event("startup")
46
+ async def start_calf():
47
+ if auto_start_collector:
48
+ await asyncio.to_thread(ensure_collector, host, port)
49
+ await emit_event_async({
50
+ "pid": os.getpid(),
51
+ "cuda_visible_devices": cuda_devices,
52
+ })
53
+ if detector is not None:
54
+ detector.start()
55
+
56
+ install_lifecycle_hooks()
57
+ if dashboard:
58
+ install_dashboard_redirect(
59
+ app,
60
+ dashboard_path,
61
+ f"http://{host}:{port}/",
62
+ )
63
+ app.add_middleware(
64
+ CalfLifecycleMiddleware,
65
+ emit=emit_event_async,
66
+ detector=detector,
67
+ cuda_devices=cuda_devices,
68
+ dashboard_path=dashboard_path if dashboard else None,
69
+ ignore_paths=("/favicon.ico", *ignore_paths),
70
+ )
@@ -1,6 +1,7 @@
1
- class CalfConfig:
2
- host = "127.0.0.1"
3
- port = 8765
1
+ class CalfConfig:
2
+ host = "127.0.0.1"
3
+ port = 8765
4
+ server = None
4
5
 
5
6
 
6
7
  config = CalfConfig()