Sentry browserTracingIntegration 实现原理深度解析

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


一、整体架构

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

scss 复制代码
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:页面加载追踪

实现方式

javascript 复制代码
// 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 不会被显式结束,而是通过空闲机制自动关闭:

ini 复制代码
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 来感知路由变化:

javascript 复制代码
// 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):

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

框架集成:参数化路由名

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

bash 复制代码
// @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

javascript 复制代码
// 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

kotlin 复制代码
// 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:

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

五、Web Vitals 追踪

实现方式:PerformanceObserver

php 复制代码
// 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+ 默认启用)

php 复制代码
// 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:

php 复制代码
// 实验性配置
browserTracingIntegration({
  _experiments: {
    enableStandaloneLcpSpans: true,
    enableStandaloneClsSpans: true,
  },
});

六、资源加载 Spans

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

javascript 复制代码
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 创建时,会在请求头中注入追踪上下文,实现前后端链路打通:

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

配置允许传播的域名:

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

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

功能 底层 Browser API 核心技术
Pageload Span performance.getEntriesByType('navigation') Navigation Timing API + Idle Span
Navigation Span history.pushState / popstate Monkey Patch
fetch 追踪 window.fetch Monkey Patch
XHR 追踪 XMLHttpRequest.prototype.open/send Monkey Patch
LCP / FCP / CLS new PerformanceObserver(...) PerformanceObserver API
INP PerformanceObserver({ type: 'event' }) PerformanceObserver API
TTFB performance.getEntriesByType('navigation')[0].responseStart Navigation Timing API
资源加载 PerformanceObserver({ type: 'resource' }) PerformanceObserver API
分布式追踪 HTTP Header 注入 sentry-trace / baggage Header
相关推荐
孟沐1 小时前
大白话理解 Java 序列化:对标前端 JSON.stringify/parse
前端
忘ci1 小时前
electron、edge.js调用C#动态链接库的一些问题
前端
yannick_liu1 小时前
推荐一个可以在vue2中格式化json数据的插件
前端
可视之道1 小时前
Canvas 渲染引擎性能优化实战:从 15 FPS 到 55 FPS
前端
小猪努力学前端2 小时前
基于PixiJS的试玩广告开发-续篇
前端·javascript·游戏
bluceli2 小时前
前端构建工具深度解析:从Webpack到Vite的演进之路
前端
wuhen_n2 小时前
v-model 的进阶用法:搞定复杂的父子组件数据通信
前端·javascript·vue.js
wuhen_n2 小时前
TypeScript 深度加持:让你的组合式函数拥有“钢筋铁骨”
前端·javascript·vue.js
滕青山3 小时前
基于 ZXing 的 Vue 在线二维码扫描器实现
前端·javascript·vue.js