Files
NextEdu/docs/superpowers/specs/2026-07-07-logging-refactor-design.md
SpecialX 5d9981fd7d
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
docs(architecture): update impact map, data, audit reports, superpowers docs
- 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
2026-07-07 16:23:35 +08:00

24 KiB
Raw Blame History

日志系统重构设计文档

字段
文档版本 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 / handleApiErrorconsole.error,前缀混乱([ExamAction] / [ActionError] / [ApiError] / 无前缀混用)
track-event ⚠️ 重复 4 个 no-op stubshared / 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.tsapi-response.ts、各 Server Action
console.warn 5 5 api/web-vitals/route.ts、redis/store 等
console.info 10 6 track-event.tsapi/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_hooksEdge 不支持)
  • 其余部分RSC、Server Action、Route Handler、data-access运行在 Node.js runtimeAsyncLocalStorage 完全支持
  • 项目自部署(next.config.tsoutput: "standalone"),运维通过 docker logs 查看日志

2. 目标与非目标

2.1 目标

  1. 统一 logger 抽象pino 替换 88 处散乱 console.*,提供 info/error/warn/debug + createModuleLogger(module) 子 logger
  2. 级别控制LOG_LEVEL 环境变量控制输出阈值debug/info/warn/errorZod 校验
  3. 结构化输出:生产环境 JSON含 timestamp/level/module/requestId/msg开发环境 pino-pretty 彩色文本
  4. Request ID 贯穿proxy.ts 生成 ID 并注入请求头Node.js runtime 通过 AsyncLocalStorage 贯穿到 data-access / audit-logger
  5. 静默失败告警:三个 audit-logger 的 catch 块从 silent 改为 logger.warn
  6. 统一错误处理接入handleActionError / handleApiError / safeActionCall 接入 logger前缀通过 module 字段规范化
  7. track-event 去重合并4 个 no-op stub 合并为 1 个,统一通过 createModuleLogger("track")
  8. error.tsx 错误上报130 个 error.tsx 通过 useErrorReport Hook 上报到 /api/client-error,含节流防风暴
  9. 架构图同步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 新增依赖 pinopino-prettydev
src/env.mjs 新增字段 LOG_LEVEL(默认 infoZod 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 输出到 stdoutdocker 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

  // 生成或复用 requestIdWeb Crypto APIEdge 兼容)
  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 中可能有问题 已配置 serverExternalPackagespino 仅服务端导入;开发期 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 实例 + createModuleLogger
  • shared/lib/request-context.ts — AsyncLocalStorage 请求上下文
  • shared/lib/with-request-context.ts — Server Action / Route Handler 入口包装
  • shared/hooks/use-error-report.ts — 客户端错误上报 Hook
  • app/api/client-error/route.ts — 客户端错误接收端点

修改章节:

  • proxy.ts — 增加 requestId 注入逻辑
  • shared/lib/audit-logger.ts / change-logger.ts / login-logger.ts — 静默失败改为 logger.warn
  • shared/lib/track-event.ts — 实现层从 no-op 改为 logger.info
  • shared/lib/action-utils.ts / api-response.ts — 接入 logger

删除记录:

  • modules/rbac/lib/track.ts
  • modules/course-plans/lib/track-event.ts
  • modules/questions/utils/track-event.ts

8.2 docs/architecture/005_architecture_data.json

  • modules.shared.lib.exports 新增 logger / createModuleLogger / requestContextStorage / getRequestContext / withRequestContext
  • modules.shared.hooks.exports 新增 useErrorReport
  • modules.app.api.client-error.exports 新增 POST
  • dependencyMatrix 更新proxy.ts → request-context仅注入请求头不导入
  • 删除 modules.rbac.lib.trackmodules.course-plans.lib.track-eventmodules.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 错误上报:useErrorReport Hook + 节流策略
  • ESLint no-console 规则与豁免

9. 实施顺序建议

  1. Phase 1基础底座

    • 装 pino / pino-pretty
    • 创建 logger.ts / request-context.ts / with-request-context.ts
    • env.mjs 添加 LOG_LEVEL
    • 单元测试
  2. Phase 2核心接入

    • proxy.ts 注入 requestId
    • action-utils.ts / api-response.ts 接入 logger
    • 三个 audit-logger 静默失败 → logger.warn
    • track-event 去重合并
  3. Phase 3批量替换

    • 88 处 console.* 替换为 logger.*
    • 前缀规范化
    • ESLint no-console 规则启用
  4. Phase 4error.tsx 上报

    • 创建 use-error-report Hook
    • 创建 /api/client-error Route Handler
    • 130 个 error.tsx 接入
  5. Phase 5架构同步

    • 更新 004 / 005 / known-issues.md
    • 验证 tsc / lint / 测试通过