基于
@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 Span | performance.getEntriesByType('navigation') | Navigation Timing API + Idle Span | 立即触发 | afterAllSetup() 执行时 |
| Navigation Span | history.pushState / popstate | Monkey Patch | 事件驱动 | 路由变化时 |
| fetch 追踪 | window.fetch | Monkey Patch | 事件驱动 | fetch() 调用时 |
| XHR 追踪 | XMLHttpRequest.prototype.open/send | Monkey Patch | 事件驱动 | xhr.send() 调用时 |
| FCP | PerformanceObserver({ type: 'paint' }) | PerformanceObserver API | 观察者回调 | 首次内容渲染后 |
| LCP | PerformanceObserver({ type: 'largest-contentful-paint' }) | PerformanceObserver API | 观察者回调 | 最大元素渲染后(可多次) |
| CLS | PerformanceObserver({ type: 'layout-shift' }) | PerformanceObserver API | 观察者回调 | 每次布局偏移时累积 |
| INP | PerformanceObserver({ type: 'event' }) | PerformanceObserver API | 观察者回调 | 用户交互渲染完成后 |
| TTFB | performance.getEntriesByType('navigation')[0].responseStart | Navigation Timing API | 立即触发 | SDK 初始化时同步读取 |
| 资源加载 | PerformanceObserver({ type: 'resource' }) | PerformanceObserver API | 观察者回调 | 每个资源加载完成后 |
| 分布式追踪 | HTTP Header 注入 | sentry-trace / baggage Header | 随请求触发 | fetch/XHR 发出时注入 |