Spaces:
Running
Running
sync: 193 file da Baida98/AI@88bf019c (2026-08-27 19:26 UTC) [deploy-all]
Browse files- api/performance_rum.py +210 -0
- main.py +1 -0
api/performance_rum.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RUM performance API for the existing FastAPI backend.
|
| 2 |
+
|
| 3 |
+
Routes:
|
| 4 |
+
POST /api/performance/ingest public, bounded and rate-limited
|
| 5 |
+
GET /api/performance/admin/summary admin-only aggregated data
|
| 6 |
+
|
| 7 |
+
The module uses the existing DATABASE_URL PostgreSQL connection and never
|
| 8 |
+
accepts or returns prompt/chat contents.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import hashlib
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import time
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
from typing import Annotated, Any
|
| 20 |
+
|
| 21 |
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
| 22 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 23 |
+
|
| 24 |
+
from .auth_guard import AuthRole, require_role
|
| 25 |
+
|
| 26 |
+
router = APIRouter(prefix="/api/performance", tags=["performance"])
|
| 27 |
+
admin_dependency = Depends(require_role(AuthRole.ADMIN))
|
| 28 |
+
|
| 29 |
+
_RELEASE_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
| 30 |
+
_ALLOWED_METRICS = frozenset({
|
| 31 |
+
"CLS", "FCP", "INP", "LCP", "TTFB",
|
| 32 |
+
"codemirror:core", "codemirror:first-interactive",
|
| 33 |
+
"codemirror:language", "agent:tool-executor",
|
| 34 |
+
})
|
| 35 |
+
_ALLOWED_CONNECTIONS = frozenset({"slow-2g", "2g", "3g", "4g", "wifi", "unknown"})
|
| 36 |
+
_ALLOWED_NAVIGATION = frozenset({"navigate", "reload", "back-forward", "prerender", "unknown"})
|
| 37 |
+
_RATE_WINDOW_SECONDS = 60
|
| 38 |
+
_RATE_LIMIT = 120
|
| 39 |
+
_rate_buckets: dict[str, tuple[float, int]] = {}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class MetricSample(BaseModel):
|
| 43 |
+
model_config = ConfigDict(extra="forbid")
|
| 44 |
+
|
| 45 |
+
metric: str = Field(min_length=2, max_length=40)
|
| 46 |
+
value: float = Field(ge=0, lt=86_400_000)
|
| 47 |
+
release: str = Field(min_length=7, max_length=40)
|
| 48 |
+
device: str
|
| 49 |
+
connection_type: str | None = Field(default=None, max_length=12)
|
| 50 |
+
navigation_type: str | None = Field(default=None, max_length=20)
|
| 51 |
+
|
| 52 |
+
@field_validator("metric")
|
| 53 |
+
@classmethod
|
| 54 |
+
def validate_metric(cls, value: str) -> str:
|
| 55 |
+
if value not in _ALLOWED_METRICS:
|
| 56 |
+
raise ValueError("unsupported metric")
|
| 57 |
+
return value
|
| 58 |
+
|
| 59 |
+
@field_validator("value")
|
| 60 |
+
@classmethod
|
| 61 |
+
def validate_value(cls, value: float) -> float:
|
| 62 |
+
if not math.isfinite(value):
|
| 63 |
+
raise ValueError("value must be finite")
|
| 64 |
+
return round(value, 3)
|
| 65 |
+
|
| 66 |
+
@field_validator("release")
|
| 67 |
+
@classmethod
|
| 68 |
+
def validate_release(cls, value: str) -> str:
|
| 69 |
+
value = value.strip().lower()
|
| 70 |
+
if not _RELEASE_RE.fullmatch(value):
|
| 71 |
+
raise ValueError("release must be a git SHA")
|
| 72 |
+
return value
|
| 73 |
+
|
| 74 |
+
@field_validator("device")
|
| 75 |
+
@classmethod
|
| 76 |
+
def validate_device(cls, value: str) -> str:
|
| 77 |
+
if value not in {"mobile", "desktop"}:
|
| 78 |
+
raise ValueError("device must be mobile or desktop")
|
| 79 |
+
return value
|
| 80 |
+
|
| 81 |
+
@field_validator("connection_type")
|
| 82 |
+
@classmethod
|
| 83 |
+
def validate_connection(cls, value: str | None) -> str | None:
|
| 84 |
+
return value if value in _ALLOWED_CONNECTIONS else ("unknown" if value else None)
|
| 85 |
+
|
| 86 |
+
@field_validator("navigation_type")
|
| 87 |
+
@classmethod
|
| 88 |
+
def validate_navigation(cls, value: str | None) -> str | None:
|
| 89 |
+
return value if value in _ALLOWED_NAVIGATION else ("unknown" if value else None)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _bucket_start(now: datetime, minutes: int = 15) -> datetime:
|
| 93 |
+
now = now.astimezone(timezone.utc).replace(second=0, microsecond=0)
|
| 94 |
+
return now.replace(minute=(now.minute // minutes) * minutes)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _rate_key(request: Request, salt: str) -> str:
|
| 98 |
+
# Digest only; the raw IP and user-agent are never persisted or logged.
|
| 99 |
+
ip = request.headers.get("cf-connecting-ip", "")
|
| 100 |
+
ua = request.headers.get("user-agent", "")[:160]
|
| 101 |
+
return hashlib.sha256(f"{salt}:{ip}:{ua}".encode()).hexdigest()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _allow_rate(key: str) -> bool:
|
| 105 |
+
now = time.monotonic()
|
| 106 |
+
started, count = _rate_buckets.get(key, (now, 0))
|
| 107 |
+
if now - started >= _RATE_WINDOW_SECONDS:
|
| 108 |
+
_rate_buckets[key] = (now, 1)
|
| 109 |
+
return True
|
| 110 |
+
if count >= _RATE_LIMIT:
|
| 111 |
+
return False
|
| 112 |
+
_rate_buckets[key] = (started, count + 1)
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _db_url() -> str:
|
| 117 |
+
value = os.getenv("DATABASE_URL", "").strip()
|
| 118 |
+
if not value.startswith(("postgresql://", "postgres://")):
|
| 119 |
+
raise HTTPException(status_code=503, detail="PostgreSQL non configurato")
|
| 120 |
+
return value
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _execute(sql: str, params: dict[str, Any], *, fetch: bool = False) -> list[dict[str, Any]]:
|
| 124 |
+
import psycopg2
|
| 125 |
+
import psycopg2.extras
|
| 126 |
+
|
| 127 |
+
conn = psycopg2.connect(_db_url())
|
| 128 |
+
try:
|
| 129 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 130 |
+
cur.execute(sql, params)
|
| 131 |
+
rows = [dict(row) for row in cur.fetchall()] if fetch else []
|
| 132 |
+
conn.commit()
|
| 133 |
+
return rows
|
| 134 |
+
finally:
|
| 135 |
+
conn.close()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.post("/ingest", status_code=status.HTTP_202_ACCEPTED)
|
| 139 |
+
async def ingest_metric(
|
| 140 |
+
sample: MetricSample,
|
| 141 |
+
request: Request,
|
| 142 |
+
x_rum_key: Annotated[str | None, Header(alias="X-Rum-Key")] = None,
|
| 143 |
+
) -> dict[str, bool]:
|
| 144 |
+
expected_key = os.getenv("RUM_INGEST_KEY", "")
|
| 145 |
+
if expected_key and x_rum_key != expected_key:
|
| 146 |
+
raise HTTPException(status_code=401, detail="invalid rum key")
|
| 147 |
+
if not _allow_rate(_rate_key(request, os.getenv("RUM_HASH_SALT", "rum"))):
|
| 148 |
+
raise HTTPException(status_code=429, detail="rate limit exceeded")
|
| 149 |
+
|
| 150 |
+
await asyncio.to_thread(
|
| 151 |
+
_execute,
|
| 152 |
+
"""
|
| 153 |
+
insert into public.rum_samples
|
| 154 |
+
(metric, value, release, device, connection_type, navigation_type, bucket_start)
|
| 155 |
+
values (%(metric)s, %(value)s, %(release)s, %(device)s, %(connection_type)s,
|
| 156 |
+
%(navigation_type)s, %(bucket_start)s)
|
| 157 |
+
""",
|
| 158 |
+
{
|
| 159 |
+
"metric": sample.metric,
|
| 160 |
+
"value": sample.value,
|
| 161 |
+
"release": sample.release,
|
| 162 |
+
"device": sample.device,
|
| 163 |
+
"connection_type": sample.connection_type,
|
| 164 |
+
"navigation_type": sample.navigation_type,
|
| 165 |
+
"bucket_start": _bucket_start(datetime.now(timezone.utc)),
|
| 166 |
+
},
|
| 167 |
+
)
|
| 168 |
+
return {"accepted": True}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/admin/summary", dependencies=[admin_dependency])
|
| 172 |
+
async def performance_summary(
|
| 173 |
+
from_time: datetime,
|
| 174 |
+
to_time: datetime,
|
| 175 |
+
release: str = "all",
|
| 176 |
+
device: str = "mobile",
|
| 177 |
+
) -> dict[str, Any]:
|
| 178 |
+
if device not in {"mobile", "desktop"}:
|
| 179 |
+
raise HTTPException(status_code=400, detail="invalid device")
|
| 180 |
+
if to_time <= from_time or (to_time - from_time).days > 31:
|
| 181 |
+
raise HTTPException(status_code=400, detail="invalid time range")
|
| 182 |
+
if release != "all" and not _RELEASE_RE.fullmatch(release.lower()):
|
| 183 |
+
raise HTTPException(status_code=400, detail="invalid release")
|
| 184 |
+
|
| 185 |
+
rows = await asyncio.to_thread(
|
| 186 |
+
_execute,
|
| 187 |
+
"""
|
| 188 |
+
select metric, release, device,
|
| 189 |
+
count(*)::integer as sample_count,
|
| 190 |
+
percentile_cont(0.50) within group (order by value) as p50,
|
| 191 |
+
percentile_cont(0.75) within group (order by value) as p75,
|
| 192 |
+
percentile_cont(0.95) within group (order by value) as p95
|
| 193 |
+
from public.rum_samples
|
| 194 |
+
where bucket_start >= %(from_time)s
|
| 195 |
+
and bucket_start < %(to_time)s
|
| 196 |
+
and device = %(device)s
|
| 197 |
+
and (%(release)s = 'all' or release = %(release)s)
|
| 198 |
+
group by metric, release, device
|
| 199 |
+
order by metric, release
|
| 200 |
+
""",
|
| 201 |
+
{"from_time": from_time, "to_time": to_time, "device": device, "release": release.lower()},
|
| 202 |
+
fetch=True,
|
| 203 |
+
)
|
| 204 |
+
for row in rows:
|
| 205 |
+
if row["sample_count"] < 20:
|
| 206 |
+
row["p50"] = row["p75"] = row["p95"] = None
|
| 207 |
+
row["insufficient_sample"] = True
|
| 208 |
+
else:
|
| 209 |
+
row["insufficient_sample"] = False
|
| 210 |
+
return {"ok": True, "generated_at": datetime.now(timezone.utc).isoformat(), "items": rows}
|
main.py
CHANGED
|
@@ -175,6 +175,7 @@ _ROUTER_MAP = {
|
|
| 175 |
"session_manager": "session_manager",
|
| 176 |
"structured_log": "structured_log",
|
| 177 |
"telemetry": "telemetry",
|
|
|
|
| 178 |
"terminal": "terminal",
|
| 179 |
"vision": "vision",
|
| 180 |
"web": "web",
|
|
|
|
| 175 |
"session_manager": "session_manager",
|
| 176 |
"structured_log": "structured_log",
|
| 177 |
"telemetry": "telemetry",
|
| 178 |
+
"performance": "performance_rum",
|
| 179 |
"terminal": "terminal",
|
| 180 |
"vision": "vision",
|
| 181 |
"web": "web",
|