Serverless 日志与链路追踪:平台日志、OpenTelemetry 与结构化日志

Serverless 日志与链路追踪深度实战:平台内置日志的局限、结构化 JSON 日志与上下文注入、OpenTelemetry 在边缘与函数里的落地、trace 传播、采样与采集成本控制,帮你构建可排障、可观测的 Serverless 应用。

一、引言

Serverless 的排障体验有个老问题:函数瞬间启动、瞬间销毁,你根本没机会 SSH 进去看日志。可观测性因此从「锦上添花」变成「生存刚需」。但 Serverless 的日志链路也很容易被低估:平台日志够用吗?trace 怎么跨函数、跨边界传播?日志量一上来,成本先崩了怎么办?

本文系统拆解三层:平台日志的能力与边界、结构化日志与上下文注入、OpenTelemetry 在 Serverless 的落地路径,最后给出「采集成本控制」的实操策略。

二、平台日志:先把手里的免费能力用好

2.1 Cloudflare Workers 的日志体系

Cloudflare 给 Workers 提供了分层日志能力:

wrangler dev         → 本地终端实时日志
wrangler tail        → 线上实时 tail(带过滤)
Workers Logs         → 托管日志(保留 / 检索)
Observability 面板   → 请求级时间线、错误、异常、排错
# 线上 tail,过滤特定 IP 或路径
npx wrangler tail --format pretty --search "path=/api/checkout"

# JSON 格式输出,方便接管道
npx wrangler tail --format json

2.2 Vercel 的日志与 Insights

Vercel 把函数日志、Runtime Logs 和 Real User Metrics 分成两个视图:排障看 Runtime Logs,性能看 Speed Insights。平台日志的优点是「零接入成本」,缺点是「只在本平台内看」——一旦你有多个平台、多个服务,平台日志就是孤岛。

// 在 Vercel 函数里打印标准日志
export default function handler(req, res) {
  console.log('checkout', { userId: req.query.uid, step: 'init' })
  res.json({ ok: true })
}

2.3 平台日志的三个边界

边界一:保留期短(Workers Logs 默认 7 天)
边界二:跨服务不可关联(没有统一 traceId)
边界三:无检索聚合(多平台日志无法联合查询)

心法:平台日志是「应急现场」,不是「分析仓库」。排障的第一步看平台日志,第二步必须落到统一日志平台。只靠平台日志的团队,会在跨服务事故时无从下手。

三、结构化日志:JSON 是 Serverless 日志的第一语言

3.1 为什么不要纯文本拼字符串

// 反模式:字符串拼接,字段无法检索
console.log(`user ${userId} failed checkout order ${orderId}`)

// 正解:结构化对象,字段可查询、可聚合
console.log('checkout_failed', {
  userId,
  orderId,
  errorCode: 'CART_EXPIRED',
  durationMs: 234,
  region: request.cf?.colo,
})

结构化日志的价值在排障时爆发:你可以按 errorCode=CART_EXPIRED 过滤、按 durationMs 画分布、按 userId 检索一个用户的所有轨迹。纯文本只能全文搜索。

3.2 统一日志格式与字段规范

// 一个轻量 logger:统一 time / level / message / 业务字段
function log(level: 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>) {
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    level,
    message,
    ...fields,
  }))
}

// 使用:所有日志自动带时间与级别
log('info', 'checkout_started', { userId, orderId })
log('error', 'checkout_failed', { userId, orderId, errorCode })

3.3 用 requestId 关联单次请求

Serverless 平台通常会在请求头里带唯一 ID,把它注入每一条日志,是「单请求排障」的地基。

export default {
  async fetch(request, env, ctx) {
    // 取平台的 trace id(Workers 可用 request.cf 或自定义头)
    const traceId = request.headers.get('cf-ray') ?? crypto.randomUUID()
    ctx.waitUntil(console.log('request', JSON.stringify({ traceId, path: new URL(request.url).pathname })))
    return new Response('ok', { headers: { 'X-Trace-Id': traceId } })
  },
}

细节:ctx.waitUntil() 让日志在响应返回后仍可异步落盘,避免「响应已发、日志丢失」的经典坑。

四、OpenTelemetry:让 Serverless 进入统一观测体系

4.1 OpenTelemetry 的三件套

Traces(链路):一次请求跨服务调用的时间线
Metrics(指标):计数、直方图、时间序列
Logs(日志):结构化事件记录

OpenTelemetry 的价值在于标准:一个 SDK、一套语义约定,把 trace / metric / log 发往任意后端(Grafana、Datadog、Honeycomb、New Relic、自建 Jaeger)。

4.2 OTel 在 Serverless 的落地形态

Serverless 运行时不能常驻后台线程,OTel 的落地方式通常是「自动注入 + 导出器」:

import { trace, context } from '@opentelemetry/api'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'

// 初始化 provider(每个函数实例初始化一次)
const provider = new NodeTracerProvider()
provider.addSpanProcessor(new BatchSpanProcessor(
  new OTLPTraceExporter({ url: env.OTEL_ENDPOINT, headers: { Authorization: `Bearer ${env.OTEL_TOKEN}` } })
))
provider.register()

export default {
  async fetch(request, env) {
    const tracer = trace.getTracer('checkout')
    return tracer.startActiveSpan('handle_request', async (span) => {
      span.setAttribute('http.method', request.method)
      span.setAttribute('url.path', new URL(request.url).pathname)
      const result = await handleOrder(request)
      span.setAttribute('order.status', result.status)
      span.end()
      return result.res
    })
  },
}

细节:OTel 官方维护了针对 Workers / 云函数的自动注入库(如 @vercel/otel、Cloudflare 的 workers-observability),推荐优先用自动注入再手动补 span,别从零手写 instrumentation。

4.3 把 traceId 贯穿到日志

trace 与 log 关联是排障的杀手锏:每条日志带上当前 span 的 traceId / spanId,前端报错就能「点进日志」再「点进 trace」。

import { trace } from '@opentelemetry/api'

function logWithTrace(level: string, message: string, fields: Record<string, unknown>) {
  const span = trace.getActiveSpan()
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    level,
    message,
    trace_id: span?.spanContext().traceId,
    span_id: span?.spanContext().spanId,
    ...fields,
  }))
}

五、链路追踪:trace 传播与边界穿越

5.1 跨函数、跨服务传播 trace 上下文

一次用户请求会穿过 API 网关 → 边缘函数 → 后端函数 → 数据库,每个边界都要把 trace 上下文带到下游:

// 上游:把 W3C traceparent 头传给下游
async function callDownstream(url: string) {
  const current = trace.getActiveSpan()
  const headers = new Headers()
  if (current) {
    headers.set('traceparent', `00-${current.spanContext().traceId}-${current.spanContext().spanId}-01`)
  }
  return fetch(url, { headers })
}

下游 SDK 读到 traceparent 头,就能把新 span 挂到同一个 trace 上——这就是「分布式追踪的接缝」。Serverless 尤其依赖这种显式传播,因为没有常驻进程替你维护线程局部上下文。

5.2 Serverless 的 trace 陷阱

陷阱一:冷启动 span 缺失(初始化代码没 instrumentation)
陷阱二:ctx.waitUntil 异步 span 提前结束
陷阱三:跨平台 traceparent 格式不一致
陷阱四:队列/定时任务无上游,trace 孤立

铁律:给「无上游入口」(Cron 任务、队列消费者、Webhook)也生成根 span 并带上业务关联键(如 jobId / webhookId)。否则它们游离在 trace 体系外,事故排查会出现「黑洞」。

六、采集成本控制:日志与 trace 的成本曲线

6.1 日志量的成本模型

日志平台的定价几乎都按「写入量 × 保留期」计费。Serverless 的日志成本问题出在「量」:

一条 200 行的 JSON 日志 ≈ 20KB
1 万 QPS × 平均 5 条日志 × 30 天 ≈ 天量存储账单

6.2 成本控制三板斧

第一板斧:采样。

// 头部采样:按 traceId 哈希,只保留 10% 的 trace
function shouldSample(traceId: string, rate = 0.1) {
  let hash = 0
  for (let i = 0; i < traceId.length; i++) hash = (hash * 31 + traceId.charCodeAt(i)) | 0
  return Math.abs(hash) / 2147483647 < rate
}

if (shouldSample(traceId)) {
  // 只有被采样到的请求才发完整 trace
  await sendTrace(span)
}

第二板斧:分级落盘。

Error 级别:100% 保留(必须)
Warn 级别:保留 + 聚合告警
Info 级别:按需采样
Debug 级别:开关控制,默认关闭

第三板斧:字段裁剪与压缩。

// 裁剪冗余字段:去掉 headers、敏感字段,只留索引字段
function trimForLog(span: any) {
  return {
    name: span.name,
    duration_ms: span.durationMs,
    status: span.status,
    attributes: pick(span.attributes, ['http.method', 'url.path', 'error']),
  }
}

6.3 预估与告警

# 估算日写入量:QPS × 每请求日志条数 × 平均单条大小 × 采样率
# 例:5000 QPS × 4 条 × 2KB × 0.1 采样 ≈ 400MB/天

心法:成本控制不是「上线后救火」,而是「接入时就内置」。默认采样率 + 分级保留 + 敏感字段裁剪,三管齐下才能让观测体系长期跑得动。

七、与可观测性平台集成

7.1 日志收集路径

Worker/函数 → JSON 结构化日志
   → 平台日志(wrangler tail / Vercel Runtime Logs,应急用)
   → 日志平台(Grafana Loki / Datadog / 自建 OpenSearch,分析用)

7.2 把 trace / metric / log 汇入一个后端

推荐用 OTel Collector 作为统一网关,负责接收、采样、脱敏、转发:

# otel-collector.yaml(片段)
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch:
  memory_limiter:
    check_interval: 5s
    limit_mib: 512
exporters:
  otlp/trace:
    endpoint: honeycomb:4317
  loki/logs:
    endpoint: http://loki:3100/loki/api/v1/push
# 本地跑 collector
otelcol --config otel-collector.yaml

7.3 从日志到告警闭环

指标阈值告警(如错误率 > 1%)
日志关键字告警(如 FATA / PANIC / CART_EXPIRED 密集)
trace 慢调用告警(如 checkout 总时长 p95 > 3s)

完整的告警闭环设计可参考 可观测性与错误追踪 里的 SLO 与告警方法论,本文重点提醒:Serverless 告警要针对「用户可感知指标」设,别只盯着平台 CPU 之类的不适用指标。

八、总结

Serverless 日志与链路追踪的落地要点:

  1. 平台日志先用起来:wrangler tail、Runtime Logs 是零成本的排障现场,但保留期短、不可跨服务关联。
  2. 日志必须结构化:JSON + 统一 time/level/message/字段,是检索与聚合的前提。
  3. traceId 贯穿始终:单请求排障靠 requestId,分布式排障靠 OTel 的 traceId,让日志与 trace 能互跳。
  4. OTel 选标准不选私有:一套 SDK 发往任意后端,避免被单一平台锁定。
  5. 显式传播 trace 上下文:Serverless 无进程局部状态,跨函数、跨平台必须用 traceparent 头显式传播。
  6. 成本内置而非补救:采样 + 分级保留 + 字段裁剪,接入时就定好规则。
  7. 统一后端、闭环告警:用 Collector 收敛 trace/metric/log,按用户可感知指标设告警。

Serverless 的不可变基础设施决定了「排障只能靠观测」——把平台日志、结构化日志、OpenTelemetry 三层都接通,再配上成本控制,你的 Serverless 应用才算真正「可观测」。链路设计还常与 API 网关与 BFF 的聚合层、边缘认证与会话 的鉴权环节交织,观测要覆盖到这些边缘组件才算完整。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「tools」更多文章

  1. AI 网关与模型路由:多模型统一入口、fallback、限流与成本控制
  2. 密钥与环境配置:Vercel、Cloudflare 环境变量与密钥轮换实战
  3. Web 安全加固:CSP、HSTS、安全响应头与 XSS 防护实战