vignette/apps/api/app/routes/client_diagnostics.py
2026-07-03 23:24:09 +09:00

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}