101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
"""Client-side boot/render diagnostic telemetry.
|
|
|
|
The route is intentionally public: it is used when the SPA cannot render enough
|
|
UI to let an authenticated operator report the failure from inside the app.
|
|
Only structural counts and short error summaries are logged; page text is not
|
|
accepted or persisted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Request, status
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
router = APIRouter(tags=["diagnostics"])
|
|
logger = logging.getLogger("vignette.client_diagnostics")
|
|
|
|
|
|
class ClientDiagnosticError(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
kind: str = ""
|
|
detail: str = ""
|
|
at: str | None = None
|
|
|
|
|
|
class ClientDiagnosticRequest(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
reason: str = "unknown"
|
|
path: str = ""
|
|
href: str = ""
|
|
asset: str = ""
|
|
elapsed_ms: int | None = None
|
|
body_text_length: int | None = None
|
|
root_text_length: int | None = None
|
|
root_child_count: int | None = None
|
|
visible_nodes: int | None = None
|
|
document_ready_state: str = ""
|
|
viewport: str = ""
|
|
user_agent: str = ""
|
|
errors: list[ClientDiagnosticError] = Field(default_factory=list)
|
|
|
|
|
|
def _short(value: Any, limit: int) -> str:
|
|
text = "" if value is None else str(value)
|
|
return text[:limit]
|
|
|
|
|
|
def _bounded_int(value: int | None, upper: int = 1_000_000) -> int | None:
|
|
if value is None:
|
|
return None
|
|
return max(0, min(int(value), upper))
|
|
|
|
|
|
def _diagnostic_log_payload(payload: ClientDiagnosticRequest, request: Request) -> dict[str, Any]:
|
|
client_host = request.client.host if request.client else ""
|
|
return {
|
|
"reason": _short(payload.reason, 80),
|
|
"path": _short(payload.path, 300),
|
|
"href": _short(payload.href, 800),
|
|
"asset": _short(payload.asset, 160),
|
|
"elapsed_ms": _bounded_int(payload.elapsed_ms),
|
|
"body_text_length": _bounded_int(payload.body_text_length),
|
|
"root_text_length": _bounded_int(payload.root_text_length),
|
|
"root_child_count": _bounded_int(payload.root_child_count, 50_000),
|
|
"visible_nodes": _bounded_int(payload.visible_nodes, 50_000),
|
|
"document_ready_state": _short(payload.document_ready_state, 40),
|
|
"viewport": _short(payload.viewport, 80),
|
|
"user_agent": _short(payload.user_agent, 300),
|
|
"origin": _short(request.headers.get("origin"), 300),
|
|
"referer": _short(request.headers.get("referer"), 800),
|
|
"cf_ray": _short(request.headers.get("cf-ray"), 80),
|
|
"client_host": _short(client_host, 80),
|
|
"errors": [
|
|
{
|
|
"kind": _short(error.kind, 80),
|
|
"detail": _short(error.detail, 900),
|
|
"at": _short(error.at, 80),
|
|
}
|
|
for error in payload.errors[:5]
|
|
],
|
|
}
|
|
|
|
|
|
@router.post("/client-diagnostics", status_code=status.HTTP_202_ACCEPTED)
|
|
async def record_client_diagnostic(
|
|
payload: ClientDiagnosticRequest, request: Request
|
|
) -> dict[str, bool]:
|
|
"""Log a minimal browser-side boot/render failure report."""
|
|
|
|
safe_payload = _diagnostic_log_payload(payload, request)
|
|
logger.warning(
|
|
"client_diagnostic %s",
|
|
json.dumps(safe_payload, ensure_ascii=False, separators=(",", ":")),
|
|
)
|
|
return {"ok": True}
|