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:
43
src/app/api/client-error/route.ts
Normal file
43
src/app/api/client-error/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
72
src/shared/hooks/use-error-report.ts
Normal file
72
src/shared/hooks/use-error-report.ts
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user