Sentry + PostHog 闭环实战:用“统一事件封装 + 关联键 + 导出链路三件套”把线上问题变成可定位、可验证、可回归

1029
2026-02-28 16:48
17 小时前

Sentry + PostHog 闭环实战:用“统一事件封装 + 关联键 + 导出链路三件套”把线上问题变成可定位、可验证、可回归

很多人都听过“可观测的正确打开方式”是反馈闭环:信号→定位→修复→验证→护栏。 但如果没有一个能落地的案例,这套方法很容易停留在概念层,最后变成“接了工具,但没人用”。

这篇文章用一个典型的前端重链路:导出文件(PPTX/PDF/PNG/...),演示如何把 错误信号(Sentry)行为信号(PostHog)串起来, 让你能回答四个关键问题:

  • 发生了什么?(错误类型/失败点/堆栈/上下文)
  • 影响谁、影响多大?(失败率、受影响会话数、按格式/语言/版本分桶)
  • 为什么会发生?(关联到路由/版本/语言/文档属性/功能开关)
  • 修复是否真的变好?(对照指标 + 回归门禁)

TL;DR(30 秒讲清楚)

  • 核心动作:把“导出链路”打成三件套:export_clickedexport_completed/export_failed(可选加 export_started)。
  • 统一封装:一个 track() 入口做事件 schema、脱敏与采样;一个 captureException() 入口补齐 tags/extra。
  • 关联键:releaseroutelocalesession_id 是基础;导出类链路再加 trace_iddoc_idexport_formatslide_count_bucket
  • 隐私与噪声:默认关闭 autocapture;用白名单限制采集页面;杜绝 PII/secret;对成功事件采样、对失败事件高采样。
  • 验收指标:导出失败率、导出耗时 p95、Crash-free sessions、MTTD/MTTR;上线后与上一版本对照。

问题定义(Problem Statement)

对“导出文件”这条关键链路建设可观测闭环:当导出失败或变慢时,能快速定位到影响面与根因;修复后能用指标对照验证;并把高频问题沉淀为回归样本与发布门禁,避免反复退步。

真实链路(导出案例:从点击到下载)

先把链路讲清楚,你才能知道信号该打在哪里。一个常见的浏览器导出链路会涉及“新窗口/跳转/关闭窗口”等副作用:

  1. 用户点击导出按钮:选择 format(pdf/pptx/png/...)。
  2. 前置校验:先发一个“justCheck”请求,确认是否允许导出(权限/额度/状态)。
  3. 打开导出页:用新窗口进入导出页(避免阻塞主页面),必要时处理弹窗拦截的降级。
  4. 导出页调用导出接口:拿到 exportUrl 后跳转到真实下载地址。
  5. 成功后自动关闭:部分 format 可以在下载触发后延迟关闭窗口。
  6. 失败路径:网络超时/权限失败/导出渲染异常/跨域资源失败,都会导致“用户看到失败,但你后台看不懂”。
导出链路的信号布点(示意图)
图:clicked→completed/failed 是“闭环最小单位”。没有 failed 事件,你永远只能猜“为什么掉转化”。

关键伪代码(读者可复现)

1) 统一事件封装(AnalyticsProvider):schema + 脱敏 + 采样

埋点的质量取决于“统一入口”。否则你会得到一堆不兼容的事件:同一个字段叫 format / export_format / type,而且到处漏 release、漏 locale

伪代码:track 统一入口(概念版)

function buildEnvelope(ctx) {
  return {
    release: ctx.release,
    route: ctx.route,
    locale: ctx.locale,
    session_id: ctx.sessionId,
    user_id: ctx.userId || null,
    trace_id: ctx.traceId || null,
    doc_id: ctx.docId || null,
    feature_flags: ctx.flags || {},
  };
}

function redact(props) {
  // 永远不要直接上报:email、token、原始文本内容、完整 query、原始错误栈
  return stripPIIAndSecrets(props);
}

function shouldSample(eventName, props) {
  // 成功事件可采样;失败事件尽量全量(或更高采样)
  if (String(eventName).endsWith('_failed')) return true;
  return Math.random() < 0.2;
}

function track(eventName, props, ctx) {
  const payload = { ...buildEnvelope(ctx), ...redact(props) };
  if (!shouldSample(eventName, payload)) return;
  analytics.capture(eventName, payload);
}

2) 错误上报(Sentry):tags 用来分桶,extra 用来排查

错误平台最强的是“聚类与追踪回归”。但前提是你要把可分桶的维度放进 tags,把可排查的上下文放进 extra。

伪代码:captureException(概念版)

function captureException(err, ctx, extra = {}) {
  errorTracking.captureException(err, {
    tags: {
      release: ctx.release,
      route: ctx.route,
      locale: ctx.locale,
      export_format: ctx.exportFormat || 'unknown',
    },
    extra: {
      trace_id: ctx.traceId,
      doc_id: ctx.docId,
      ...extra,
    },
  });
}

3) 导出链路三件套:clicked → completed / failed(可选 started)

导出类链路有两个常见坑:

  • 只打 clicked 不打 failed:你只能看到“点击很多,但完成很少”,却不知道为什么。
  • 把 completed 打在“请求发起”时:会把失败当成功,导致转化与失败率都不可信。

伪代码:导出闭环(概念版)

async function exportFile({ format, docId, slideCount, locale }) {
  const traceId = crypto.randomUUID();
  const ctx = {
    traceId,
    docId,
    exportFormat: format,
    locale,
    route: getRoute(),
    release: getRelease(),
    sessionId: getSessionId(),
    userId: getUserIdOrNull(),
  };

  track('export_clicked', {
    export_format: format,
    slide_count_bucket: bucketize(slideCount),
  }, ctx);

  const start = performance.now();
  try {
    track('export_started', { export_format: format }, ctx); // 可选
    const exportUrl = await requestExportUrl({ format, docId }); // 调用导出接口
    await triggerBrowserDownload(exportUrl);                    // 跳转/下载

    const durationMs = Math.round(performance.now() - start);
    track('export_completed', { export_format: format, duration_ms: durationMs }, ctx);
  } catch (e) {
    const durationMs = Math.round(performance.now() - start);
    captureException(e, ctx, { duration_ms: durationMs });
    track('export_failed', { export_format: format, duration_ms: durationMs, error_code: normalize(e) }, ctx);
    throw e;
  }
}

4) 身份与会话治理:identify 一次、logout/reset 一次

多用户/登录态系统里,如果你不做 reset,很容易出现“用户 A 的事件挂在用户 B 身上”的事故。 工程上建议:

  • 登录后 identify(一次):避免重复 identify;必要时写 person properties。
  • 退出/401 时 reset:清理 analytics 身份与缓存,避免跨用户污染。
  • 匿名 session_id 永远存在:即使不登录,也能串联“点击→失败→重试”。

指标与验证(Metrics & Validation)

建议把导出链路的闭环验收,固化为一张仪表盘(按 release/format/locale 分桶):

  • 导出失败率:export_failed / (export_completed + export_failed)
  • 导出耗时 p95:按 format 分桶(pdf/pptx/png)与 slide_count_bucket 分桶
  • Crash-free sessions:导出相关页面的无崩溃会话占比
  • MTTD / MTTR:从异常出现到被发现、到修复上线的时间

通过标准(建议):

  • 可关联:事件/异常都带统一 envelope(release/route/locale/session/trace/doc 等),能按维度分桶定位。
  • 可验收:失败率与耗时 p95 有基线对照(新版本不回退),Top issues 可回溯到具体链路。
  • 可控:采样与脱敏到位(不泄露 PII),并把高频问题沉淀成回归护栏。

验证方式建议用“对照”而不是“感觉”:

  1. 上线前:跑一套导出样本库回归(含 RTL/混排/长文/列表/复杂图片)。
  2. 上线后:对照上一版本的失败率与耗时 p95,至少观察 24 小时。
  3. 复盘时:把高频失败样本沉淀成回归用例(并写清触发条件)。

最小可复现验证(示例):

# 1) 触发一次成功导出(产生 export_clicked + export_completed)
# 2) 触发一次失败导出(产生 export_clicked + export_failed,并在 Sentry 里出现对应异常)
# 3) 在 PostHog 里按 release/format/locale 分桶看漏斗:clicked→completed/failed 是否闭环
# 4) 在 Sentry 里打开同一 release 的 issue:tags/extra 是否包含 trace_id/doc_id/export_format
# 5) 抽样复制 trace_id:在两边都能搜到,并能还原“谁在什么条件下失败”

预期与判定(建议):

  • 闭环成立:每次 clicked 最终能落到 completed/failed 之一(别只打 clicked)。
  • 可关联:PostHog 事件与 Sentry 异常共享关联键(至少 release/route/locale/session/trace/doc)。
  • 可对照:按 release 对照失败率与耗时 p95,新版本不回退;出现回退能定位到具体 issue/样本。

不通过先查:事件是否因跳转/关窗丢失(必要时用 sendBeacon);identify/reset 是否导致跨用户污染; autocapture 是否带来噪声淹没关键事件;以及脱敏是否把“可定位所需字段”一并抹掉了。

常见坑与规避(Pitfalls)

  • 事件字段不统一:没有 envelope 就没有关联;没有关联就无法分桶定位。
  • 只采集成功不采集失败:会导致你永远在“转化掉了”里盲修。
  • PII 泄露:把脱敏写进统一封装,禁止业务方直接上报原始文本/email/token。
  • autocapture 过度:默认关;只对白名单页面开;只采集能驱动决策的事件。
  • 新窗口/跳转导致丢事件:关键事件要尽量在“跳转前”发送;必要时用 sendBeacon 类机制兜底。

FAQ(常见问题)

Q1:为什么要同时用 Sentry 和 PostHog?不能只用一个吗?

两者数据模型不同:错误平台擅长聚类、堆栈与回归;行为平台擅长漏斗、分群与影响面。 关键是让它们共享关联键,才能把“错误”映射到“业务影响”。

Q2:如何定义“关键链路”?

选择标准:失败会直接损失收入/留存/信任,或耗时会显著破坏体验(例如导出、支付、生成、保存)。 关键链路建议都打三件套:clicked→completed/failed。

Q3:如何控制成本与噪声?

对“成功事件”做采样,对“失败事件”提高采样;禁用全站 autocapture; 对导出等长链路只采集关键步骤与关键维度(format/locale/release/slide_count_bucket)。

Q4:如何在不泄露隐私的前提下做定位?

不上报原始内容;用 hash/长度/桶化字段替代(例如 slide_count_bucket); 用业务实体 ID(doc_id)串联,不要上报文档全文。

Q5:如何保证“修复后不会退步”?

把高频失败样本加入回归库;把失败率、耗时 p95 设为发布门禁;必要时灰度并对照上一版本。 可观测闭环的终点不是“修一次”,而是“修一次就加一条护栏”。

Q6:为什么 clicked/completed/failed 这三件套这么重要?能不能只打一个事件?

只打一个事件你永远无法回答“是用户没点,还是点了但失败了”。三件套的价值是把链路闭合:你能算失败率、能算耗时、能按维度分桶。 对长链路(导出/生成/支付)来说,这是最小可观测单元。

Q7:新窗口/跳转导致事件丢失怎么办?

关键事件尽量在“跳转前”发送;必要时用 sendBeacon 或“先写队列后异步 flush”兜底; 同时把 export_failed 这种关键失败事件设为高采样甚至全量,避免你只看到“漏斗掉了”却看不到失败原因。

延伸阅读: 可观测性方法论(反馈闭环) / 导出链路一致性方法论