用户提交导出任务,API 返回 202,几分钟后页面只显示“导出失败”。前端有 requestId,API 日志能查到入队成功,Worker 日志却使用另一个 ID。我们只能按时间、用户和文件名猜哪条任务对应哪次请求,跨过消息队列后链路就断了。

后来接入 OpenTelemetry 时,我们没有只给 HTTP 自动埋点,而是明确规定上下文怎样进入消息头、怎样在 Worker 恢复,以及异步重试如何表达新的执行尝试。

traceparent 从浏览器经过 API 和消息队列传播到 Worker 的时序

图 1:队列不是追踪终点;生产 span 与消费 span 通过消息上下文关联。

traceId 与 jobId 解决不同问题

traceId 描述一次执行链,适合性能和错误定位;jobId 描述长期业务任务,跨重试、暂停和人工恢复保持稳定。一次 Job 可能对应多个 trace。

type ExportMessage = {
  jobId: string;
  attempt: number;
  tenantId: string;
  payload: ExportInput;
};

trace context 放消息 headers,不混入业务 payload。日志同时记录 traceId、spanId、jobId 和 attempt,既能沿执行链看时间,也能汇总任务历史。

生产消息时注入上下文

import { context, propagation, trace } from "@opentelemetry/api";
 
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
 
await queue.publish("export.requested", message, { headers });

消息中间件可能只接受字符串 header,需要显式序列化。不要把整个上下文对象 JSON 化,因为标准传播字段和采样标志需要被其他语言理解。

消费时恢复,但不伪造同步父子关系

队列消息可能等待很久、被多个消费者处理或批量消费。我们根据语义选择 parent 或 link。一次普通单消费可以以提取上下文为父:

const parent = propagation.extract(context.active(), delivery.headers);
 
await context.with(parent, async () => {
  const tracer = trace.getTracer("export-worker");
  return tracer.startActiveSpan("export.process", async span => {
    try {
      await processExport(delivery.message);
      span.setStatus({ code: 1 });
    } catch (error) {
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end();
    }
  });
});

批处理由多个消息共同触发时,更适合新建 span 并链接多个来源,避免假装只有一个父节点。

采样不能让错误证据全部消失

全量 trace 成本太高,我们对普通成功请求低比例采样,对高风险任务和错误提高保留率。头部采样在请求开始时决定,无法预知后续失败;因此 Collector 还使用尾部采样,根据错误、长延迟和关键属性决定保留整条 trace。

属性 用途 注意
service.name 区分服务 使用稳定名称,不含实例 ID
job.type 分析任务类型 控制基数
tenant.id 权限过滤与聚合 按政策脱敏,不作为公开标签
attempt 区分重试 与 jobId 配合
error.code 聚合失败类型 不使用动态 message

上下文传播也要做契约测试

我们在集成环境发送带已知 traceparent 的消息,断言 Worker span 与其关联;重试产生新 span 但 jobId 不变;死信记录最后 traceId;日志能够从 trace 跳到 job 页面,再从 job 页面列出所有 attempts。

链路接通后,那类“API 成功、后台失败”的问题不再靠时间猜测。更重要的是,我们没有把 traceId 当万能业务 ID。追踪关注一次执行,业务模型关注一个长期任务;把两者同时保留,系统才既能诊断性能,也能解释用户结果。

这两类 ID 的边界会在 Agent 场景里被进一步放大:模型的一次调用既是执行链的一部分,又是一个需要审批、重试和补偿的长期任务。我在人工审批不是弹窗里把 planId、taskId 与执行尝试分离开,正是同一原则在事务边界的延伸。可观测性的整体组织方式则在可观测性不是一块 Grafana 大屏里展开。