diff --git a/src/app/api/client-error/route.ts b/src/app/api/client-error/route.ts new file mode 100644 index 0000000..cace659 --- /dev/null +++ b/src/app/api/client-error/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; + +import { createModuleLogger } from "@/shared/lib/logger"; + +const log = createModuleLogger("client-error"); + +type ClientErrorPayload = { + message: string; + stack?: string; + digest?: string; + path: string; + userAgent: string; + timestamp: number; +}; + +/** + * 客户端错误上报端点 + * + * 接收前端 error.tsx 通过 useErrorReport Hook 上报的客户端错误, + * 写入服务端 pino 日志流,统一与 server-side 错误一起排查。 + */ +export async function POST(request: Request): Promise { + let payload: ClientErrorPayload; + try { + payload = (await request.json()) as ClientErrorPayload; + } catch { + return NextResponse.json({ ok: false }, { status: 400 }); + } + + log.error( + { + message: payload.message, + stack: payload.stack, + digest: payload.digest, + path: payload.path, + userAgent: payload.userAgent, + clientTimestamp: payload.timestamp, + }, + "client-error", + ); + + return NextResponse.json({ ok: true }); +} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 57c276f..2cfe6d9 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -2,6 +2,7 @@ export { useActionWithToast } from "./use-action-with-toast" export { useAriaLive } from "./use-aria-live" export { useCurrentTime } from "./use-current-time" export { useDebounce } from "./use-debounce" +export { useErrorReport } from "./use-error-report" export { useMediaQuery } from "./use-media-query" export { useLocalStorage } from "./use-local-storage" export { usePermission } from "./use-permission" diff --git a/src/shared/hooks/use-error-report.ts b/src/shared/hooks/use-error-report.ts new file mode 100644 index 0000000..2a9dd43 --- /dev/null +++ b/src/shared/hooks/use-error-report.ts @@ -0,0 +1,72 @@ +"use client" + +import { useEffect, useRef } from "react" + +const ENDPOINT = "/api/client-error" +const THROTTLE_KEY = "error-report-throttle" +const THROTTLE_WINDOW_MS = 60_000 + +type ErrorInput = Error & { digest?: string } + +/** + * 客户端错误上报 Hook。 + * + * 在 error.tsx 中使用,将客户端错误通过 navigator.sendBeacon 上报到 + * /api/client-error 端点,服务端通过 pino logger 统一记录。 + * + * 节流策略:同一 error digest 在 1 分钟内只上报一次(基于 sessionStorage)。 + */ +export function useErrorReport(error: ErrorInput | null): void { + const lastReportRef = useRef(null) + + useEffect(() => { + if (!error) return + + const digest = error.digest ?? error.message + if (lastReportRef.current === digest) return + + // sessionStorage 跨页面刷新节流 + try { + const stored = sessionStorage.getItem(THROTTLE_KEY) + const now = Date.now() + if (stored) { + const entries = JSON.parse(stored) as Record + const lastTime = entries[digest] + if (lastTime && now - lastTime < THROTTLE_WINDOW_MS) { + return + } + entries[digest] = now + sessionStorage.setItem(THROTTLE_KEY, JSON.stringify(entries)) + } else { + sessionStorage.setItem(THROTTLE_KEY, JSON.stringify({ [digest]: now })) + } + } catch { + // sessionStorage 不可用时静默降级(仅内存节流) + } + + lastReportRef.current = digest + + const payload = { + message: error.message, + stack: error.stack, + digest: error.digest, + path: window.location.pathname, + userAgent: navigator.userAgent, + timestamp: Date.now(), + } + + if (navigator.sendBeacon) { + const blob = new Blob([JSON.stringify(payload)], { type: "application/json" }) + navigator.sendBeacon(ENDPOINT, blob) + } else { + void fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => { + // 上报失败静默处理,不影响用户体验 + }) + } + }, [error]) +}