feat(logging): add withRequestContext HOF for Server Actions
This commit is contained in:
81
src/shared/lib/with-request-context.test.ts
Normal file
81
src/shared/lib/with-request-context.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
|
||||||
|
// mock next/headers 的 headers() 函数
|
||||||
|
const mockHeaders = vi.fn()
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
headers: () => mockHeaders(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// mock node:crypto 的 randomUUID(vi.stubGlobal 无法拦截内置模块导入)
|
||||||
|
// vitest 4.x 要求 mock 提供与原模块一致的导出结构(含 default)
|
||||||
|
vi.mock("node:crypto", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("node:crypto")>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: { ...actual, randomUUID: () => "generated-uuid" },
|
||||||
|
randomUUID: () => "generated-uuid",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { withRequestContext } from "./with-request-context"
|
||||||
|
import { getRequestContext } from "./request-context"
|
||||||
|
|
||||||
|
describe("withRequestContext", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHeaders.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("从 headers 读取 x-request-id 并注入 context", async () => {
|
||||||
|
mockHeaders.mockReturnValue({
|
||||||
|
get: (name: string) =>
|
||||||
|
name === "x-request-id" ? "proxy-injected-id" : null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapped = withRequestContext(async () => {
|
||||||
|
return getRequestContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await wrapped()
|
||||||
|
expect(result).toEqual({ requestId: "proxy-injected-id" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("headers 无 x-request-id 时生成新 UUID", async () => {
|
||||||
|
mockHeaders.mockReturnValue({
|
||||||
|
get: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapped = withRequestContext(async () => {
|
||||||
|
return getRequestContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await wrapped()
|
||||||
|
expect(result).toEqual({ requestId: "generated-uuid" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("包装函数的参数与返回值正常透传", async () => {
|
||||||
|
mockHeaders.mockReturnValue({ get: () => null })
|
||||||
|
|
||||||
|
const wrapped = withRequestContext(async (a: number, b: number) => {
|
||||||
|
return a + b
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await wrapped(3, 4)
|
||||||
|
expect(result).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("调用栈下游 logger 自动获得 requestId", async () => {
|
||||||
|
mockHeaders.mockReturnValue({
|
||||||
|
get: (name: string) =>
|
||||||
|
name === "x-request-id" ? "req-downstream" : null,
|
||||||
|
})
|
||||||
|
|
||||||
|
let capturedCtx: { requestId?: string } = {}
|
||||||
|
const wrapped = withRequestContext(async () => {
|
||||||
|
// 模拟 data-access 层调用
|
||||||
|
capturedCtx = getRequestContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapped()
|
||||||
|
expect(capturedCtx.requestId).toBe("req-downstream")
|
||||||
|
})
|
||||||
|
})
|
||||||
34
src/shared/lib/with-request-context.ts
Normal file
34
src/shared/lib/with-request-context.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { headers } from "next/headers"
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user