21.07-Trace追踪

要点

  • Trace 追踪用于追踪跨多个服务的请求链路
  • 一个 trace 包含多个 span,每个 span 代表一个操作
  • Cloudflare Workers 支持 OpenTelemetry 标准
  • 对于 AI 网关,trace 可以追踪:HTTP 请求 → 缓存查询 → LLM 调用 → 响应

内容

1. Trace 和 Span 的概念

Trace:一次完整请求的链路追踪。例如用户调用 /v1/chat/completions,整个请求从开始到结束就是一个 trace。

Span:trace 中的一个操作单元。例如:

  • HTTP 请求处理(父 span)
    • 鉴权中间件(子 span)
    • 缓存查询(子 span)
    • LLM API 调用(子 span)
    • 响应序列化(子 span)

每个 span 记录:

  • operationName:操作名称(如 llm.call.openai
  • startTime:开始时间
  • duration:持续时间
  • tags:标签(如 model=gpt-4tokens=1500
  • logs:事件日志(如错误信息)

2. 为什么需要 Trace

日志可以告诉你「这个请求花了 2500ms」,但不能告诉你:

  • 这 2500ms 里,哪一步最慢?
  • 缓存命中率是多少?
  • LLM API 调用了多少次?每次耗时多少?

Trace 可以可视化整个请求链路:

HTTP Request (2500ms)
├── Auth Middleware (5ms)
├── Cache Lookup (10ms) [HIT]
├── LLM Call (2450ms)
│   ├── OpenAI API (2400ms)
│   └── Response Parsing (50ms)
└── Response Serialization (35ms)

一眼就能看出:LLM API 调用占了 96% 的时间。

3. Cloudflare Workers 的 Trace 支持

Workers 原生支持 OpenTelemetry(OTel),可以通过 observability.traces 配置启用:

// wrangler.jsonc
{
  "observability": {
    "traces": {
      "enabled": true
    }
  }
}

启用后,Workers 会自动生成 trace 并发送到 Cloudflare Dashboard。

3.1 手动创建 Span

自动 trace 只记录粗粒度的信息(HTTP 请求、fetch 调用)。要记录业务逻辑,需要手动创建 span:

// src/lib/trace.ts
import { trace } from '@opentelemetry/api'
 
const tracer = trace.getTracer('ai-gateway')
 
export async function withSpan<T>(
  name: string,
  fn: () => Promise<T>,
  attributes?: Record<string, string | number>
): Promise<T> {
  return tracer.startActiveSpan(name, async (span) => {
    try {
      if (attributes) {
        for (const [key, value] of Object.entries(attributes)) {
          span.setAttribute(key, value)
        }
      }
 
      const result = await fn()
      span.setStatus({ code: 0 })  // OK
      return result
    } catch (err) {
      span.setStatus({ code: 2, message: err.message })  // ERROR
      span.recordException(err)
      throw err
    } finally {
      span.end()
    }
  })
}
// src/routes/chat.ts
import { withSpan } from '../lib/trace'
 
app.post('/v1/chat/completions', async (c) => {
  const requestId = c.get('requestId')
 
  // 1. 鉴权
  const user = await withSpan('auth.verify', async () => {
    return verifyApiKey(c.req.header('Authorization'))
  })
 
  // 2. 缓存查询
  const cacheKey = generateCacheKey(body)
  const cached = await withSpan('cache.lookup', async () => {
    return await c.env.CACHE.get(cacheKey)
  }, { 'cache.key': cacheKey })
 
  if (cached) {
    return c.json(cached)
  }
 
  // 3. LLM 调用
  const result = await withSpan('llm.call', async () => {
    return await callLLM(body, c.env)
  }, {
    'llm.model': body.model,
    'llm.provider': 'openai',
  })
 
  // 4. 缓存写入
  await withSpan('cache.write', async () => {
    await c.env.CACHE.put(cacheKey, JSON.stringify(result))
  })
 
  return c.json(result)
})

4. 安装 OpenTelemetry SDK

Workers 需要安装 OpenTelemetry SDK:

// terminal
npm install @opentelemetry/api @opentelemetry/sdk-trace-base
// src/index.ts
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-base'
import { trace } from '@opentelemetry/api'
 
// 初始化 tracer provider
const provider = new NodeTracerProvider()
provider.register()
 
// 创建 tracer
export const tracer = trace.getTracer('ai-gateway')
 
// ... Hono app

注意:Workers 环境下,OpenTelemetry 的配置可能和 Node.js 有些差异。具体参考 Cloudflare 的文档。

5. 实战:AI 网关的完整 Trace

// src/routes/chat.ts
import { tracer } from '../lib/trace'
import { log } from '../lib/logger'
 
app.post('/v1/chat/completions', async (c) => {
  const requestId = c.get('requestId')
  const userId = c.get('userId')
  const body = await c.req.json()
 
  // 创建根 span
  const rootSpan = tracer.startSpan('http.post./v1/chat/completions')
  rootSpan.setAttribute('request.id', requestId)
  rootSpan.setAttribute('user.id', userId)
 
  try {
    // 1. 鉴权
    const authSpan = tracer.startSpan('auth.verify', { childOf: rootSpan })
    try {
      const user = await verifyApiKey(c.req.header('Authorization'))
      authSpan.setAttribute('user.id', user.id)
      authSpan.setStatus({ code: 0 })
    } catch (err) {
      authSpan.setStatus({ code: 2, message: err.message })
      throw err
    } finally {
      authSpan.end()
    }
 
    // 2. 缓存查询
    const cacheSpan = tracer.startSpan('cache.lookup', { childOf: rootSpan })
    try {
      const cacheKey = generateCacheKey(body)
      const cached = await c.env.CACHE.get(cacheKey)
 
      cacheSpan.setAttribute('cache.key', cacheKey)
      cacheSpan.setAttribute('cache.hit', cached ? 'true' : 'false')
      cacheSpan.setStatus({ code: 0 })
 
      if (cached) {
        return c.json(JSON.parse(cached))
      }
    } catch (err) {
      cacheSpan.setStatus({ code: 2, message: err.message })
    } finally {
      cacheSpan.end()
    }
 
    // 3. LLM 调用
    const llmSpan = tracer.startSpan('llm.call', { childOf: rootSpan })
    let result
    try {
      llmSpan.setAttribute('llm.model', body.model)
      llmSpan.setAttribute('llm.provider', 'openai')
 
      result = await callLLM(body, c.env)
 
      llmSpan.setAttribute('llm.tokens.prompt', result.usage.prompt_tokens)
      llmSpan.setAttribute('llm.tokens.completion', result.usage.completion_tokens)
      llmSpan.setAttribute('llm.tokens.total', result.usage.total_tokens)
      llmSpan.setStatus({ code: 0 })
    } catch (err) {
      llmSpan.setStatus({ code: 2, message: err.message })
      llmSpan.recordException(err)
      throw err
    } finally {
      llmSpan.end()
    }
 
    // 4. 缓存写入
    const cacheWriteSpan = tracer.startSpan('cache.write', { childOf: rootSpan })
    try {
      const cacheKey = generateCacheKey(body)
      await c.env.CACHE.put(cacheKey, JSON.stringify(result), {
        expirationTtl: 3600,
      })
      cacheWriteSpan.setAttribute('cache.key', cacheKey)
      cacheWriteSpan.setAttribute('cache.ttl', 3600)
      cacheWriteSpan.setStatus({ code: 0 })
    } catch (err) {
      cacheWriteSpan.setStatus({ code: 2, message: err.message })
    } finally {
      cacheWriteSpan.end()
    }
 
    rootSpan.setStatus({ code: 0 })
    return c.json(result)
  } catch (err) {
    rootSpan.setStatus({ code: 2, message: err.message })
    rootSpan.recordException(err)
    throw err
  } finally {
    rootSpan.end()
  }
})

6. 用装饰器简化 Trace

手动管理 span 很繁琐,可以用装饰器简化:

// src/lib/trace-decorator.ts
import { tracer } from './trace'
 
export function traced(operationName?: string) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value
    const name = operationName || `${target.constructor.name}.${propertyKey}`
 
    descriptor.value = async function (...args: any[]) {
      const span = tracer.startSpan(name)
 
      try {
        const result = await original.apply(this, args)
        span.setStatus({ code: 0 })
        return result
      } catch (err) {
        span.setStatus({ code: 2, message: err.message })
        span.recordException(err)
        throw err
      } finally {
        span.end()
      }
    }
  }
}
// src/lib/llm.ts
export class LLMService {
  @traced('llm.call.openai')
  async callOpenAI(body: ChatRequest): Promise<ChatResponse> {
    // ...
  }
 
  @traced('cache.lookup')
  async lookupCache(key: string): Promise<string | null> {
    // ...
  }
}

7. Trace 和日志的结合

Trace 和日志是互补的:

  • Trace:可视化请求链路,快速定位慢操作
  • 日志:记录详细信息,深入排查问题

两者通过 requestId(或 traceId)关联:

// src/routes/chat.ts
import { trace } from '@opentelemetry/api'
 
app.post('/v1/chat/completions', async (c) => {
  const span = tracer.startSpan('http.post./v1/chat/completions')
  const traceId = span.spanContext().traceId
 
  // 把 traceId 存到上下文,所有日志都带上
  c.set('traceId', traceId)
 
  try {
    log.info('llm.call.start', {
      requestId: c.get('requestId'),
      traceId,
      model: body.model,
    })
 
    const result = await callLLM(body, c.env)
 
    log.info('llm.call.success', {
      requestId: c.get('requestId'),
      traceId,
      model: body.model,
      tokens: result.usage.total_tokens,
    })
 
    return c.json(result)
  } catch (err) {
    log.error('llm.call.failed', {
      requestId: c.get('requestId'),
      traceId,
      model: body.model,
      error: err.message,
    })
 
    throw err
  } finally {
    span.end()
  }
})

这样在 Dashboard 里:

  1. 看到 trace 显示「LLM 调用花了 2400ms」
  2. 点击 span,看到关联的日志:「llm.call.success model=gpt-4 tokens=1500」
  3. 结合两者判断:这 2400ms 是正常的 LLM 响应时间,还是有什么异常

8. 第三方 Trace 平台

Cloudflare Dashboard 的 trace 功能比较基础,复杂场景可以接入第三方平台:

平台特点价格
Honeycomb专为 trace 设计,查询强大按数据量计费
Datadog全功能 APM,和日志/指标集成按主机计费
Jaeger开源,可以自建免费(需要自己部署)
New Relic全功能 APM按数据量计费

接入方式通常是通过 OpenTelemetry 导出器:

// src/index.ts
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
 
const exporter = new OTLPTraceExporter({
  url: 'https://api.honeycomb.io/v1/traces',
  headers: {
    'x-honeycomb-team': env.HONEYCOMB_API_KEY,
  },
})
 
provider.addSpanProcessor(new BatchSpanProcessor(exporter))

9. 小结

Trace 追踪的关键点:

  1. 概念:Trace 是一次请求的完整链路,Span 是其中的一个操作
  2. 用途:快速定位慢操作、分析请求链路、排查跨服务问题
  3. Workers 支持:通过 observability.traces 启用,支持 OpenTelemetry
  4. 手动 Span:用 tracer.startSpan() 记录业务逻辑
  5. 和日志结合:通过 traceId 关联 trace 和日志

对于中小项目,Workers Observability 自带的 trace 功能基本够用。复杂场景可以接入 Honeycomb、Datadog 等平台。

下一节讲 Metrics 指标,看看怎么收集和查询自定义指标。