zeetay's picture
initial deploy
b0add2b verified
Raw
History Blame Contribute Delete
7.71 kB
"""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()