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_search 和 structured_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 都完整写入数据库,存储成本会很快失控。原因很简单:一条请求不止有一个节点,每个节点还有 input 和 output 字段。以 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 的第一手证据
- 降级请求也值钱,因为它往往解释了"为什么回复没那么好"
- 超慢请求也值钱,因为它们是性能问题的证据
- 正常请求只留样本,用于整体趋势分析即可
这里的"摘要存储"通常只保留节点名称、耗时和状态,不保存详细的 input 和 output。这样一条记录的体积会从 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 请求处理流程是这样的:
- 请求到达 → 执行处理逻辑 → 返回响应 → Worker 可能被销毁
使用 waitUntil 后的流程变成:
- 请求到达 → 执行处理逻辑 → 返回响应给用户
- 同时,
waitUntil中注册的后台任务继续执行 - 后台任务全部完成后,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. 总结
这一篇落地的内容可以归纳为三件事:
- 用 TraceContext 统一承接每个节点的结构化记录——提供
span()方法自动完成计时、状态判定和记录,节点接入成本极低 - 用"异常全量、正常采样"的方式控制存储成本——错误、降级、超慢的请求完整保留,正常请求只存摘要
- 用 D1 + KV + waitUntil 实现分层架构——D1 存完整现场用于排查,KV 存聚合摘要用于监控,waitUntil 确保写入不阻塞响应
到这里,单次请求的"现场捕获"已经成立了。下一篇再补上另一半能力:如何通过 Metrics 看整体健康度,以及当线上出现"回复不对""变慢了""情绪跳变"时,具体应该怎么排查。