관리자 빈 화면 진단 로그 추가
This commit is contained in:
parent
85608b41ee
commit
64e06a1185
6 changed files with 224 additions and 5 deletions
101
apps/api/app/routes/client_diagnostics.py
Normal file
101
apps/api/app/routes/client_diagnostics.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue