一个线上请求从进入Node.js进程到返回响应,通常会经过路由、控制器、服务层、数据库和多次下游HTTP调用。排查慢请求或错误时,如果只看单行日志,根本无法区分这些日志属于哪个请求。TraceID的核心价值就是给同一次调用链路上的所有日志打上同一个标识,检索时输入一个TraceID就能还原整条链路。对Express应用来说,实现TraceID并不需要立刻接入重量级APM,只需做好三件事:唯一ID生成、异步上下文传递、日志与下游透传。在深入中间件之前,先区分TraceID和SpanID这两个概念。

一、从TraceID和SpanID的差异理解链路追踪
TraceID标识的是整个请求调用链,而SpanID标识的是这条链路中的某个具体操作片段。一个TraceID下面可以挂多个SpanID,例如Express中间件本身可以看作一个Span,数据库查询是另一个Span,向下游API发起HTTP请求又是一个Span。只记录TraceID可以聚合日志,但无法还原调用顺序和耗时分布,所以在轻量实现中至少要生成 traceId 和 spanId 两个字段。
TraceID需要全局唯一,常见方案有UUID v4、crypto.randomUUID 以及雪花算法。UUID v4通常包含连字符,为了方便日志检索,可以统一去掉连字符,整理成32位十六进制字符串。如果对ID长度比较敏感,也可以使用16字节随机数转成hex字符串。SpanID一般取8字节随机数即可。生成逻辑要足够快,不能成为请求入口的性能瓶颈。Node.js 14.17以上版本可以直接使用 crypto.randomUUID,无需额外依赖。
const crypto = require('crypto');
function generateTraceId() {
return crypto.randomUUID().replace(/-/g, '');
}
function generateSpanId() {
return crypto.randomBytes(8).toString('hex');
}
二、为什么不能只挂载req.traceId
很多Express开发者在中间件里习惯把traceId挂到 req 对象上,例如 req.traceId = traceId,然后在业务代码里通过 req.traceId 读取。这种方式在同步路由里完全可用,但在Promise、定时器、数据库回调、事件监听等异步场景中,req 对象并不一定能在所有函数参数里拿到。如果强行层层传递,函数签名会逐渐膨胀,代码也会变得难以维护。
Node.js提供了 AsyncLocalStorage,可以优雅地解决异步上下文传递问题。它基于 async_hooks 追踪异步资源生命周期,每个异步执行链都会保留独立的存储副本。在请求入口用 als.run 传入上下文,之后无论经过多少层异步调用,通过 als.getStore 取到的都是当前请求的上下文,不会发生并发串数据。该API已经稳定,不再是实验性能力。
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
function getTraceContext() {
return als.getStore() || {};
}
function runWithTraceContext(store, fn) {
return als.run(store, fn);
}
module.exports = {
als: als,
getTraceContext: getTraceContext,
runWithTraceContext: runWithTraceContext
};
入口统一通过 runWithTraceContext 执行,业务代码任意位置再调用 getTraceContext 获取当前请求的traceId和spanId。这样既不需要修改数据库访问层,也不会破坏原有函数签名。
三、在Express中落地TraceID中间件
这个中间件应该放在最前面,可以早于 express.json() 等解析中间件。它先读取请求头中的 x-trace-id,如果上游服务已经生成,就沿用;否则新生成一个traceId。同时无论是否来自上游,都为当前服务生成一个新的spanId,表示Express这一环。最后把traceId通过响应头返回给调用方,方便前端或网关在排障时直接拿到。
中间件还要调用 als.run,并把 next() 放到回调里执行。只有这样才能确保后续路由和异步逻辑共享同一个上下文。不要在 als.run 外面直接调用 next,否则异步上下文会丢失,后续日志取到的可能是空对象。
const crypto = require('crypto');
const { als } = require('./tracing');
app.use(function (req, res, next) {
const incoming = req.headers['x-trace-id'];
let traceId = incoming;
if (typeof traceId !== 'string' || traceId === '') {
traceId = crypto.randomUUID().replace(/-/g, '');
}
const spanId = crypto.randomBytes(8).toString('hex');
res.setHeader('x-trace-id', traceId);
als.run({ traceId: traceId, spanId: spanId, startTime: Date.now() }, function () {
next();
});
});
如果应用启用了跨域,需要在 Access-Control-Expose-Headers 中加入 x-trace-id,否则浏览器端无法通过 fetch 或 XMLHttpRequest 读取这个响应头。另外也不要无条件信任上游传入的header,可以做长度裁剪,避免非法字符被写入日志系统。
四、日志集成与下游透传,让TraceID真正贯穿全链路
所有日志必须携带traceId,否则链路追踪就失去了意义。可以封装一个 log 函数,内部调用 getTraceContext 获取当前traceId和spanId,再用 console.log 或 pino 输出。pino是结构化日志库,输出JSON格式,方便ELK、Loki这类系统按字段过滤。使用pino时可以把traceId作为base字段注入,子logger会自动继承,业务代码无需重复传入。
const { getTraceContext } = require('./tracing');
function log(message, extra) {
extra = extra || {};
const ctx = getTraceContext();
console.log(JSON.stringify({
time: new Date().toISOString(),
traceId: ctx.traceId || '-',
spanId: ctx.spanId || '-',
message: message,
extra: extra
}));
}
下游HTTP调用同样需要透传。使用 http 或 axios 时,在请求头中带上 x-trace-id。服务B的Express中间件检测到该头后沿用,不重新生成,这样跨服务后日志里的traceId才能保持一致。如果下游遵循W3C Trace Context,可以优先解析 traceparent 头,它的结构是版本号、traceId、parentId、采样标志四段,例如 00-<trace-id-value>-<span-id-value>-01,尖括号只是占位说明,实际报文里是十六进制值。可以使用 split('-') 处理。
const http = require('http');
const { getTraceContext } = require('./tracing');
function callDownstream(url, options) {
options = options || {};
const ctx = getTraceContext();
const headers = Object.assign({}, options.headers, {
'x-trace-id': ctx.traceId || ''
});
const requestOptions = Object.assign({}, options, { headers: headers });
return new Promise(function (resolve, reject) {
const req = http.request(url, requestOptions, function (res) {
let body = '';
res.on('data', function (chunk) {
body += chunk;
});
res.on('end', function () {
resolve({ statusCode: res.statusCode, body: body });
});
});
req.on('error', reject);
req.end();
});
}
数据库和消息队列场景也可以传递TraceID。MySQL可以放到会话变量或SQL注释里,但不建议每条SQL都拼接TraceID,避免影响执行计划缓存;消息队列可以直接使用消息头传递。同时日志输出时避免记录请求体、手机号、密码等敏感数据,只保留traceId、spanId、接口路径、耗时和错误信息即可。
至此已经覆盖了入口生成、异步上下文、日志输出和下游传播四个关键环节。之后如果需要接入OpenTelemetry等标准协议,只需把现有 x-trace-id 逻辑替换为W3C traceparent,同时保留SpanID,就可以平滑演进到完整分布式追踪系统。