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

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()

View file

@ -67,6 +67,7 @@
(function () {
var errors = [];
var loadedAt = Date.now();
var sentDiagnostics = {};
function short(value) {
return String(value || "").slice(0, 900);
}
@ -109,6 +110,58 @@
);
}).length;
}
function diagnosticApiBase() {
var host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
if (host === "vnet.18ka.net") {
return "https://api-vnet.18ka.net";
}
return "/api";
}
function sendDiagnostic(reason, textLength, visible) {
var key = reason + ":" + window.location.pathname;
if (sentDiagnostics[key]) return;
sentDiagnostics[key] = true;
var root = document.getElementById("root");
var payload = {
reason: reason,
path: window.location.pathname,
href: window.location.href,
asset: assetLabel(),
elapsed_ms: Date.now() - loadedAt,
body_text_length: textLength,
root_text_length: root && root.innerText ? root.innerText.trim().length : 0,
root_child_count: root ? root.childElementCount : 0,
visible_nodes: visible,
document_ready_state: document.readyState,
viewport: window.innerWidth + "x" + window.innerHeight,
user_agent: short(window.navigator && window.navigator.userAgent),
errors: errors.slice(-5),
};
var url = diagnosticApiBase().replace(/\/$/, "") + "/client-diagnostics";
var body = JSON.stringify(payload);
try {
if (window.navigator && navigator.sendBeacon) {
var blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon(url, blob)) return;
}
} catch (error) {
/* fetch fallback below */
}
try {
fetch(url, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: body,
}).catch(function () {});
} catch (error) {
/* diagnostics must never affect app boot */
}
}
function render(reason) {
var path = window.location.pathname;
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
@ -122,9 +175,16 @@
if (old) old.remove();
return;
}
sendDiagnostic(reason, text.length, visible);
var panel = document.getElementById("vignette-boot-diagnostic");
if (!panel) {
if (!document.body) {
window.setTimeout(function () {
render(reason);
}, 100);
return;
}
panel = document.createElement("section");
panel.id = "vignette-boot-diagnostic";
panel.setAttribute("role", "alert");