sync: 193 file da Baida98/AI@88bf019c (2026-08-27 19:26 UTC) [deploy-all]

#124
by Baida07 - opened
Files changed (2) hide show
  1. api/performance_rum.py +210 -0
  2. 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",