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