- Update 004_architecture_impact_map.md and 005_architecture_data.json - Add audit reports: data-access-audit-framework-v1, data-access-audit-v1-data.json, data-access-audit-v1, g1-g5 audit outputs - Add superpowers plans and specs (logging-refactor, documentation-system-redesign) - Update troubleshooting/known-issues.md
24 KiB
24 KiB
日志系统重构设计文档
| 字段 | 值 |
|---|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-07 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | pino logger 抽象 + Request ID 贯穿 + 静默失败修复 + error.tsx 上报 + track-event 去重 |
| 策略 | 应用层 SDK 改造,不部署外部可观测性服务(OTel / Sentry / Prometheus 等) |
1. 背景与现状
1.1 现状调研摘要
基于 2026-07-07 全项目调研:
| 维度 | 状态 | 说明 |
|---|---|---|
| 业务审计日志 | ✅ 已成熟 | audit / login / change logger + 3 张 DB 表 + audit 模块管理后台 |
| 应用层结构化日志 | ❌ 未实现 | 88 处散乱 console.*,无级别、无 JSON、无 request ID |
| Request Correlation | ❌ 未实现 | proxy.ts 中间件零日志、不注入请求 ID |
| 错误边界上报 | ❌ 全部丢弃 | 130 个 error.tsx 接收 error prop 后直接丢弃,前端运行时错误对开发者不可见 |
| 静默失败 | ⚠️ 严重 | 三个 audit-logger 的 catch 块完全吞没错误,运维无法感知审计写入失败 |
| 统一错误处理 | ⚠️ 部分 | handleActionError / handleApiError 仅 console.error,前缀混乱([ExamAction] / [ActionError] / [ApiError] / 无前缀混用) |
| track-event | ⚠️ 重复 | 4 个 no-op stub(shared / rbac / course-plans / questions 各一份) |
| 架构图覆盖 | ❌ 未覆盖 | 004 / 005 完全未记录可观测性基础设施 |
1.2 console.* 分布
| 类型 | 次数 | 文件数 | 主要位置 |
|---|---|---|---|
console.log |
2 | 1 | questions/utils/parse-content.ts |
console.error |
67 | 30 | data-access 的 catch 块、action-utils.ts、api-response.ts、各 Server Action |
console.warn |
5 | 5 | api/web-vitals/route.ts、redis/store 等 |
console.info |
10 | 6 | track-event.ts、api/web-vitals/route.ts、notifications channels |
console.debug |
4 | 3 | 各模块 track-event.ts(仅 dev) |
1.3 关键约束
- proxy.ts 在 Edge Runtime 运行(proxy.ts:64 注释明确说明),
AsyncLocalStorage不可用(依赖node:async_hooks,Edge 不支持) - 其余部分(RSC、Server Action、Route Handler、data-access)运行在 Node.js runtime,
AsyncLocalStorage完全支持 - 项目自部署(
next.config.ts中output: "standalone"),运维通过docker logs查看日志
2. 目标与非目标
2.1 目标
- 统一 logger 抽象:pino 替换 88 处散乱
console.*,提供info/error/warn/debug+createModuleLogger(module)子 logger - 级别控制:
LOG_LEVEL环境变量控制输出阈值(debug/info/warn/error),Zod 校验 - 结构化输出:生产环境 JSON(含 timestamp/level/module/requestId/msg),开发环境
pino-pretty彩色文本 - Request ID 贯穿:proxy.ts 生成 ID 并注入请求头,Node.js runtime 通过
AsyncLocalStorage贯穿到 data-access / audit-logger - 静默失败告警:三个 audit-logger 的 catch 块从 silent 改为
logger.warn - 统一错误处理接入:
handleActionError/handleApiError/safeActionCall接入 logger,前缀通过module字段规范化 - track-event 去重合并:4 个 no-op stub 合并为 1 个,统一通过
createModuleLogger("track") - error.tsx 错误上报:130 个 error.tsx 通过
useErrorReportHook 上报到/api/client-error,含节流防风暴 - 架构图同步:004 / 005 / known-issues.md 全量更新
2.2 非目标(YAGNI)
- OpenTelemetry / 链路追踪(trace)
- Prometheus /
/metrics端点 - Sentry / Bugsnag / Datadog 等 SaaS 错误监控
- Web Vitals 后端持久化(保留现有
console.warn) - 数据库查询日志 / 慢查询日志
- 日志文件轮转(
docker logs已足够) - next-auth events 回调改造(与本次重构解耦)
3. 架构设计
3.1 整体数据流
[Client] ──HTTP──> [proxy.ts (Edge Runtime)]
│ requestId = crypto.randomUUID() // Web Crypto API
│ NextResponse.next({ request: { headers } })
│ 注入 x-request-id 到下游请求头
▼
[RSC / Server Action / Route Handler (Node.js Runtime)]
│ withRequestContext(fn):
│ 1. headers().get("x-request-id") 读取
│ 2. requestContextStorage.run({ requestId }, fn)
▼
[data-access / audit-logger / 业务逻辑 (Node.js Runtime)]
│ logger.info({...}, "msg") 调用
│ pino mixin 自动从 getRequestContext() 取 requestId
▼
[stdout: {"level":"info","time":...,"requestId":"abc-123","module":"audit","msg":"..."}]
3.2 新增文件清单
src/shared/lib/
├─ logger.ts # pino 实例 + createModuleLogger 工厂
├─ request-context.ts # AsyncLocalStorage(仅 Node.js runtime)
└─ with-request-context.ts # Server Action / Route Handler 入口包装
src/shared/hooks/
└─ use-error-report.ts # error.tsx 公共上报 Hook(含节流)
src/app/api/client-error/
└─ route.ts # 客户端错误接收端点
3.3 修改文件清单
| 文件 | 改动类型 | 说明 |
|---|---|---|
package.json |
新增依赖 | pino、pino-pretty(dev) |
src/env.mjs |
新增字段 | LOG_LEVEL(默认 info,Zod enum) |
src/proxy.ts |
修改 | 生成 requestId 并通过 NextResponse.next 注入请求头 |
src/shared/lib/action-utils.ts |
修改 | handleActionError / safeActionCall 用 logger |
src/shared/lib/api-response.ts |
修改 | handleApiError 用 logger |
src/shared/lib/audit-logger.ts |
修改 | catch 块从 silent 改为 logger.warn |
src/shared/lib/change-logger.ts |
修改 | 同上 |
src/shared/lib/login-logger.ts |
修改 | 同上 |
src/shared/lib/track-event.ts |
修改 | 改为 createModuleLogger("track"),删除 no-op 输出 |
src/modules/rbac/lib/track.ts |
删除 | 引用方改为从 @/shared/lib/track-event 导入 |
src/modules/course-plans/lib/track-event.ts |
删除 | 同上 |
src/modules/questions/utils/track-event.ts |
删除 | 同上 |
88 处 console.* 调用点 |
修改 | 替换为 logger.* 或 createModuleLogger(module) |
130 个 error.tsx |
修改 | 在 useEffect 中调用 useErrorReport(error) |
next.config.ts |
修改 | 在现有 serverExternalPackages 数组中添加 "pino"(当前已有 mysql2/tencentcloud-sdk-nodejs/exceljs) |
.eslintrc / eslint.config.mjs |
修改 | 新增 no-console 规则,仅允许 logger.ts 中使用 console |
4. 核心模块设计
4.1 src/shared/lib/logger.ts
import pino, { type Logger } from "pino"
import { env } from "@/env.mjs"
import { getRequestContext } from "./request-context"
/**
* 全局 logger 实例。
*
* - 生产环境:JSON 输出到 stdout(docker logs 友好)
* - 开发环境:pino-pretty 彩色文本
* - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在)
*/
export const logger = pino({
level: env.LOG_LEVEL,
base: { service: "cicd-app" },
formatters: {
level: (label) => ({ level: label }),
},
mixin: () => getRequestContext(),
...(env.NODE_ENV === "development" && {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "SYS:standard" },
},
}),
})
/**
* 创建模块级子 logger,自动绑定 module 字段。
*
* @example
* ```ts
* const log = createModuleLogger("audit")
* log.info({ userId }, "User action logged")
* // 输出: {"level":"info","module":"audit","msg":"User action logged", ...}
* ```
*/
export function createModuleLogger(module: string): Logger {
return logger.child({ module })
}
export type { Logger }
4.2 src/shared/lib/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks"
/**
* 请求上下文,贯穿整个请求生命周期。
*
* 仅在 Node.js Runtime 中可用(proxy.ts 是 Edge Runtime,不导入此模块)。
* 通过 withRequestContext 高阶函数注入。
*/
export interface RequestContext {
requestId: string
userId?: string
module?: string
}
export const requestContextStorage = new AsyncLocalStorage<RequestContext>()
/**
* 获取当前请求上下文(若存在)。
*
* - 在 withRequestContext 包装的调用栈内:返回完整上下文
* - 在调用栈外(如顶层模块初始化、定时任务):返回空对象
*
* pino logger 的 mixin 配置会自动调用此函数混入 requestId。
*/
export function getRequestContext(): Partial<RequestContext> {
return requestContextStorage.getStore() ?? {}
}
4.3 src/shared/lib/with-request-context.ts
import { headers } from "next/headers"
import { randomUUID } from "node:crypto"
import { requestContextStorage, type RequestContext } from "./request-context"
/**
* 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。
*
* 工作流程:
* 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id`
* 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID
* 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage
* 4. 在调用栈内的所有 logger 调用自动获得 requestId
*
* @example
* ```ts
* export const createUserAction = withRequestContext(
* async (state: ActionState<User>, input: CreateUserInput) => {
* // 此处 logger.info 会自动带 requestId
* return handleAction(...)
* }
* )
* ```
*/
export function withRequestContext<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>
): (...args: TArgs) => Promise<TResult> {
return async (...args: TArgs) => {
const headersList = await headers()
const requestId =
headersList.get("x-request-id") ?? randomUUID()
const ctx: RequestContext = { requestId }
return requestContextStorage.run(ctx, () => fn(...args))
}
}
4.4 src/proxy.ts 改造
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
// ... 原有 imports
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// 生成或复用 requestId(Web Crypto API,Edge 兼容)
const requestId =
request.headers.get("x-request-id") ?? crypto.randomUUID()
// 跳过静态资源和登录页
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/auth") ||
pathname === "/login" ||
pathname === "/register" ||
pathname === "/favicon.ico"
) {
return NextResponse.next({
request: { headers: injectRequestId(request, requestId) },
})
}
// ... 原有 token / onboarding / 权限检查逻辑
// 所有 NextResponse.next() / NextResponse.redirect() 调用保留,
// 但 NextResponse.next() 调用统一传入 request.headers
const response = NextResponse.next({
request: { headers: injectRequestId(request, requestId) },
})
response.headers.set("x-request-id", requestId)
return response
}
/**
* 创建包含 x-request-id 的新 Headers 对象。
* 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。
*/
function injectRequestId(request: NextRequest, requestId: string): Headers {
const headers = new Headers(request.headers)
headers.set("x-request-id", requestId)
return headers
}
说明:proxy.ts 不导入
request-context.ts,避免在 Edge Runtime 中加载node:async_hooks导致构建错误。
4.5 src/shared/hooks/use-error-report.ts
"use client"
import { useEffect } from "react"
interface ClientErrorPayload {
message: string
stack?: string
digest?: string
url: string
userAgent: string
timestamp: string
}
/**
* 客户端错误上报 Hook。
*
* 用于 error.tsx 接收 error prop 后上报到 /api/client-error。
*
* 节流策略:
* - 同一 digest(或 message)在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报
* - 上报失败时静默忽略,避免无限循环
*/
export function useErrorReport(error: Error & { digest?: string }): void {
useEffect(() => {
if (!error) return
const digest = error.digest ?? error.message
const storageKey = `error-reported:${digest}`
if (sessionStorage.getItem(storageKey)) return
sessionStorage.setItem(storageKey, "1")
const payload: ClientErrorPayload = {
message: error.message,
stack: error.stack,
digest: error.digest,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
}
fetch("/api/client-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true, // 即使页面卸载也尝试发送
}).catch(() => {
// 上报失败时不再上报,避免无限循环
})
}, [error])
}
4.6 src/app/api/client-error/route.ts
import { NextResponse } from "next/server"
import { createModuleLogger } from "@/shared/lib/logger"
import { withRequestContext } from "@/shared/lib/with-request-context"
const log = createModuleLogger("client-error")
interface ClientErrorPayload {
message: string
stack?: string
digest?: string
url: string
userAgent: string
timestamp: string
}
/**
* 接收客户端 error.tsx 上报的错误。
*
* 注意:客户端错误的 requestId 与原始请求不同(来自 /api/client-error 的 HTTP 请求),
* 但 digest 字段可用于关联原始错误。
*/
export const POST = withRequestContext(async (request: Request) => {
try {
const body = (await request.json()) as ClientErrorPayload
log.error(
{
clientMessage: body.message,
stack: body.stack,
digest: body.digest,
url: body.url,
userAgent: body.userAgent,
clientTimestamp: body.timestamp,
},
"Client error reported"
)
return NextResponse.json({ ok: true })
} catch (error) {
log.error({ err: error }, "Failed to parse client error payload")
return NextResponse.json({ ok: false }, { status: 400 })
}
})
4.7 src/shared/lib/audit-logger.ts 改造(静默失败 → 告警)
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("audit-logger")
export async function logAudit(params: LogAuditParams): Promise<void> {
try {
// ... 原有写入逻辑
} catch (error) {
// 旧:catch { /* Silently fail */ }
// 新:记录到 logger,运维可感知
log.warn(
{ err: error, action: params.action, module: params.module },
"Audit log write failed"
)
}
}
4.8 src/shared/lib/action-utils.ts 改造
import { createModuleLogger } from "@/shared/lib/logger"
import type { ActionState } from "@/shared/types/action-state"
import { PermissionDeniedError } from "@/shared/lib/errors"
const log = createModuleLogger("action")
export function handleActionError(e: unknown): ActionState<never> {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof BusinessError) {
return { success: false, message: e.message, errorCode: e.code }
}
if (e instanceof Error) {
// 旧:console.error("[ActionError]", e.name, e.message, e.stack)
log.error({ err: e }, "Action failed")
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
log.error({ err: e }, "Unknown action error")
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
export async function safeActionCall<T>(
action: () => Promise<ActionState<T>>,
options?: {
onError?: (error: unknown) => void
onFinally?: () => void
}
): Promise<ActionState<T> | null> {
try {
return await action()
} catch (e) {
options?.onError?.(e)
// 旧:console.error("[SafeActionCall]", e)
log.error({ err: e }, "Safe action call threw")
return null
} finally {
options?.onFinally?.()
}
}
4.9 src/shared/lib/track-event.ts 改造
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("track")
/**
* 业务埋点接口。
*
* 不再是 no-op stub,通过 logger.info 输出结构化事件,
* 后续可扩展为接入外部 analytics 服务。
*/
export function trackEvent(
name: string,
props?: Record<string, unknown>
): void {
log.info({ event: name, ...props }, "track event")
}
export function trackExamEvent(
name: string,
props?: Record<string, unknown>
): void {
trackEvent(`exam.${name}`, props)
}
export function trackAuthEvent(
name: string,
props?: Record<string, unknown>
): void {
trackEvent(`auth.${name}`, props)
}
4.10 src/env.mjs 改造
// 在 server schema 中添加:
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
4.11 ESLint 规则
// eslint.config.mjs
{
rules: {
"no-console": ["error", { allow: [], allowWithImplicit: false }]
},
// logger.ts 豁免(pino 内部需用 console)
overrides: [
{
files: ["src/shared/lib/logger.ts"],
rules: { "no-console": "off" }
}
]
}
5. 改造范围与策略
5.1 console.* 替换映射
| 类型 | 数量 | 替换为 | 调用方式 |
|---|---|---|---|
console.log |
2 | log.debug |
const log = createModuleLogger("questions") |
console.error |
67 | log.error |
按文件所属模块创建子 logger |
console.warn |
5 | log.warn |
同上 |
console.info |
10 | log.info |
同上 |
console.debug |
4 | log.debug |
同上 |
5.2 模块前缀规范化
当前散乱前缀 → 统一通过 module 字段:
| 当前前缀 | 新 module 字段 |
|---|---|
[ExamAction] |
exams |
[ActionError] |
action |
[ApiError] |
api |
[AuditLogger] |
audit-logger |
[SafeActionCall] |
action |
[Files] / [files] |
files |
| 无前缀 | 按文件所属模块 |
5.3 Server Action 包装策略
由于 Server Action 通过 "use server" 自动成为 RPC,不能简单用 withRequestContext 包装导出函数(会丢失 Next.js 类型推断)。且 handleActionError 是同步函数,无法 await headers() 读取请求头。
最终策略:在每个 Server Action 模块的入口点调用 withRequestContext 包装:
// src/modules/audit/actions.ts
"use server"
import { withRequestContext } from "@/shared/lib/with-request-context"
import { createModuleLogger } from "@/shared/lib/logger"
const log = createModuleLogger("audit")
export const createAuditLogAction = withRequestContext(
async (state: ActionState<void>, input: CreateAuditInput) => {
// 此处 logger 自动带 requestId
log.info({ input }, "Creating audit log")
return handleAction(...)
}
)
data-access 层无需显式包装:因为 data-access 总是从 Server Action 调用,AsyncLocalStorage 上下文会自动贯穿到调用栈下游。data-access 中的 logger 调用会自动获得 requestId。
实施时验证项:
- Next.js 16 是否允许高阶函数包装 Server Action(保留 NextServerAction 标记)
- 若不允许,回退方案:在每个 Server Action 函数体首行调用
await initRequestContext(),该函数内部读取 headers 并写入 AsyncLocalStorage
5.4 error.tsx 改造模式
130 个 error.tsx 统一改为:
"use client"
import { useEffect } from "react"
import { useErrorReport } from "@/shared/hooks/use-error-report"
// ... 原有 imports
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useErrorReport(error)
// ... 原有 UI 渲染
}
每个 error.tsx 仅增加 2 行(import + 调用 Hook)。
6. 测试策略
6.1 单元测试
| 测试文件 | 覆盖内容 |
|---|---|
__tests__/logger.test.ts |
level 控制、JSON 输出格式、mixin 注入 requestId、createModuleLogger |
__tests__/request-context.test.ts |
AsyncLocalStorage 读写、嵌套调用、空上下文 |
__tests__/use-error-report.test.tsx |
节流(sessionStorage 标记)、fetch 调用、错误处理 |
6.2 集成测试
- 启动开发服务器,发起请求,验证日志包含 requestId
- 触发 Server Action 错误,验证日志包含 module + requestId + error stack
- 触发 client error.tsx,验证
/api/client-error收到请求并记录日志
6.3 回归验证
npx tsc --noEmit零错误npm run lint零错误(含新增no-console规则)- 现有 vitest 测试套件全部通过
7. 风险与权衡
| 风险 | 影响 | 缓解 |
|---|---|---|
| pino 在 Next.js bundling 中可能有问题 | 高 | 已配置 serverExternalPackages,pino 仅服务端导入;开发期 pino-pretty 通过 transport 配置启用 |
AsyncLocalStorage 在 Server Action 中可能不工作 |
中 | Server Action 运行在 Node.js runtime,完全支持;实施时先做最小验证 |
| Server Action 高阶函数包装可能丢失 Next.js 类型 | 中 | 实施时验证,必要时回退为显式 headers() 调用 |
| 88 处 console.* 替换可能遗漏 | 低 | 通过 ESLint no-console 规则强制,仅允许 logger.ts 中使用 |
| 130 个 error.tsx 改造量大 | 中 | 提取公共 useErrorReport Hook,每个 error.tsx 仅加 2 行 |
| track-event 合并可能破坏调用方 | 低 | 删除前 grep 所有引用,统一改为从 @/shared/lib/track-event 导入 |
| 客户端错误风暴(无限循环上报) | 中 | sessionStorage 节流 + fetch 失败静默 + keepalive 选项 |
| proxy.ts 改造可能影响 Edge Runtime 构建 | 中 | 不导入任何 Node.js 模块,仅用 Web Crypto API |
8. 架构图同步(强制)
按项目规则"改码必同步图",重构后必须更新:
8.1 docs/architecture/004_architecture_impact_map.md
新增章节:
shared/lib/logger.ts— pino 实例 + createModuleLoggershared/lib/request-context.ts— AsyncLocalStorage 请求上下文shared/lib/with-request-context.ts— Server Action / Route Handler 入口包装shared/hooks/use-error-report.ts— 客户端错误上报 Hookapp/api/client-error/route.ts— 客户端错误接收端点
修改章节:
proxy.ts— 增加 requestId 注入逻辑shared/lib/audit-logger.ts/change-logger.ts/login-logger.ts— 静默失败改为 logger.warnshared/lib/track-event.ts— 实现层从 no-op 改为 logger.infoshared/lib/action-utils.ts/api-response.ts— 接入 logger
删除记录:
modules/rbac/lib/track.tsmodules/course-plans/lib/track-event.tsmodules/questions/utils/track-event.ts
8.2 docs/architecture/005_architecture_data.json
modules.shared.lib.exports新增logger/createModuleLogger/requestContextStorage/getRequestContext/withRequestContextmodules.shared.hooks.exports新增useErrorReportmodules.app.api.client-error.exports新增POSTdependencyMatrix更新:proxy.ts → request-context(仅注入请求头,不导入)- 删除
modules.rbac.lib.track、modules.course-plans.lib.track-event、modules.questions.utils.track-event
8.3 docs/troubleshooting/known-issues.md
新增规则条目:
- pino 集成:
serverExternalPackages配置 +pino-pretty仅 dev - Edge Runtime 限制:proxy.ts 不能导入
node:async_hooks - Server Action 包装:
withRequestContext高阶函数使用模式 - error.tsx 错误上报:
useErrorReportHook + 节流策略 - ESLint
no-console规则与豁免
9. 实施顺序建议
-
Phase 1:基础底座
- 装 pino / pino-pretty
- 创建
logger.ts/request-context.ts/with-request-context.ts - 在
env.mjs添加 LOG_LEVEL - 单元测试
-
Phase 2:核心接入
- proxy.ts 注入 requestId
action-utils.ts/api-response.ts接入 logger- 三个 audit-logger 静默失败 → logger.warn
- track-event 去重合并
-
Phase 3:批量替换
- 88 处 console.* 替换为 logger.*
- 前缀规范化
- ESLint
no-console规则启用
-
Phase 4:error.tsx 上报
- 创建
use-error-reportHook - 创建
/api/client-errorRoute Handler - 130 个 error.tsx 接入
- 创建
-
Phase 5:架构同步
- 更新 004 / 005 / known-issues.md
- 验证 tsc / lint / 测试通过