Spaces:
Sleeping
Sleeping
File size: 7,707 Bytes
b0add2b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | """FastAPI app: WebSocket broadcast, REST /stats, Prometheus /metrics, static dashboard."""
import asyncio
import json
import logging
import logging.config
import time
from collections import deque
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from src.config import settings
from src.pipeline.runner import run_pipeline
from src.stream.generator import generate_stream
# Structured JSON logging
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "logging.Formatter",
"fmt": '{"time":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","message":"%(message)s"}',
"datefmt": "%Y-%m-%dT%H:%M:%S",
}
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}
},
"root": {"level": "INFO", "handlers": ["console"]},
})
logger = logging.getLogger(__name__)
REPLAY_BUFFER_SIZE = settings.server.replay_buffer_size
# Shared state updated by the pipeline, read by /stats and /metrics
STATE: dict = {
"total_observations": 0,
"total_anomalies_detected": 0,
"total_drift_events": 0,
"current_precision": 0.0,
"current_recall": 0.0,
"current_f1": 0.0,
"cycle_start_time": 0.0,
}
replay_buffer: deque = deque(maxlen=REPLAY_BUFFER_SIZE)
_pipeline_task: asyncio.Task | None = None
class ConnectionManager:
def __init__(self) -> None:
self._connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
self._connections.append(websocket)
def register(self, websocket: WebSocket) -> None:
self._connections.append(websocket)
def disconnect(self, websocket: WebSocket) -> None:
if websocket in self._connections:
self._connections.remove(websocket)
async def broadcast(self, message: dict) -> None:
text = json.dumps(message)
dead: list[WebSocket] = []
for ws in self._connections:
try:
await ws.send_text(text)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws)
manager = ConnectionManager()
async def _broadcast_with_replay(message: dict) -> None:
replay_buffer.append(message)
await manager.broadcast(message)
def _reset_state() -> None:
STATE["total_observations"] = 0
STATE["total_anomalies_detected"] = 0
STATE["total_drift_events"] = 0
STATE["current_precision"] = 0.0
STATE["current_recall"] = 0.0
STATE["current_f1"] = 0.0
STATE["cycle_start_time"] = time.time()
async def _run_pipeline_loop() -> None:
cfg_s = settings.stream
cfg_d = settings.detector
cfg_dr = settings.drift
while True:
_reset_state()
logger.info("cycle_start", extra={"seed": cfg_s.seed})
stream = generate_stream(
phase_a_length=cfg_s.phase_a_length,
phase_b_length=cfg_s.phase_b_length,
phase_c_length=cfg_s.phase_c_length,
drift_magnitude=cfg_s.drift_magnitude,
anomaly_rate=cfg_s.anomaly_rate,
point_ratio=cfg_s.point_ratio,
delay=cfg_s.delay,
seed=cfg_s.seed,
)
await run_pipeline(
stream,
_broadcast_with_replay,
state=STATE,
anomaly_threshold=cfg_d.threshold,
n_trees=cfg_d.n_trees,
height=cfg_d.height,
window_size=cfg_d.window_size,
drift_delta=cfg_dr.delta,
drift_grace_period=cfg_dr.grace_period,
)
def _ensure_pipeline_running() -> None:
global _pipeline_task
if _pipeline_task is None or _pipeline_task.done():
_pipeline_task = asyncio.create_task(_run_pipeline_loop())
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
global _pipeline_task
if _pipeline_task is not None and not _pipeline_task.done():
_pipeline_task.cancel()
try:
await _pipeline_task
except asyncio.CancelledError:
pass
app = FastAPI(title="Real-Time Anomaly Detection", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/")
async def root():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/dashboard")
async def dashboard():
return FileResponse(STATIC_DIR / "index.html")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
await websocket.accept()
for msg in replay_buffer:
try:
await websocket.send_text(json.dumps(msg))
except Exception:
break
manager.register(websocket)
_ensure_pipeline_running()
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.disconnect(websocket)
@app.get("/stats")
async def stats():
return {
"total_observations": STATE["total_observations"],
"total_anomalies_detected": STATE["total_anomalies_detected"],
"total_drift_events": STATE["total_drift_events"],
"current_precision": STATE["current_precision"],
"current_recall": STATE["current_recall"],
"current_f1": STATE["current_f1"],
}
@app.get("/metrics", response_class=PlainTextResponse)
async def metrics():
"""Prometheus-compatible metrics endpoint."""
uptime = time.time() - STATE["cycle_start_time"] if STATE["cycle_start_time"] else 0.0
lines = [
"# HELP anomaly_observations_total Total observations processed in current cycle",
"# TYPE anomaly_observations_total counter",
f'anomaly_observations_total {STATE["total_observations"]}',
"",
"# HELP anomaly_alerts_total Total anomaly alerts fired in current cycle",
"# TYPE anomaly_alerts_total counter",
f'anomaly_alerts_total {STATE["total_anomalies_detected"]}',
"",
"# HELP anomaly_drift_events_total Total drift events detected in current cycle",
"# TYPE anomaly_drift_events_total counter",
f'anomaly_drift_events_total {STATE["total_drift_events"]}',
"",
"# HELP anomaly_precision Running precision (alerts that were true anomalies)",
"# TYPE anomaly_precision gauge",
f'anomaly_precision {STATE["current_precision"]:.6f}',
"",
"# HELP anomaly_recall Running recall (true anomalies that were detected)",
"# TYPE anomaly_recall gauge",
f'anomaly_recall {STATE["current_recall"]:.6f}',
"",
"# HELP anomaly_f1 Running F1 score",
"# TYPE anomaly_f1 gauge",
f'anomaly_f1 {STATE["current_f1"]:.6f}',
"",
"# HELP anomaly_cycle_uptime_seconds Seconds since current stream cycle started",
"# TYPE anomaly_cycle_uptime_seconds gauge",
f"anomaly_cycle_uptime_seconds {uptime:.3f}",
"",
]
return "\n".join(lines)
def main() -> None:
import os
import uvicorn
port = int(os.environ.get("PORT", str(settings.server.port)))
uvicorn.run("src.server.app:app", host="0.0.0.0", port=port, reload=False)
if __name__ == "__main__":
main()
|