Workers 下的 Tracing 实现

要点

  • 上一篇我们已经把可观测性的核心概念讲清楚了:Trace 是一次请求的完整快照,Span 是每个节点的执行记录
  • 回顾上一篇的 Trace 示意图:一次请求包含多个 Span,每个 Span 对应一个管线节点(安全检查、记忆检索、LLM 生成等)
  • 如果你把所有 Trace 都完整写入数据库,存储成本会很快失控
  • 1 为什么要分层存储
  • 1 为什么不能在主路径里写 Trace

内容

1. 从概念走向实现:这篇文章要解决什么问题

上一篇我们已经把可观测性的核心概念讲清楚了:Trace 是一次请求的完整快照,Span 是每个节点的执行记录,degraded 状态用来标记「没有报错但效果变差」的情况。我们还对比了 LangSmith、Langfuse 和自建 Tracing 三种方案的适用场景。

这一篇不再讲「为什么」,只回答工程问题:在 Cloudflare Workers 环境里,我们怎样真正落地一套可用的 Tracing 系统。

在开始之前,先简单解释一下 Cloudflare Workers 环境的特殊性。Workers 是一种边缘计算平台,你的代码会被部署到全球几百个节点上,用户的请求会就近处理。它的优势是延迟低、离用户近,但也带来一些约束:

  • 没有持久进程:每次请求到达时,Worker 会启动执行,请求结束后就可能被销毁。你不能像传统服务器那样在内存里攒一批日志再统一写入
  • 执行时间有限:CPU 时间有上限,不适合做重计算
  • 自带配套存储:Cloudflare 提供了 D1(关系型数据库)和 KV(键值存储)等服务,可以直接在 Worker 里使用

理解了这些约束,我们的目标就很明确了:

  • 每次请求生成唯一的 traceId,用来把这次请求的所有信息串起来
  • 每个管线节点自动记录输入、输出、耗时和状态,不需要开发者手动写日志
  • 异常请求完整保留,正常请求按比例采样,控制存储成本
  • 写入过程不阻塞用户响应,Tracing 不能拖慢业务

这里最核心的设计对象,就是一个贯穿请求生命周期的 TraceContext

2. TraceContext:贯穿整条请求链路的上下文对象

2.1 先理解设计思路

回顾上一篇的 Trace 示意图:一次请求包含多个 Span,每个 Span 对应一个管线节点(安全检查、记忆检索、LLM 生成等)。我们需要一个对象来承载这些 Span,这就是 TraceContext

TraceContext 的职责不是"存日志",而是把每个节点的执行过程结构化记录成 Span。你可以把它想象成一张表格:每当一个节点执行完毕,就会在这张表格里自动填入一行记录,包含这个节点的名称、耗时、输入、输出和状态。

2.2 定义 Span 的数据结构

首先定义每个 Span 需要记录哪些字段。如果你读过上一篇 Span 表格的介绍,这些字段应该不会陌生:

// span.ts
interface Span {
 
  spanId: string            // 当前节点的唯一标识
 
  parentSpanId: string | null // 父节点 ID,用于构建树形结构
 
  name: string              // 节点名称,如 'memory_retrieval'
 
  startTime: number         // 开始执行的时间戳
 
  duration: number          // 执行耗时(毫秒)
 
  status: 'ok' | 'error' | 'degraded'  // 执行状态
 
  input: Record<string, any>   // 节点接收的输入
 
  output: Record<string, any>  // 节点产生的输出
 
  metadata: Record<string, any> // 额外的诊断信息
 
}

其中 parentSpanId 的作用是支持嵌套。比如 memory_retrieval 内部可能包含 vector_searchstructured_query 两个子操作,通过 parentSpanId 就能把它们组装成树形结构,方便事后查看调用层级。

Drawing canvas

2.3 实现 TraceContext 类

有了 Span 的定义,接下来实现 TraceContext 本身。它的核心能力只有一个:提供一个 span() 方法,让你把任意异步操作包起来,自动完成计时、状态判定和记录。

// trace.ts
class TraceContext {
 
  readonly traceId: string
 
  private spans: Span[] = []
 
  private startTime: number
 
  constructor(traceId?: string) {
 
    // 如果外部传入了 traceId 就用外部的,否则自动生成一个
 
    // 这在跨服务追踪时很有用:上游服务可以把 traceId 传下来
 
    this.traceId = traceId ?? crypto.randomUUID()
 
    this.startTime = Date.now()
 
  }
 
  // 核心方法:包装一个异步操作,自动记录为 Span
 
  // <T> 是 TypeScript 的泛型——意思是"返回类型取决于你传入的函数返回什么"
 
  // 比如 fn 返回 Promise<string>,那 span() 的返回值也是 Promise<string>
 
  async span<T>(
 
    name: string,
 
    input: Record<string, any>,
 
    fn: () => Promise<T>,
 
    parentSpanId?: string
 
  ): Promise<T> {
 
    const spanId = crypto.randomUUID()
 
    const start = Date.now()
 
    let status: Span['status'] = 'ok'
 
    let output: Record<string, any> = {}
 
    try {
 
      const result = await fn()
 
      output = { result }
 
      return result
 
    } catch (err) {
 
      status = 'error'
 
      output = { error: (err as Error).message }
 
      throw err  // 记录完错误后继续抛出,不吞掉异常
 
    } finally {
 
      // 无论成功还是失败,都会执行 finally 块
 
      // 确保这个 Span 一定会被记录下来
 
      this.spans.push({
 
        spanId,
 
        parentSpanId: parentSpanId ?? null,
 
        name,
 
        startTime: start,
 
        duration: Date.now() - start,
 
        status,
 
        input,
 
        output,
 
        metadata: {}
 
      })
 
    }
 
  }
 
  // 手动将某个已记录的 Span 标记为降级状态
 
  // 适用于"执行成功了,但结果不理想"的场景
 
  markDegraded(spanName: string, reason: string) {
 
    const span = this.spans.find(s => s.name === spanName)
 
    if (span) {
 
      span.status = 'degraded'
 
      span.metadata.degradeReason = reason
 
    }
 
  }
 
  // 判断是否有任何 Span 出错
 
  hasError(): boolean {
 
    return this.spans.some(s => s.status === 'error')
 
  }
 
  // 判断是否有任何 Span 降级
 
  hasDegraded(): boolean {
 
    return this.spans.some(s => s.status === 'degraded')
 
  }
 
  // 获取从创建到现在的总耗时
 
  get totalDuration(): number {
 
    return Date.now() - this.startTime
 
  }
 
  // 将整个 Trace 序列化为 JSON,方便存储和传输
 
  toJSON() {
 
    return {
 
      traceId: this.traceId,
 
      totalDuration: this.totalDuration,
 
      spanCount: this.spans.length,
 
      spans: this.spans
 
    }
 
  }
 
}

这个抽象的好处是,节点侧的接入成本非常低。你不需要在每个节点里手写"开始计时、结束计时、异常捕获、结构化输出",只要用 trace.span() 把逻辑包起来就够了。

2.4 实际使用示例

下面展示在 AI 管线中如何使用 TraceContext。假设用户发了一条消息,我们需要依次执行安全检查和记忆检索:

// usage.ts
// 1. 在请求入口创建 TraceContext 实例
 
const trace = new TraceContext()
 
// 2. 用 trace.span() 包装每个管线节点
 
//    第一个参数是节点名称,第二个是输入数据,第三个是要执行的异步函数
 
const safetyResult = await trace.span(
 
  'input_safety_check',
 
  { message: userMessage },
 
  () => runSafetyCheck(userMessage)
 
)
 
const memories = await trace.span(
 
  'memory_retrieval',
 
  { query: userMessage, sessionId },
 
  () => retrieveMemories(env, userMessage, sessionId)
 
)
 
// 3. 根据执行结果,手动标记降级状态
 
//    记忆检索成功了(没报错),但结果为空,说明效果不理想
 
if (memories.length === 0) {
 
  trace.markDegraded('memory_retrieval', 'no_memories_found')
 
}
 
// 4. 后续节点继续用 trace.span() 包装...
 
const response = await trace.span(
 
  'llm_generation',
 
  { prompt: assembledPrompt },
 
  () => callLLM(assembledPrompt)
 
)

每个 trace.span() 调用都会自动记录该节点的耗时、输入、输出和状态。整个过程对业务代码的侵入非常小——你只是把原本的函数调用包了一层,不需要修改函数本身的逻辑。

这类设计尤其适合 AI 管线,因为你的节点很多,而且每个节点都值得被单独诊断。

3. 采样策略:不是所有 Trace 都值得完整保存

3.1 为什么需要采样

如果你把所有 Trace 都完整写入数据库,存储成本会很快失控。原因很简单:一条请求不止有一个节点,每个节点还有 inputoutput 字段。以 LLM 生成节点为例,input 是完整的 Prompt(可能几千个 Token),output 是模型的完整回复。一条 Trace 的数据量可能就达到几 KB 甚至几十 KB,乘以每天几十万次请求,存储开销会非常可观。

所以工程上更现实的做法是:异常请求全量保留,正常请求按比例采样。

3.2 实现采样决策

采样函数的逻辑其实很朴素:逐条检查这个 Trace 是否"值得完整保存",如果不值得,就只存一份轻量摘要。

// sampling.ts
// 采样配置
 
interface SamplingConfig {
 
  latencyP99Threshold: number  // 延迟阈值(毫秒),超过这个值视为异常慢
 
  normalSampleRate: number     // 正常请求的采样比例,如 0.05 表示 5%
 
}
 
// 采样决策结果
 
interface SampleDecision {
 
  store: 'full' | 'summary'   // 完整存储 or 仅存摘要
 
  destination: 'd1' | 'kv'    // 存到 D1 数据库 or KV 键值存储
 
}
 
function shouldSample(
 
  trace: TraceContext,
 
  config: SamplingConfig
 
): SampleDecision {
 
  // 规则 1:有错误的请求,必须完整保留
 
  // 这是最重要的数据,排查问题全靠它
 
  if (trace.hasError()) {
 
    return { store: 'full', destination: 'd1' }
 
  }
 
  // 规则 2:有降级的请求,也完整保留
 
  // 降级请求往往能解释"为什么回复质量变差了"
 
  if (trace.hasDegraded()) {
 
    return { store: 'full', destination: 'd1' }
 
  }
 
  // 规则 3:耗时异常高的请求,完整保留
 
  // 这些是性能问题的直接证据
 
  if (trace.totalDuration > config.latencyP99Threshold) {
 
    return { store: 'full', destination: 'd1' }
 
  }
 
  // 规则 4:正常请求按比例随机采样
 
  // 比如 normalSampleRate = 0.05,则只有 5% 的正常请求会被完整保存
 
  if (Math.random() < config.normalSampleRate) {
 
    return { store: 'full', destination: 'd1' }
 
  }
 
  // 规则 5:其余正常请求只存摘要
 
  return { store: 'summary', destination: 'kv' }
 
}

3.3 采样策略背后的思路

这套策略背后的优先级排序非常朴素:

  • 错误请求最值钱,必须保留——这是排查 bug 的第一手证据
  • 降级请求也值钱,因为它往往解释了"为什么回复没那么好"
  • 超慢请求也值钱,因为它们是性能问题的证据
  • 正常请求只留样本,用于整体趋势分析即可

这里的"摘要存储"通常只保留节点名称、耗时和状态,不保存详细的 inputoutput。这样一条记录的体积会从 KB 级压缩到几十字节级,非常适合用来做高频的聚合统计。

4. 存储分层:D1 存完整现场,KV 存高频摘要

4.1 为什么要分层存储

在采样策略里,我们把 Trace 分成了"完整存储"和"摘要存储"两类。这两类数据的特性和用途完全不同,所以存储方式也不一样。

先解释一下 Cloudflare 提供的两种存储服务:

  • D1 是 Cloudflare 提供的关系型数据库(基于 SQLite)。它支持 SQL 查询,适合存储需要按条件检索的结构化数据。比如"查出某个用户过去 24 小时内所有报错的 Trace"
  • KV 是 Cloudflare 提供的全球分布式键值存储。读取速度极快,但只支持通过 key 查找 value,不支持复杂查询。适合存储需要高频读写的简单数据

基于这两种存储的特性,我们的分层策略是:

  • D1 存完整 Trace:支持按用户、时间、状态等条件搜索,用于事后排查具体问题
  • KV 存高频摘要:存放每分钟的请求总数、耗时分布、错误数等聚合数据,用于看系统整体健康度

4.2 D1 表结构设计

完整 Trace 写入 D1 时,需要两张表:traces 存一次请求的总体信息,spans 存每个节点的细节。分成两张表是因为一个 Trace 包含多个 Span,这是典型的一对多关系。

// schema.sql
-- traces 表:每条记录代表一次完整请求
 
CREATE TABLE traces (
 
  trace_id TEXT PRIMARY KEY,          -- 请求唯一标识
 
  user_id TEXT NOT NULL,              -- 用户 ID
 
  session_id TEXT NOT NULL,           -- 会话 ID
 
  total_duration INTEGER NOT NULL,    -- 请求总耗时(毫秒)
 
  has_error BOOLEAN DEFAULT FALSE,    -- 是否有节点报错
 
  has_degraded BOOLEAN DEFAULT FALSE, -- 是否有节点降级
 
  created_at TEXT NOT NULL            -- 创建时间
 
);
 
-- spans 表:每条记录代表一个管线节点的执行记录
 
CREATE TABLE spans (
 
  span_id TEXT PRIMARY KEY,           -- 节点唯一标识
 
  trace_id TEXT NOT NULL,             -- 关联的请求 ID
 
  parent_span_id TEXT,                -- 父节点 ID,用于还原树形调用层级
 
  name TEXT NOT NULL,                 -- 节点名称
 
  start_time INTEGER NOT NULL,        -- 开始时间戳,用于还原各节点的执行时序
 
  duration INTEGER NOT NULL,          -- 节点耗时(毫秒)
 
  status TEXT NOT NULL,               -- 状态:ok / error / degraded
 
  input TEXT,                         -- 输入数据(JSON 字符串)
 
  output TEXT,                        -- 输出数据(JSON 字符串)
 
  metadata TEXT,                      -- 诊断附加信息(JSON 字符串)
 
  FOREIGN KEY (trace_id) REFERENCES traces(trace_id)
 
);
 
-- 索引:加速常用的查询场景
 
CREATE INDEX idx_traces_user ON traces(user_id, created_at);
 
CREATE INDEX idx_traces_error ON traces(has_error, created_at);
 
CREATE INDEX idx_spans_trace ON spans(trace_id);

三个索引分别服务于三种典型查询场景:

  • idx_traces_user:按用户 + 时间查询,比如"查某用户最近的请求"
  • idx_traces_error:按错误状态 + 时间查询,比如"查最近 1 小时所有报错的请求"
  • idx_spans_trace:按 trace_id 查询所有 Span,比如"展开某次请求的完整细节"

4.3 实现完整 Trace 的写入

下面是将一个 TraceContext 实例写入 D1 的具体代码。在 Workers 环境中,env.DB 是 Cloudflare 绑定的 D1 数据库实例,可以直接使用。

// store-trace.ts
async function storeFullTrace(
 
  env: { DB: D1Database },
 
  trace: TraceContext,
 
  userId: string,
 
  sessionId: string
 
) {
 
  const data = trace.toJSON()
 
  // 第一步:写入 traces 主表
 
  await env.DB.prepare(
 
    `INSERT INTO traces
 
      (trace_id, user_id, session_id, total_duration, has_error, has_degraded, created_at)
 
     VALUES (?, ?, ?, ?, ?, ?, ?)`
 
  ).bind(
 
    data.traceId,
 
    userId,
 
    sessionId,
 
    data.totalDuration,
 
    data.spans.some(s => s.status === 'error'),
 
    data.spans.some(s => s.status === 'degraded'),
 
    new Date().toISOString()
 
  ).run()
 
  // 第二步:批量写入 spans 子表
 
  // 使用 env.DB.batch() 把多条插入合并为一次数据库操作,减少网络开销
 
  const stmt = env.DB.prepare(
 
    `INSERT INTO spans
 
      (span_id, trace_id, parent_span_id, name, start_time, duration, status, input, output, metadata)
 
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
 
  )
 
  await env.DB.batch(
 
    data.spans.map(span =>
 
      stmt.bind(
 
        span.spanId,
 
        data.traceId,
 
        span.parentSpanId,
 
        span.name,
 
        span.startTime,
 
        span.duration,
 
        span.status,
 
        JSON.stringify(span.input),
 
        JSON.stringify(span.output),
 
        JSON.stringify(span.metadata)
 
      )
 
    )
 
  )
 
}

这里有一个关键细节:env.DB.batch() 会把多条 SQL 语句合并为一次数据库调用。如果一个 Trace 有 8 个 Span,不用 batch 就需要 8 次网络请求,用了 batch 只需要 1 次。在边缘环境中,减少网络请求次数对性能影响非常大。

4.4 实现摘要数据的写入

对于不需要完整保存的正常请求,我们只在 KV 中更新聚合统计。env.KV 是 Cloudflare 绑定的 KV 存储实例。

// store-summary.ts
async function storeSummary(
 
  env: { KV: KVNamespace },
 
  trace: TraceContext
 
) {
 
  const data = trace.toJSON()
 
  // 以分钟为粒度生成 key,格式如 "metrics:2026-03-12T10:05"
 
  const minuteKey = `metrics:${new Date().toISOString().slice(0, 16)}`
 
  // 读取当前这一分钟已有的聚合数据
 
  const existing = await env.KV.get(minuteKey, 'json') as {
 
    requestCount: number
 
    errorCount: number
 
    degradedCount: number
 
    totalDuration: number
 
  } | null
 
  const current = existing ?? {
 
    requestCount: 0,
 
    errorCount: 0,
 
    degradedCount: 0,
 
    totalDuration: 0,
 
  }
 
  // 累加本次请求的统计
 
  current.requestCount += 1
 
  current.errorCount += data.spans.filter(s => s.status === 'error').length
 
  current.degradedCount += data.spans.filter(s => s.status === 'degraded').length
 
  current.totalDuration += data.totalDuration
 
  // 写回 KV,设置 24 小时过期(摘要数据不需要长期保留)
 
  await env.KV.put(minuteKey, JSON.stringify(current), {
 
    expirationTtl: 86400  // 24 小时后自动删除
 
  })
 
}

这样整套方案的分工就非常清晰:

  • D1:回答"某个用户某次请求到底发生了什么"——用于事后排查
  • KV:回答"最近 1 小时系统整体表现如何"——用于实时监控

注意:上面 storeSummary 的"先读后写"存在并发竞态问题。 如果同一时刻两个请求都读到了同一份旧数据,各自累加后写回 KV,就会丢失其中一个请求的计数。在低流量场景下误差可以接受;在高并发场景下,你需要用 Cloudflare Durable Objects(一种支持单点强一致的边缘存储)来替代 KV 做原子计数,或者改为"只写不聚合"——每条请求写一个独立的 KV key,再由定时任务统一汇总。

5. waitUntil:把 Tracing 从主路径里挪出去

5.1 为什么不能在主路径里写 Trace

Tracing 再重要,也不该让用户为它额外等待。

假设写入 D1 需要 50ms,写入 KV 需要 10ms。如果把这些操作放在返回响应之前执行,用户的每次请求都会多等 60ms。对于一个 AI 伴侣来说,首字节时间本来就受 LLM 调用影响(通常 1-2 秒),再额外加上存储写入的时间,体验会更差。

5.2 waitUntil 的工作原理

Cloudflare Workers 提供了一个 waitUntil API,它的作用是:告诉运行时"我还有一些后台任务要执行,请不要在响应返回后立刻销毁 Worker"。

普通的 Workers 请求处理流程是这样的:

  1. 请求到达 → 执行处理逻辑 → 返回响应 → Worker 可能被销毁

使用 waitUntil 后的流程变成:

  1. 请求到达 → 执行处理逻辑 → 返回响应给用户
  2. 同时,waitUntil 中注册的后台任务继续执行
  3. 后台任务全部完成后,Worker 才会被销毁

这意味着用户拿到响应和后台任务执行是并行的,用户不需要等后台任务完成。

5.3 在请求处理中使用 waitUntil

下面展示完整的请求处理流程,把 Tracing 写入放进 waitUntil

// handler.ts
// Env 类型声明了 Worker 绑定的外部资源
 
// 在 wrangler.toml 中配置 D1 和 KV 绑定后,Workers 运行时会自动注入这些对象
 
interface Env {
 
  DB: D1Database    // Cloudflare D1 关系型数据库
 
  KV: KVNamespace   // Cloudflare KV 键值存储
 
}
 
export default {
 
  // fetch 是 Workers 的入口函数,每次请求都会调用
 
  // request: 用户请求  env: 绑定的外部资源  ctx: 请求上下文(提供 waitUntil 等方法)
 
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
 
    // 1. 在请求入口创建 TraceContext
 
    const trace = new TraceContext()
 
    // 从请求中提取用户信息(实际项目中通常从 JWT token 或 session cookie 里解析)
 
    const { userId, sessionId } = await extractUserInfo(request)
 
    // 2. 执行 AI 管线(这是主路径,用户在等待这部分的结果)
 
    const response = await handleAIPipeline(request, env, trace)
 
    // 3. 采样决策
 
    const decision = shouldSample(trace, {
 
      latencyP99Threshold: 5000,  // 超过 5 秒视为异常
 
      normalSampleRate: 0.05      // 正常请求采样 5%
 
    })
 
    // 4. 把存储操作放进 waitUntil,不阻塞响应返回
 
    ctx.waitUntil(
 
      (async () => {
 
        try {
 
          if (decision.store === 'full') {
 
            // 完整写入 D1
 
            await storeFullTrace(env, trace, userId, sessionId)
 
          }
 
          // 无论是否完整存储,都更新 KV 中的聚合摘要
 
          await storeSummary(env, trace)
 
        } catch (err) {
 
          // Tracing 写入失败不应影响已返回的响应
 
          // 但需要记录下来,避免静默丢失
 
          console.error('Trace storage failed:', err)
 
        }
 
      })()
 
    )
 
    // 5. 立即返回响应,不等后台任务完成
 
    return response
 
  }
 
}

注意第 4 步:ctx.waitUntil() 接收一个 Promise,这个 Promise 里的代码会在响应返回后继续在后台执行。这样做的直接收益是:

  • 不把数据库写入时间加到用户等待时间里
  • 降低可观测性系统对主链路的性能影响
  • 让"监控系统"不反过来拖慢"业务系统"

5.4 需要注意的风险

但这也要求你在设计上接受一个现实:极少数情况下,主请求成功返回了,但后台 Trace 写入可能失败。 比如 D1 暂时不可用,或者 Worker 在后台任务执行过程中被强制回收。

所以上面的代码里加了 try/catch,确保写入失败时至少有一条错误日志。在更成熟的方案中,你还可以加上重试机制、失败计数和丢失率监控,确保 Tracing 系统本身的可靠性。

6. 总结

这一篇落地的内容可以归纳为三件事:

  1. 用 TraceContext 统一承接每个节点的结构化记录——提供 span() 方法自动完成计时、状态判定和记录,节点接入成本极低
  2. 用"异常全量、正常采样"的方式控制存储成本——错误、降级、超慢的请求完整保留,正常请求只存摘要
  3. 用 D1 + KV + waitUntil 实现分层架构——D1 存完整现场用于排查,KV 存聚合摘要用于监控,waitUntil 确保写入不阻塞响应

到这里,单次请求的"现场捕获"已经成立了。下一篇再补上另一半能力:如何通过 Metrics 看整体健康度,以及当线上出现"回复不对""变慢了""情绪跳变"时,具体应该怎么排查。