관리자 빈 화면 진단 로그 추가

This commit is contained in:
Yun Chan 2026-07-03 23:24:09 +09:00
parent 85608b41ee
commit 64e06a1185
6 changed files with 224 additions and 5 deletions

View file

@ -23,6 +23,7 @@ from .persona_repository import materialize_seed_personas
from .session_persistence import ensure_review_tables
from .routes import auth as auth_routes
from .routes import admin as admin_routes
from .routes import client_diagnostics as client_diagnostics_routes
from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import personas as persona_routes
@ -96,6 +97,7 @@ _upload_root.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(_upload_root)), name="uploads")
app.include_router(auth_routes.router)
app.include_router(client_diagnostics_routes.router)
app.include_router(admin_routes.router)
app.include_router(persona_routes.router)
app.include_router(session_routes.router)

View 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}

View file

@ -0,0 +1,56 @@
"""Client diagnostics telemetry tests."""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from .routes import client_diagnostics
class ClientDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_client_diagnostic_logs_safe_structural_payload(self) -> None:
payload = client_diagnostics.ClientDiagnosticRequest(
reason="watchdog",
path="/admin/users",
href="https://vignette.chanpaca.net/admin/users",
asset="index-R5KK7hZI.js",
elapsed_ms=3510,
body_text_length=0,
root_text_length=0,
root_child_count=1,
visible_nodes=0,
document_ready_state="complete",
viewport="1920x1080",
user_agent="Playwright",
errors=[
client_diagnostics.ClientDiagnosticError(
kind="unhandledrejection",
detail="Cannot read properties of null",
at="2026-07-03T00:00:00.000Z",
)
],
)
request = SimpleNamespace(
client=SimpleNamespace(host="127.0.0.1"),
headers={
"origin": "https://vignette.chanpaca.net",
"referer": "https://vignette.chanpaca.net/admin/users",
"cf-ray": "test-ray",
},
)
with self.assertLogs("vignette.client_diagnostics", level="WARNING") as logs:
result = await client_diagnostics.record_client_diagnostic(payload, request)
joined = "\n".join(logs.output)
self.assertEqual(result, {"ok": True})
self.assertIn("client_diagnostic", joined)
self.assertIn("/admin/users", joined)
self.assertIn("body_text_length", joined)
self.assertIn("Cannot read properties of null", joined)
self.assertNotIn("body_text=", joined)
if __name__ == "__main__":
unittest.main()