Sentry browserTracingIntegration 实现原理深度解析

18 阅读9分钟

基于 @sentry/browser 源码(packages/browser)及官方开发者文档整理


一、整体架构

browserTracingIntegration 本质上是一个插件容器,它在 afterAllSetup 钩子被调用时(即 Sentry Client 完全初始化后),批量注册多个底层监听器和 Monkey Patch,从而实现零侵入的自动追踪。

Sentry.init()
    └── afterAllSetup(client) 被调用
            ├── 1. 启动 Pageload Span(回溯时间戳到浏览器请求开始)
            ├── 2. 监听 History API → Navigation Span
            ├── 3. Monkey Patch fetch / XHR → HTTP Spans
            ├── 4. PerformanceObserver → Web Vitals Spans
            └── 5. PerformanceObserver → 资源加载 Spans

二、Pageload Span:页面加载追踪

实现方式

// packages/browser/src/tracing/browserTracingIntegration.ts(简化)

function afterAllSetup(client) {
  if (options.instrumentPageLoad) {
    // 1. 立即创建 pageload idle span
    const pageloadSpan = startIdleSpan({
      name: window.location.pathname,
      op: 'pageload',
      // 2. 关键:将开始时间回溯到浏览器真实发起请求的时刻
      startTime: getDocumentStartTime(), 
    });
  }
}

function getDocumentStartTime(): number {
  // 使用 Navigation Timing API 获取浏览器真实请求时间
  // performance.timing.navigationStart 或
  // performance.getEntriesByType('navigation')[0].startTime
  const navEntry = performance.getEntriesByType('navigation')[0];
  return navEntry ? navEntry.startTime / 1000 : Date.now() / 1000;
}

关键设计:Idle Span(空闲 Span)

Pageload Span 不会被显式结束,而是通过空闲机制自动关闭:

Span 创建
  │
  ├── 子 Span 活跃期间:保持开启
  │
  ├── 无新子 Span 超过 idleTimeout(默认 1s)→ 自动结束
  │
  └── 超过 finalTimeout(默认 30s)→ 强制结束

最早结束时机:document.readyState === 'interactive' | 'complete'

为什么需要 Idle Span?因为浏览器没有一个统一的"页面加载完毕"事件,不同应用(SSR、SPA、懒加载)的结束时机各不相同。


三、Navigation Span:路由变化追踪

实现方式:Monkey Patch History API

SPA 路由跳转不会触发页面刷新,Sentry 通过劫持 History API 来感知路由变化:

// packages/utils/src/instrument/history.ts(简化)

const originalPushState = window.history.pushState.bind(window.history);
const originalReplaceState = window.history.replaceState.bind(window.history);

window.history.pushState = function (...args) {
  // 先执行原始方法
  const result = originalPushState.apply(this, args);
  // 触发 Sentry 内部事件
  triggerHandlers('history', { from: oldHref, to: window.location.href });
  return result;
};

window.history.replaceState = function (...args) {
  const result = originalReplaceState.apply(this, args);
  triggerHandlers('history', { from: oldHref, to: window.location.href });
  return result;
};

// 同时监听浏览器前进/后退(popstate 事件)
window.addEventListener('popstate', () => {
  triggerHandlers('history', { from: oldHref, to: window.location.href });
});

自动重定向检测(v9.37.0+)

Sentry 能区分用户主动导航程序自动重定向(如未登录跳转到 /login):

导航发生
  │
  ├── 在 pageload 进行中 + 极短时间内触发 → 视为"重定向",不新建 Span
  │
  └── 用户交互后触发(有 click/keydown 事件) → 视为"导航",新建 Navigation Span

框架集成:参数化路由名

通用方案只能拿到原始 URL(如 /users/12345),框架 SDK 通过直接挂钩路由器获得参数化名称:

// @sentry/react 中的路由感知(简化)
// 可将 /users/12345 → /users/:id,便于在 Sentry UI 中聚合分析
browserTracingIntegration({
  beforeStartSpan: (context) => ({
    ...context,
    name: location.pathname.replace(//\d+/g, '/:id'),
  }),
});

四、HTTP 请求 Spans:fetch / XHR 追踪

fetch Monkey Patch

// packages/utils/src/instrument/fetch.ts(简化)

const originalFetch = window.fetch.bind(window);

window.fetch = function (input, init) {
  const url = getUrlFromInput(input);
  
  // 创建子 Span
  const span = startInactiveSpan({
    name: `${method} ${url}`,
    op: 'http.client',
    attributes: {
      'http.method': method,
      'http.url': url,
    },
  });

  // 注入分布式追踪 Header(传播 traceId 到后端)
  const headers = {
    ...init?.headers,
    'sentry-trace': spanToSentryTrace(span),
    'baggage': spanToBaggage(span),
  };

  return originalFetch(input, { ...init, headers })
    .then((response) => {
      // 请求完成,结束 Span
      span.setAttribute('http.status_code', response.status);
      span.end();
      return response;
    })
    .catch((error) => {
      // 请求失败
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.end();
      throw error;
    });
};

XMLHttpRequest Monkey Patch

// packages/utils/src/instrument/xhr.ts(简化)

const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;

XMLHttpRequest.prototype.open = function (method, url) {
  // 记录请求信息到实例上,send 时使用
  this._sentryData = { method, url };
  return originalOpen.apply(this, arguments);
};

XMLHttpRequest.prototype.send = function (body) {
  const span = startInactiveSpan({
    name: `${this._sentryData.method} ${this._sentryData.url}`,
    op: 'http.client',
  });

  this.addEventListener('readystatechange', () => {
    if (this.readyState === XMLHttpRequest.DONE) {
      span.setAttribute('http.status_code', this.status);
      span.end();
    }
  });

  // 注入追踪 Header
  this.setRequestHeader('sentry-trace', spanToSentryTrace(span));
  this.setRequestHeader('baggage', spanToBaggage(span));

  return originalSend.apply(this, arguments);
};

shouldCreateSpanForRequest 过滤器

可以精细控制哪些请求需要创建 Span:

browserTracingIntegration({
  shouldCreateSpanForRequest: (url) => {
    // 不追踪健康检查和埋点请求
    return !url.match(//(health|analytics)/?$/);
  },
});

五、Web Vitals 追踪

实现方式:PerformanceObserver

// packages/browser/src/metrics/index.ts(简化)

// LCP(最大内容绘制)
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1] as LargestContentfulPaint;
  
  // 将 LCP 值记录为当前 pageload span 的属性
  setMeasurement('lcp', lastEntry.startTime, 'millisecond');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// CLS(累积布局偏移)
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!(entry as LayoutShift).hadRecentInput) {
      clsValue += (entry as LayoutShift).value;
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

// FCP(首次内容绘制)
const fcpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      setMeasurement('fcp', entry.startTime, 'millisecond');
    }
  }
});
fcpObserver.observe({ type: 'paint', buffered: true });

// TTFB(首字节时间)—— 从 Navigation Timing API 直接读取
const navEntry = performance.getEntriesByType('navigation')[0];
if (navEntry) {
  setMeasurement('ttfb', navEntry.responseStart, 'millisecond');
}

INP(交互到下一帧,v8.x+ 默认启用)

// INP 需要持续监听所有用户交互
const inpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 每次用户交互(click/keydown/pointerdown)都创建一个独立 Span
    startSpan({
      name: `${entry.name} ${entry.target}`,
      op: 'ui.interaction',
      attributes: {
        'inp.value': entry.duration,
      },
    });
  }
});
inpObserver.observe({ type: 'event', durationThreshold: 40, buffered: true });

LCP/CLS 独立 Span(v10 实验性特性)

旧版本在 pageload span 结束时才上报 LCP/CLS,可能错过最终值。新版将其解耦为独立 Span:

// 实验性配置
browserTracingIntegration({
  _experiments: {
    enableStandaloneLcpSpans: true,
    enableStandaloneClsSpans: true,
  },
});

六、资源加载 Spans

通过 PerformanceObserver 监听资源加载(JS、CSS、图片、字体等):

const resourceObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
    startInactiveSpan({
      name: entry.name,           // 资源 URL
      op: `resource.${getResourceType(entry)}`, // resource.script / resource.css / resource.img
      startTime: entry.startTime / 1000,
      attributes: {
        'http.response_content_length': entry.transferSize,
        'resource.render_blocking_status': entry.renderBlockingStatus,
      },
    }).end(entry.responseEnd / 1000); // 直接用真实结束时间
  }
});
resourceObserver.observe({ type: 'resource', buffered: true });

七、分布式追踪:Header 传播

每个 HTTP Span 创建时,会在请求头中注入追踪上下文,实现前后端链路打通:

浏览器发起请求
  
  ├── 自动注入 Header:
       sentry-trace: {traceId}-{spanId}-{sampled}
       baggage: sentry-trace_id=xxx,sentry-environment=prod,...
  
后端 Sentry SDK 读取 Header
  
  └── 将后端 Span 挂载到同一 traceId   完整链路视图

配置允许传播的域名:

browserTracingIntegration({
  // 只对这些域名注入追踪 Header,防止泄露到第三方
  tracePropagationTargets: ['localhost', /^https://api.myapp.com/],
});

八、采集触发方式与时机

8.1 触发方式分类

Sentry 的采集触发方式可归纳为三大类:

触发方式说明适用场景
立即触发(Eager)SDK 初始化完成后同步执行,无需等待任何事件Pageload Span、TTFB
事件驱动(Event-driven)订阅浏览器事件或被 Monkey Patch 拦截后触发Navigation、fetch、XHR、INP
观察者回调(Observer-driven)浏览器异步推送性能条目时触发LCP、FCP、CLS、资源加载

8.2 各功能触发时机详解

Pageload Span

时机:afterAllSetup() 同步执行时,即 Sentry.init() 调用完毕后立刻触发

触发条件:
  ├── options.instrumentPageLoad !== false(默认开启)
  └── 当前是首次页面加载(非 SPA 导航)

注意:开始时间会被回溯到 navigation timing 的 fetchStart,
     即便 SDK 的 JS 是异步加载的,时间戳也是准确的

Navigation Span

时机:路由变化发生时触发(同步,在原始方法执行后立即触发)

触发条件(满足任一):
  ├── window.history.pushState 被调用
  ├── window.history.replaceState 被调用
  └── 浏览器 popstate 事件(前进 / 后退)

不触发的情况:
  ├── pageload 尚在进行中且极短时间内发生(视为重定向,跳过)
  └── options.instrumentNavigation === false

fetch / XHR Span

时机:请求调用时同步创建 Span,响应完成时结束 Span

fetch 触发节点:
  ├── 创建:window.fetch(...) 被调用时
  └── 结束:Promise resolve(response 返回)或 reject(网络错误)

XHR 触发节点:
  ├── 创建:xhr.send() 被调用时
  └── 结束:readystatechange 事件中 readyState === 4(DONE)

不触发的情况:
  ├── URL 不在 tracePropagationTargets 匹配范围内(不注入 Header,但仍创建 Span)
  └── shouldCreateSpanForRequest(url) 返回 false

LCP(最大内容绘制)

时机:由浏览器异步推送,通常在页面内容渲染完成后数百毫秒内触发

触发节点:
  ├── 首次触发:页面中最大的可见元素完成渲染
  ├── 可能多次触发:后续更大的元素出现时更新
  └── 最终值:用户首次交互(click/keydown/scroll)或页面隐藏时确定

buffered: true 的作用:
  即使 Observer 注册晚于实际 LCP 发生时刻,也能拿到历史条目

FCP(首次内容绘制)

时机:页面从白屏变为有任何内容(文字/图片/SVG)的瞬间,只触发一次

触发节点:浏览器绘制第一个 DOM 内容时,异步回调
特点:早于 LCP,通常在 DOMContentLoaded 前后

CLS(累积布局偏移)

时机:页面生命周期内持续累积,不是某一个时间点

触发节点:
  ├── 每次布局偏移(元素位置改变)都会触发一次回调
  ├── hadRecentInput === true 时忽略(用户交互引发的偏移不计入)
  └── 最终值:页面卸载或进入后台时

实现要点:Sentry 在内存中累加每次回调的 value,
         在 pageload span 结束时将总和写入 measurement

TTFB(首字节时间)

时机:Sentry.init() 执行时同步读取,无需等待任何事件

触发节点:
  performance.getEntriesByType('navigation')[0].responseStart
  
特点:此值在页面加载完成后就已存在于浏览器缓存中,
     SDK 直接读取即可,不需要 Observer

INP(交互到下一帧)

时机:每次用户交互后,浏览器完成下一帧渲染时触发

触发事件类型:click、keydown、pointerdown
触发条件:事件处理耗时 > durationThreshold(默认 40ms)

触发节点:
  ├── 用户触发输入事件
  ├── 浏览器处理事件并渲染下一帧
  └── PerformanceObserver 回调被调用(延迟,非同步)

INP 最终值:所有交互中耗时最长的那次(P98 近似)

资源加载 Spans

时机:每个资源(JS/CSS/图片/字体)加载完成后异步触发

触发节点:
  ├── 资源开始加载:记录在 entry.startTime
  └── 资源加载完成:PerformanceObserver 回调,此时 entry.responseEnd 已有值

buffered: true 的作用:
  捕获 SDK 注册 Observer 之前已完成加载的资源(如内联 <script> 提前加载的依赖)

8.3 采集触发完整时间线

以一次典型 SPA 页面访问为例:

t=0ms     浏览器发起 HTTP 请求(fetchStart)
           │
t=50ms    后端返回第一个字节(TTFB)
           │
t=200ms   HTML 解析完成,Sentry SDK JS 开始执行
           │
t=201ms   Sentry.init() → afterAllSetup()
           ├── [立即] 创建 Pageload Span(开始时间回溯到 t=0)
           ├── [立即] 读取 TTFB 写入 measurement
           ├── [立即] 注册 PerformanceObserver(LCP/FCP/CLS/资源)
           ├── [立即] Monkey Patch fetch / XHR
           └── [立即] Monkey Patch history.pushState
           │
t=210ms   FCP 触发 → PerformanceObserver 回调 → 写入 fcp measurement
           │
t=300ms   图片/JS 资源加载完成 → resource Observer 回调 → 创建 resource Spans
           │
t=450ms   LCP 触发(首次)→ 写入 lcp measurement
           │
t=600ms   用户点击按钮 → LCP 最终值确定(不再更新)
           │
t=601ms   click 触发 fetch('/api/data') → 创建 http.client Span
           │
t=650ms   fetch 响应返回 → http.client Span 结束
           │
t=1600ms  1s 内无新子 Span → Pageload Span idleTimeout → Pageload 结束并上报
           │
t=2000ms  用户点击路由链接 → history.pushState 被劫持
           └── 结束上一个 Span(若有)→ 创建新 Navigation Span

8.4 采集开关与采样控制

Sentry.init({
  // 全局采样率:0.0 ~ 1.0,控制多少比例的 transaction 被采集
  tracesSampleRate: 0.2, // 20% 采样

  // 动态采样:可根据 transaction 内容决定是否采集
  tracesSampler: (samplingContext) => {
    // 重要页面 100% 采样
    if (samplingContext.transactionContext.name === '/checkout') return 1.0;
    // 其余 5% 采样
    return 0.05;
  },
});

browserTracingIntegration({
  instrumentPageLoad: true,    // 是否采集页面加载(默认 true)
  instrumentNavigation: true,  // 是否采集 SPA 路由跳转(默认 true)

  // 请求级别过滤
  shouldCreateSpanForRequest: (url) => !url.includes('/health'),
});

采样决策发生时机:

Pageload / Navigation Span 创建时
  │
  ├── 检查是否有父级 Span(从 sentry-trace Header 继承采样决策)
  │
  ├── 无父级 → 执行 tracesSampler 或按 tracesSampleRate 随机抽样
  │
  └── 采样结果写入所有子 Span → 子 Span 不单独决策,全程继承父级结果
      (即一个 transaction 要么整体上报,要么整体丢弃)

九、总结:各功能的底层 API 对应关系

功能底层 Browser API核心技术触发方式触发时机
Pageload Spanperformance.getEntriesByType('navigation')Navigation Timing API + Idle Span立即触发afterAllSetup() 执行时
Navigation Spanhistory.pushState / popstateMonkey Patch事件驱动路由变化时
fetch 追踪window.fetchMonkey Patch事件驱动fetch() 调用时
XHR 追踪XMLHttpRequest.prototype.open/sendMonkey Patch事件驱动xhr.send() 调用时
FCPPerformanceObserver({ type: 'paint' })PerformanceObserver API观察者回调首次内容渲染后
LCPPerformanceObserver({ type: 'largest-contentful-paint' })PerformanceObserver API观察者回调最大元素渲染后(可多次)
CLSPerformanceObserver({ type: 'layout-shift' })PerformanceObserver API观察者回调每次布局偏移时累积
INPPerformanceObserver({ type: 'event' })PerformanceObserver API观察者回调用户交互渲染完成后
TTFBperformance.getEntriesByType('navigation')[0].responseStartNavigation Timing API立即触发SDK 初始化时同步读取
资源加载PerformanceObserver({ type: 'resource' })PerformanceObserver API观察者回调每个资源加载完成后
分布式追踪HTTP Header 注入sentry-trace / baggage Header随请求触发fetch/XHR 发出时注入