feat(logging): add useErrorReport hook and /api/client-error endpoint

Task 12: useErrorReport Hook 使用 navigator.sendBeacon 上报客户端错误到 /api/client-error,降级到 fetch keepalive。节流策略:同一 error digest 在 1 分钟内只上报一次(基于 sessionStorage)。

Task 13: /api/client-error Route Handler 接收客户端错误,用 createModuleLogger('client-error') 记录到 pino 日志流,字段包含 message/stack/digest/path/userAgent/timestamp。
This commit is contained in:
SpecialX
2026-07-07 12:41:41 +08:00
parent 811ad11f9f
commit 4122175915
3 changed files with 116 additions and 0 deletions

View File

@@ -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<NextResponse> {
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 });
}

View File

@@ -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"

View File

@@ -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<string | null>(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<string, number>
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])
}