Spaces:
Sleeping
Sleeping
| """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()) | |
| 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") | |
| async def root(): | |
| return FileResponse(STATIC_DIR / "index.html") | |
| async def dashboard(): | |
| return FileResponse(STATIC_DIR / "index.html") | |
| 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) | |
| 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"], | |
| } | |
| 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() | |