在 Cloudflare Workflows 中驯服 Sentry 错误噪音

前言

最近在一个使用 Cloudflare Workers + Workflows 的项目中接入了 @sentry/cloudflare SDK。上线几天后,Sentry 面板上迅速积累了大量未解决 issue 和数百个事件。

打开一看,绝大多数是外部 API 的瞬时故障——重试后就成功了:

错误类型 占比 典型错误信息
500 Internal Server Error 最高 500 Internal Server Error
JSON 格式异常 较高 Invalid JSON
请求超时 中等 Request timed out
网络断连 较低 Network connection lost

核心矛盾:这些错误大多会被 Cloudflare Workflow 的重试机制自动恢复,但 Sentry 上却每一次中间失败都有记录。告警通道被噪音淹没,真正的故障反而看不到了。

Cloudflare Workflow 的重试机制

Cloudflare Workflows 通过 step.do() 将任务拆分为可重试的原子步骤。每个 step 可以独立配置重试策略:

const result = await step.do(
  "调用外部 API",
  { retries: { limit: 3, delay: "2 seconds", backoff: "exponential" } },
  async () => {
    // 调用外部 API,可能因瞬时故障失败...
  }
)

一个典型的 Workflow 包含多个 step,形成流水线:

graph LR
  A[Step 1<br/>数据准备] --> B[Step 2<br/>API 调用 A]
  B --> C[Step 3<br/>API 调用 B]
  C --> D[Step 4<br/>结果写入]

问题出在重试成功的场景。以一个 step 三次尝试为例:

sequenceDiagram
  participant W as Workflow Engine
  participant S as step.do()
  participant API as 外部 API
  participant Sentry as Sentry

  W->>S: 执行 step
  S->>API: 第 1 次调用
  API-->>S: 500 Error
  S->>Sentry: captureException ①
  Note over S: throw → 引擎捕获,等待重试

  W->>S: 重试 step
  S->>API: 第 2 次调用
  API-->>S: 500 Error
  S->>Sentry: captureException ②
  Note over S: throw → 引擎捕获,等待重试

  W->>S: 重试 step
  S->>API: 第 3 次调用
  API-->>S: 200 OK
  Note over S: 成功返回,继续下一个 step

  Note over Sentry: 最终结果:成功<br/>Sentry 上却有 2 个虚假错误

第 3 次调用成功了,整个 Workflow 正常完成。但 Sentry 上已经记录了 2 个毫无意义的错误事件。当 Workflow 执行频率较高时,噪音会迅速积累到数百上千个事件。

SDK 源码解读:噪音从何而来

为什么每次 step 重试 throw 都会上报 Sentry?答案藏在 @sentry/cloudflareinstrumentWorkflowWithSentry 实现中。

WrappedWorkflowStep.do() 的 catch 块

SDK 对 step.do() 做了包装(Wrapped),在回调 throw 时自动捕获异常。关键代码在 workflows.js 第 80-81 行:

// @sentry/cloudflare SDK 源码(简化)
async do(name, configOrCallback, callback) {
  // ... 省略参数处理
  return this._step.do(name, config, async () => {
    return Sentry.startSpan(/*...*/, async (span) => {
      try {
        return await callback()
      } catch (error) {
        // 第 80-81 行:每次 throw 都上报!
        captureException(error, {
          mechanism: { handled: true, type: 'auto.faas.cloudflare.workflow' }
        })
        throw error  // 重新抛出,让 Workflow 引擎处理重试
      }
    })
  })
}

关键点:captureExceptionthrow 之前执行。也就是说,无论这个错误后续是否会被重试成功,SDK 都会立即上报。

run() 层没有兜底上报

再看 run() 层的包装:

// SDK 对 run() 的包装(简化)
async run(event, step) {
  return Sentry.withIsolationScope(async () => {
    try {
      return await originalRun.call(this, event, wrappedStep)
    } finally {
      // 只有 finally,没有 catch
      // → 如果所有重试耗尽最终失败,没有人上报最终错误
    }
  })
}

这意味着两件事:

  1. 中间失败被过度上报——每次 step throw 都报,即使后续重试成功
  2. 最终失败反而没有上报——run() 层只有 try-finally,没有 catch

下面的流程图对比了自动捕获路径和理想路径:

flowchart TB
  subgraph problem["现状:SDK 自动捕获"]
    direction TB
    P1[step 回调 throw] --> P2[SDK catch 块]
    P2 --> P3["captureException<br/>mechanism: auto.faas.cloudflare.workflow"]
    P3 --> P4[throw → 引擎重试]
    P4 -->|重试成功| P5["Sentry 有虚假错误 ❌"]
    P4 -->|重试耗尽| P6["run() finally<br/>无 catch → 无上报 ❌"]
  end

  subgraph solution["目标:只上报最终失败"]
    direction TB
    S1[step 回调 throw] --> S2[SDK catch 块]
    S2 --> S3["captureException → beforeSend 过滤 → 丢弃"]
    S3 --> S4[throw → 引擎重试]
    S4 -->|重试成功| S5["Sentry 无噪音 ✅"]
    S4 -->|重试耗尽| S6["run() catch<br/>手动 captureException ✅"]
  end

GitHub Issue #17421

这不是个别项目独有的困扰。Sentry JavaScript SDK 的 Issue #17421 讨论了同样的问题。

Sentry 团队成员的回复一针见血:

SDK 无法检测重试次数。Cloudflare Workflow 的 step 执行之间不共享状态,除非 Cloudflare 未来在 API 中暴露重试计数元数据,否则 SDK 侧无法改进。

也就是说,这个问题不会在 SDK 层面被修复,需要使用方自行处理。

mechanism.type:区分的钥匙

既然 SDK 在每次 step throw 时都会上报,我们需要找到一种方式区分自动捕获和手动上报。答案就是 mechanism.type

什么是 mechanism

Sentry 的 Exception Interface 定义了 mechanism 字段,用于记录异常是如何被捕获的元数据。其中 type 是一个字符串,标识捕获来源。

命名遵循 Sentry 的 Trace Origin RFC 规范:auto.<category>.<integration>.<part>

不同来源的 mechanism.type 对比

捕获方式 mechanism.type 含义
SDK step 自动捕获 auto.faas.cloudflare.workflow Workflow step 回调 throw 时自动上报
手动 Sentry.captureException() generic 开发者主动上报(默认值)
SDK queue 自动捕获 auto.faas.cloudflare.queue Queue consumer 异常时自动上报

这正是我们需要的:SDK 自动捕获的 step 错误带有 auto.faas.cloudflare.workflow,而我们在 run() 顶层手动上报的最终失败带有 generic

稳定性评估

mechanism.type 的具体值并不属于 Sentry 的稳定公开 API。但实际变更风险很低:

  • 该值遵循明确的命名规范(Trace Origin RFC)
  • 历史变更极少(从早期的 'cloudflare' 改为当前格式,PR #17582)
  • 即使未来调整,也大概率保持 auto.faas.cloudflare 前缀
  • 升级 SDK 时检查 changelog 即可防范

方案设计与取舍

梳理完 SDK 行为后,有三种可能的方案:

方案 做法 优点 缺点
移除 instrumentWorkflowWithSentry 不用 SDK 包装,纯手动初始化 Sentry 无噪音 丧失自动 tracing、span、上下文注入等能力
在 step 回调内 try-catch 吞异常 捕获后不 throw 不上报 破坏 Workflow 重试机制,step 不会被重试
beforeSend 过滤 + run() 手动上报 过滤自动捕获,顶层补报最终失败 保留全部 SDK 能力,只报真正失败 依赖 mechanism.type 的值稳定性

第三种方案是最优解:保留 instrumentWorkflowWithSentry 带来的 tracing 和上下文能力,同时通过 beforeSend 精准过滤噪音,在 run() 顶层手动上报真正的最终失败。

实现

beforeSend 过滤

在 Sentry 配置中添加 beforeSend 钩子:

import * as Sentry from "@sentry/cloudflare"

function getSentryOptions(env: Env): Sentry.CloudflareOptions {
  return {
    dsn: env.SENTRY_DSN,
    environment: env.SENTRY_ENVIRONMENT,
    tracesSampleRate: 0.1,
    // ... 其他配置
    beforeSend(event) {
      // 过滤 Workflow step 自动捕获的中间重试错误
      const isWorkflowAutoCapture = event.exception?.values?.some(
        (v) => v.mechanism?.type === "auto.faas.cloudflare.workflow"
      )
      if (isWorkflowAutoCapture) return null  // 丢弃
      return event  // 保留
    },
  }
}

逻辑很简单:遍历事件的 exception.values,只要有一个的 mechanism.typeauto.faas.cloudflare.workflow,就返回 null 丢弃整个事件。其他所有事件(手动上报、queue 自动捕获等)照常通过。

Workflow 顶层 try-catch

beforeSend 解决了噪音问题,但也引入了一个空缺:如果所有重试耗尽,Workflow 最终失败了,谁来上报?

答案是在每个 Workflow 的 run() 方法中添加顶层 try-catch,手动调用 Sentry.captureException()

export class MyWorkflow extends WorkflowEntrypoint<Env, Payload> {
  async run(event: WorkflowEvent<Payload>, step: WorkflowStep): Promise<void> {
    try {
      // step1: 数据准备
      const data = await step.do("加载数据", async () => { /* ... */ })

      // step2: 调用外部 API(配置 3 次重试)
      const result = await step.do("调用 API",
        { retries: { limit: 3, delay: "2 seconds", backoff: "exponential" } },
        async () => { /* ... */ }
      )

      // step3: 写入结果
      await step.do("写入结果", async () => { /* ... */ })
    } catch (error) {
      // 所有重试耗尽后的最终失败 → 手动上报
      // mechanism.type 默认为 "generic",不会被 beforeSend 过滤
      Sentry.captureException(error)
      throw error  // 重新抛出,让 Workflow 标记为失败
    }
  }
}

所有 Workflow 均采用同样的模式:顶层 try-catch 捕获最终失败,手动上报后重新 throw。

单元测试

beforeSend 编写了完整的单元测试,覆盖关键场景:

describe("beforeSend 过滤 Workflow step 自动捕获的错误", () => {
  it("过滤 mechanism.type 为 auto.faas.cloudflare.workflow 的事件", () => {
    const event = {
      exception: {
        values: [{
          type: "Error",
          value: "some transient error",
          mechanism: { type: "auto.faas.cloudflare.workflow", handled: true },
        }],
      },
    }
    expect(beforeSend(event, {})).toBeNull()  // 丢弃
  })

  it("保留手动 captureException 的事件(mechanism.type 为 generic)", () => {
    const event = {
      exception: {
        values: [{
          type: "Error",
          value: "final failure",
          mechanism: { type: "generic", handled: true },
        }],
      },
    }
    expect(beforeSend(event, {})).toBe(event)  // 保留
  })

  it("保留队列消费等其他 Cloudflare 自动捕获的事件", () => {
    const event = {
      exception: {
        values: [{
          mechanism: { type: "auto.faas.cloudflare.queue", handled: false },
        }],
      },
    }
    expect(beforeSend(event, {})).toBe(event)  // 保留
  })

  it("多个 exception values 中只要有一个匹配就过滤", () => {
    const event = {
      exception: {
        values: [
          { mechanism: { type: "generic" } },
          { mechanism: { type: "auto.faas.cloudflare.workflow", handled: true } },
        ],
      },
    }
    expect(beforeSend(event, {})).toBeNull()  // 丢弃
  })
})

测试覆盖了:过滤 workflow 自动捕获、保留手动上报、保留 queue 自动捕获、保留无 mechanism 事件、保留无 exception 事件、多 exception values 场景。

总结

效果

部署后,Sentry 上只会出现真正需要关注的最终失败:所有重试耗尽后仍然失败的错误。中间的瞬时故障(API 500、超时、网络断连)不再产生噪音。

关键认知

  1. mechanism.type 是区分自动捕获和手动上报的钥匙。SDK 自动捕获的异常带有 auto.faas.cloudflare.workflow,手动 captureException() 默认带有 generic

  2. instrumentWorkflowWithSentry 依然有价值。它提供了自动 tracing、span 创建、上下文注入等能力。我们只需要过滤它的错误上报,而不是移除整个包装。

  3. beforeSend 是 Sentry 最灵活的客户端过滤机制。它在事件发送前拦截,可以基于任何事件属性做过滤、修改或丢弃。

参考资料

  1. Sentry Exception Interface - mechanism 字段
  2. Sentry JavaScript SDK Issue #17421 - Workflow 重试噪音
  3. Sentry Trace Origin RFC
  4. Cloudflare Workflows 文档
  5. @sentry/cloudflare SDK 源码

本文采用 CC BY-NC-SA 4.0 许可