1、实现目标
上一篇介绍了可观测性的几个基本概念:Trace 是一次请求的完整快照,Span 记录每个节点的执行过程,degraded 用来标记没有报错、但执行效果已经变差的情况。我们也比较了 LangSmith、Langfuse 和自建 Tracing 三种方案各自适合的场景。
这一篇解决一个具体实现问题:如何在 Cloudflare Workers 环境中落地一套可用的 Tracing 系统。
开始设计之前,需要先了解 Cloudflare Workers 的运行环境。Workers 是边缘计算平台,代码会部署在全球数百个节点上,请求由距离用户较近的节点处理。这种模式降低了访问延迟,同时也带来了几项约束
| 约束 | 说明 |
|---|---|
| 不能依赖实例常驻 | 请求结束后 Worker 实例可能被回收,因此不能把日志长期积攒在内存中,再依赖下一次请求集中写入 |
| CPU 预算有限 | CPU 时间有套餐和配置上限,应避免在请求中执行不必要的大量计算;网络等待时间与 CPU 时间是两个不同的概念 |
| 提供配套存储 | Cloudflare 提供 D1(关系型数据库)和 KV(键值存储)等服务,可以直接在 Worker 中访问 |
基于这些约束,这套 Tracing 实现需要满足四个目标:
- 每次请求生成唯一的
traceId,用来把这次请求的所有信息串起来 - 每个管线节点都可以通过统一包装记录输入、输出、耗时和状态,减少开发者手动记录日志的工作
- 优先保留异常请求,正常请求按比例采样,控制存储成本
- 写入过程不阻塞用户响应,Tracing 不能拖慢业务
承担这些职责的核心对象是 TraceContext,它会贯穿一次请求的整个生命周期。
2、TraceContext 设计
一次请求包含多个 Span,每个 Span 对应一个管线节点,例如安全检查、记忆检索或 LLM 生成。TraceContext 负责在请求执行期间收集和管理这些 Span。
它并不只是保存几条自由文本日志,而是把每个节点的执行过程转换成结构化的 Span。每当一个节点执行结束,TraceContext 都会追加一条记录,其中包含节点名称、耗时、输入、输出和状态。
我们先定义 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, unknown> // 节点接收的输入
output: Record<string, unknown> // 节点产生的输出
metadata: Record<string, unknown> // 额外的诊断信息
}
parentSpanId 用于表示 Span 之间的嵌套关系。例如,memory_retrieval 内部包含 vector_search 和 structured_query 两个子操作,它们可以通过 parentSpanId 指向父 Span。读取 Trace 时,便能据此还原完整的调用层级。
这里的 degraded 是业务层状态,不是 OpenTelemetry 的标准 Span 状态。若要和 OpenTelemetry 对接,应映射到 UNSET、OK、ERROR 等标准状态,再把 degraded 作为业务属性或事件记录。input、output 也不应默认保存原始用户消息,生产环境应先做脱敏、截断或哈希处理。
定义好 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, unknown>,
fn: () => Promise<T>,
parentSpanId?: string
): Promise<T> {
const spanId = crypto.randomUUID()
const start = Date.now()
let status: Span['status'] = 'ok'
let output: Record<string, unknown> = {}
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 标记为降级状态
// 适用于"执行成功了,但结果不理想"的场景
// 示例按顺序执行且节点名称唯一;并发或重复节点应改用 spanId 定位
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
}
}
}
这个抽象能够降低节点接入 Tracing 的成本。开发者不必在每个节点中重复编写开始计时、结束计时、异常捕获和结构化输出等逻辑,只需要用 trace.span() 包装原来的函数调用。
下面把 TraceContext 接入一条简单的 AI 管线。用户发送消息后,系统会依次执行安全检查、记忆检索和 LLM 生成:
usage.ts
// 1. 在请求入口创建 TraceContext 实例
const trace = n