在Next.js + tRPC + Auth.js的T3应用中为Azure Application Insights配置用户ID
我之前在T3栈里也遇到过几乎一模一样的问题——想把Auth.js的用户ID关联到Azure Application Insights的请求追踪里,折腾了好一会儿才搞清楚Context传播和Span生命周期的坑。下面是我验证过的可行方案,分步骤给你讲清楚:
为什么你之前的方法没生效?
核心问题是OpenTelemetry的Context和tRPC/Next.js的请求上下文没有正确绑定:
- 你在tRPC中间件或layout里设置的Context值,没有被传播到Span的生命周期中(Span创建时的parentContext没有包含你的
KEY_USER_ID) SpanProcessor的onEnd阶段,当前活跃的Context可能已经切换到了其他请求,导致拿不到正确的用户ID
解决方案:从两个维度入手
我们要确保请求早期获取用户ID,并把它注入到当前请求的所有Span中,分两种场景处理:tRPC接口请求和Next.js Server Component页面请求。
1. 先配置自定义的SpanProcessor(关键)
先写一个能从Span的父Context中读取用户ID并注入属性的SpanProcessor,这次我们用onStart方法(比onEnd可靠,因为它在Span创建时就执行,能拿到创建Span时的上下文):
// src/utils/otelSpanProcessor.ts import { SpanProcessor, ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { context, ContextKey } from "@opentelemetry/api"; import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; // 创建全局的Context Key,用于存储用户ID export const USER_ID_CONTEXT_KEY: ContextKey<string> = context.createContextKey( "AUTH_USER_ID" ); export class UserIdEnrichingProcessor implements SpanProcessor { // 当Span创建时执行,这里能拿到创建Span的父上下文 onStart(span: ReadableSpan, parentContext: context.Context) { const userId = parentContext.getValue(USER_ID_CONTEXT_KEY); if (userId) { // 用OpenTelemetry标准的用户ID属性键 span.setAttribute(SemanticAttributes.ENDUSER_ID, userId); } } onEnd(_span: ReadableSpan) { // 这里可以留空,或者做其他收尾操作 } // 必须实现的SpanProcessor接口方法 forceFlush(): Promise<void> { return Promise.resolve(); } shutdown(): Promise<void> { return Promise.resolve(); } }
然后在instrumentation.ts里把这个处理器加进去:
// instrumentation.ts import { useAzureMonitor } from "@azure/monitor-opentelemetry"; import { UserIdEnrichingProcessor } from "~/utils/otelSpanProcessor"; export function register() { useAzureMonitor({ azureMonitorExporterOptions: { connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, }, spanProcessors: [new UserIdEnrichingProcessor()], }); }
2. 在请求早期注入用户ID到OTel Context
接下来要在请求处理的最早期拿到用户ID,并存入OTel的Context中,这样后续创建的所有Span都能通过onStart方法获取到用户ID。
场景1:tRPC接口请求
T3栈的tRPC Context已经集成了Auth.js的Session,我们直接在全局中间件里注入用户ID:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerAuthSession } from "~/server/auth"; import { context } from "@opentelemetry/api"; import { USER_ID_CONTEXT_KEY } from "~/utils/otelSpanProcessor"; const createInnerTRPCContext = async (opts: { session: Awaited<ReturnType<typeof getServerAuthSession>>; req: Request; res: Response; }) => { // 拿到用户ID后,更新OTel的当前上下文 if (opts.session?.user?.id) { const currentCtx = context.active(); const newCtx = currentCtx.setValue(USER_ID_CONTEXT_KEY, opts.session.user.id); // 把新的上下文设为活跃上下文,确保后续Span创建用这个上下文 context.with(newCtx, () => {}); } return { session: opts.session, req: opts.req, res: opts.res, }; }; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerAuthSession({ req: opts.req, res: opts.res }); return createInnerTRPCContext({ session, req: opts.req, res: opts.res }); }; const t = initTRPC.context<typeof createTRPCContext>().create(); // 全局中间件,确保已认证的请求都注入用户ID export const middleware = t.middleware; export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } // 双重保险,再次确认用户ID注入 if (ctx.session.user.id) { const currentCtx = context.active(); const newCtx = currentCtx.setValue(USER_ID_CONTEXT_KEY, ctx.session.user.id); context.with(newCtx, () => {}); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const publicProcedure = t.procedure; export const router = t.router;
场景2:Next.js Server Component页面请求
对于App Router的Server Component(比如layout.tsx或page.tsx),我们在服务器端获取Session后注入用户ID:
// app/layout.tsx import { getServerAuthSession } from "~/server/auth"; import { context } from "@opentelemetry/api"; import { USER_ID_CONTEXT_KEY } from "~/utils/otelSpanProcessor"; export default async function RootLayout({ children, }: { children: React.ReactNode; }) { const session = await getServerAuthSession(); if (session?.user?.id) { const currentCtx = context.active(); const newCtx = currentCtx.setValue(USER_ID_CONTEXT_KEY, session.user.id); // 确保后续页面组件渲染时用这个上下文 context.with(newCtx, () => {}); } return ( <html lang="en"> <body>{children}</body> </html> ); }
3. 更直接的备选方案:直接修改当前Span属性
如果上面的Context传播还是有问题,你可以跳过SpanProcessor,直接在拿到用户ID后修改当前活跃Span的属性:
// 在tRPC Context或Server Component里 import { trace, context } from "@opentelemetry/api"; import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; if (session?.user?.id) { const activeSpan = trace.getSpan(context.active()); if (activeSpan) { activeSpan.setAttribute(SemanticAttributes.ENDUSER_ID, session.user.id); } }
这种方式更简单,适合快速验证,缺点是需要在每个需要的地方重复代码,而SpanProcessor是全局生效的。
验证是否生效
部署后,打开Azure Portal的Application Insights,进入Logs,执行以下查询:
traces | where customDimensions contains "ENDUSER_ID" | project timestamp, message, customDimensions.ENDUSER_ID
如果能看到用户ID,就说明配置成功了!
常见坑点
- Runtime兼容:确保你的OTEL配置是针对Node.js Runtime的,Next.js Edge Runtime的OTEL配置逻辑不同,T3栈默认用Node.js Runtime。
- Session获取时机:必须在服务器端获取Session,客户端无法拿到安全的用户ID,所以只能在Server Components、tRPC Procedures、API路由里操作。
- Context传播:用
context.with(newCtx, () => {})确保后续操作在新的上下文环境中执行,否则Span创建时可能还是用旧的上下文。
内容来源于stack exchange

