前端监控体系搭建
大约 16 分钟约 4802 字
前端监控体系搭建
简介
前端监控是保障 Web 应用质量的关键基础设施。一套完整的监控体系涵盖错误监控、性能监控、用户行为追踪三大维度,能够帮助团队第一时间发现线上问题、量化用户体验、驱动性能优化。本文从 SDK 设计到数据上报,从指标采集到告警规则,系统讲解前端监控体系的搭建方法。
特点
错误监控
JS 错误捕获
// monitor/plugins/js-error.ts
export interface JSErrorData {
type: 'js_error' | 'promise_rejection' | 'resource_error'
message: string
filename: string
lineno: number
colno: number
stack: string
timestamp: number
pageUrl: string
userAgent: string
}
export class JSErrorPlugin {
private reporter: (data: JSErrorData) => void
constructor(reporter: (data: JSErrorData) => void) {
this.reporter = reporter
}
init(): void {
this.catchJSErrors()
this.catchPromiseRejections()
this.catchResourceErrors()
this.catchConsoleErrors()
}
// 捕获 JS 运行时错误
private catchJSErrors(): void {
window.addEventListener('error', (event: ErrorEvent) => {
// 区分资源加载错误和 JS 错误
if (event.target && (event.target as HTMLElement).tagName) {
return // 资源错误由 catchResourceErrors 处理
}
const errorData: JSErrorData = {
type: 'js_error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack || '',
timestamp: Date.now(),
pageUrl: window.location.href,
userAgent: navigator.userAgent
}
this.reporter(errorData)
}, true)
}
// 捕获未处理的 Promise 拒绝
private catchPromiseRejections(): void {
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const reason = event.reason
let message = ''
let stack = ''
if (reason instanceof Error) {
message = reason.message
stack = reason.stack || ''
} else if (typeof reason === 'string') {
message = reason
} else {
message = JSON.stringify(reason)
}
const errorData: JSErrorData = {
type: 'promise_rejection',
message,
filename: '',
lineno: 0,
colno: 0,
stack,
timestamp: Date.now(),
pageUrl: window.location.href,
userAgent: navigator.userAgent
}
this.reporter(errorData)
})
}
// 捕获资源加载错误
private catchResourceErrors(): void {
window.addEventListener('error', (event: Event) => {
const target = event.target as HTMLElement
if (!target || !target.tagName) return
const tagName = target.tagName.toLowerCase()
if (!['img', 'script', 'link', 'audio', 'video'].includes(tagName)) return
const url =
(target as HTMLImageElement).src ||
(target as HTMLLinkElement).href ||
''
if (!url) return
const errorData: JSErrorData = {
type: 'resource_error',
message: `资源加载失败: ${tagName}`,
filename: url,
lineno: 0,
colno: 0,
stack: '',
timestamp: Date.now(),
pageUrl: window.location.href,
userAgent: navigator.userAgent
}
this.reporter(errorData)
}, true) // 使用捕获阶段
}
// 捕获 console.error
private catchConsoleErrors(): void {
const originalError = console.error
console.error = (...args: any[]) => {
originalError.apply(console, args)
const message = args.map(arg => {
if (arg instanceof Error) return arg.message
if (typeof arg === 'object') return JSON.stringify(arg)
return String(arg)
}).join(' ')
this.reporter({
type: 'js_error',
message,
filename: '',
lineno: 0,
colno: 0,
stack: '',
timestamp: Date.now(),
pageUrl: window.location.href,
userAgent: navigator.userAgent
})
}
}
}接口错误捕获
// monitor/plugins/api-error.ts
export interface ApiErrorData {
type: 'api_error'
url: string
method: string
status: number
statusText: string
duration: number
requestPayload: string
responseSnippet: string
timestamp: number
}
export class ApiErrorPlugin {
private reporter: (data: ApiErrorData) => void
constructor(reporter: (data: ApiErrorData) => void) {
this.reporter = reporter
}
init(): void {
this.interceptXHR()
this.interceptFetch()
}
// 拦截 XMLHttpRequest
private interceptXHR(): void {
const originalOpen = XMLHttpRequest.prototype.open
const originalSend = XMLHttpRequest.prototype.send
const reporter = this.reporter
XMLHttpRequest.prototype.open = function(
method: string, url: string, ...rest: any[]
) {
(this as any)._monitorData = {
method,
url,
startTime: 0,
payload: ''
}
return originalOpen.apply(this, [method, url, ...rest] as any)
}
XMLHttpRequest.prototype.send = function(payload?: Document | null) {
if ((this as any)._monitorData) {
(this as any)._monitorData.startTime = Date.now()
if (payload) {
try {
(this as any)._monitorData.payload = typeof payload === 'string'
? payload.slice(0, 500)
: ''
} catch { /* ignore */ }
}
this.addEventListener('loadend', function() {
const data = (this as any)._monitorData
if (!data) return
const duration = Date.now() - data.startTime
const status = this.status
// 只上报错误请求(4xx、5xx 或超时)
if (status >= 400 || status === 0) {
reporter({
type: 'api_error',
url: data.url,
method: data.method,
status,
statusText: this.statusText,
duration,
requestPayload: data.payload,
responseSnippet: this.responseText?.slice(0, 200) || '',
timestamp: Date.now()
})
}
})
}
return originalSend.apply(this, [payload] as any)
}
}
// 拦截 Fetch
private interceptFetch(): void {
const originalFetch = window.fetch
const reporter = this.reporter
window.fetch = async function(input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input :
input instanceof URL ? input.href :
input instanceof Request ? input.url : String(input)
const method = init?.method || 'GET'
const startTime = Date.now()
const payload = typeof init?.body === 'string'
? init.body.slice(0, 500)
: ''
try {
const response = await originalFetch.apply(this, [input, init] as any)
const duration = Date.now() - startTime
if (!response.ok) {
let responseText = ''
try {
responseText = await response.clone().text()
} catch { /* ignore */ }
reporter({
type: 'api_error',
url,
method,
status: response.status,
statusText: response.statusText,
duration,
requestPayload: payload,
responseSnippet: responseText.slice(0, 200),
timestamp: Date.now()
})
}
return response
} catch (error) {
reporter({
type: 'api_error',
url,
method,
status: 0,
statusText: (error as Error).message,
duration: Date.now() - startTime,
requestPayload: payload,
responseSnippet: '',
timestamp: Date.now()
})
throw error
}
}
}
}性能监控
Web Vitals 指标采集
// monitor/plugins/web-vitals.ts
export interface PerformanceData {
type: 'performance'
// Core Web Vitals
LCP: number | null // Largest Contentful Paint
FID: number | null // First Input Delay
CLS: number | null // Cumulative Layout Shift
INP: number | null // Interaction to Next Paint
TTFB: number | null // Time to First Byte
// 其他指标
FCP: number | null // First Contentful Paint
TTI: number | null // Time to Interactive
domReady: number | null
load: number | null
timestamp: number
pageUrl: string
}
export class WebVitalsPlugin {
private reporter: (data: PerformanceData) => void
private metrics: Partial<PerformanceData> = {}
constructor(reporter: (data: PerformanceData) => void) {
this.reporter = reporter
}
init(): void {
this.measureTTFB()
this.measureFCP()
this.measureLCP()
this.measureFID()
this.measureCLS()
this.measureINP()
this.measurePageLoad()
}
// TTFB: 首字节时间
private measureTTFB(): void {
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
if (navEntry) {
this.metrics.TTFB = navEntry.responseStart - navEntry.requestStart
}
}
// FCP: 首次内容绘制
private measureFCP(): void {
const paintEntries = performance.getEntriesByType('paint')
const fcp = paintEntries.find(
(entry) => entry.name === 'first-contentful-paint'
)
if (fcp) {
this.metrics.FCP = fcp.startTime
}
}
// LCP: 最大内容绘制
private measureLCP(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.metrics.LCP = lastEntry.startTime
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
}
// FID: 首次输入延迟
private measureFID(): void {
const observer = new PerformanceObserver((list) => {
const firstInput = list.getEntries()[0] as any
if (firstInput) {
this.metrics.FID = firstInput.processingStart - firstInput.startTime
}
})
observer.observe({ type: 'first-input', buffered: true })
}
// CLS: 累积布局偏移
private measureCLS(): void {
let clsValue = 0
let sessionValue = 0
let sessionEntries: any[] = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0]
const lastSessionEntry = sessionEntries[sessionEntries.length - 1]
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value
} else {
sessionValue = entry.value
sessionEntries = []
}
sessionEntries.push(entry)
clsValue = Math.max(clsValue, sessionValue)
this.metrics.CLS = clsValue
}
}
})
observer.observe({ type: 'layout-shift', buffered: true })
}
// INP: 交互到下一次绘制
private measureINP(): void {
let maxINP = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (entry.interactionId) {
const duration = entry.processingStart
? entry.processingEnd - entry.startTime
: entry.duration
maxINP = Math.max(maxINP, duration)
this.metrics.INP = maxINP
}
}
})
try {
observer.observe({ type: 'event', buffered: true })
} catch {
// 浏览器不支持 event 类型
}
}
// 页面加载性能
private measurePageLoad(): void {
window.addEventListener('load', () => {
setTimeout(() => {
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
if (!timing) return
this.metrics.domReady = timing.domContentLoadedEventEnd - timing.fetchStart
this.metrics.load = timing.loadEventEnd - timing.fetchStart
this.metrics.timestamp = Date.now()
this.metrics.pageUrl = window.location.href
this.metrics.type = 'performance'
this.reporter(this.metrics as PerformanceData)
}, 0)
})
}
}资源加载监控
// monitor/plugins/resource-timing.ts
export interface ResourceTimingData {
type: 'resource_timing'
resources: Array<{
name: string
initiatorType: string
duration: number
transferSize: number
encodedBodySize: number
startTime: number
}>
timestamp: number
}
export class ResourceTimingPlugin {
private reporter: (data: ResourceTimingData) => void
constructor(reporter: (data: ResourceTimingData) => void) {
this.reporter = reporter
}
init(): void {
// 页面加载完成后采集资源信息
window.addEventListener('load', () => {
setTimeout(() => this.collect(), 1000)
})
}
private collect(): void {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const resources = entries.map(entry => ({
name: this.simplifyUrl(entry.name),
initiatorType: entry.initiatorType,
duration: Math.round(entry.duration),
transferSize: entry.transferSize,
encodedBodySize: entry.encodedBodySize,
startTime: Math.round(entry.startTime)
}))
this.reporter({
type: 'resource_timing',
resources,
timestamp: Date.now()
})
}
private simplifyUrl(url: string): string {
try {
const u = new URL(url)
return u.pathname + u.search
} catch {
return url.slice(0, 200)
}
}
}用户行为追踪
PV/UV 与路由追踪
// monitor/plugins/user-behavior.ts
export interface BehaviorData {
type: 'pv' | 'click' | 'route_change' | 'custom'
path: string
referrer?: string
action?: string
element?: string
extra?: Record<string, any>
timestamp: number
sessionId: string
userId?: string
}
export class UserBehaviorPlugin {
private reporter: (data: BehaviorData) => void
private sessionId: string
private userId?: string
private lastPath = ''
constructor(
reporter: (data: BehaviorData) => void,
userId?: string
) {
this.reporter = reporter
this.userId = userId
this.sessionId = this.generateSessionId()
}
init(): void {
this.trackPV()
this.trackClicks()
this.trackRouteChanges()
this.trackPageVisibility()
}
// 生成会话 ID
private generateSessionId(): string {
const id = sessionStorage.getItem('_monitor_sid')
if (id) return id
const newId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
sessionStorage.setItem('_monitor_sid', newId)
return newId
}
// PV 采集
private trackPV(): void {
this.reportPV()
// 监听 popstate 事件(浏览器前进后退)
window.addEventListener('popstate', () => {
this.reportPV()
})
}
private reportPV(): void {
const currentPath = window.location.pathname + window.location.search
if (currentPath === this.lastPath) return
this.lastPath = currentPath
this.reporter({
type: 'pv',
path: currentPath,
referrer: document.referrer,
timestamp: Date.now(),
sessionId: this.sessionId,
userId: this.userId
})
}
// 点击事件采集
private trackClicks(): void {
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target) return
// 获取有意义的元素描述
const description = this.getElementDescription(target)
this.reporter({
type: 'click',
path: window.location.pathname,
action: 'click',
element: description,
timestamp: Date.now(),
sessionId: this.sessionId,
userId: this.userId
})
}, { capture: true })
}
// 获取元素描述
private getElementDescription(el: HTMLElement): string {
const tag = el.tagName.toLowerCase()
// 优先使用 data-monitor 属性
const monitorTag = el.getAttribute('data-monitor')
if (monitorTag) return `${tag}[data-monitor="${monitorTag}"]`
// 使用 id
if (el.id) return `${tag}#${el.id}`
// 使用 class
const classStr = el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: ''
// 使用文字内容
const text = el.textContent?.trim().slice(0, 20) || ''
return `${tag}${classStr}${text ? `("${text}")` : ''}`
}
// SPA 路由变化追踪
private trackRouteChanges(): void {
// 拦截 history.pushState
const originalPushState = history.pushState
history.pushState = (...args) => {
originalPushState.apply(history, args)
this.reportRouteChange('pushState')
}
// 拦截 history.replaceState
const originalReplaceState = history.replaceState
history.replaceState = (...args) => {
originalReplaceState.apply(history, args)
this.reportRouteChange('replaceState')
}
}
private reportRouteChange(trigger: string): void {
const currentPath = window.location.pathname + window.location.search
this.reporter({
type: 'route_change',
path: currentPath,
referrer: this.lastPath,
extra: { trigger },
timestamp: Date.now(),
sessionId: this.sessionId,
userId: this.userId
})
this.lastPath = currentPath
}
// 页面可见性追踪(统计停留时长)
private trackPageVisibility(): void {
let enterTime = Date.now()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const stayDuration = Date.now() - enterTime
this.reporter({
type: 'custom',
path: window.location.pathname,
action: 'page_stay',
extra: { duration: stayDuration },
timestamp: Date.now(),
sessionId: this.sessionId,
userId: this.userId
})
} else {
enterTime = Date.now()
}
})
}
// 自定义事件
trackEvent(name: string, extra?: Record<string, any>): void {
this.reporter({
type: 'custom',
path: window.location.pathname,
action: name,
extra,
timestamp: Date.now(),
sessionId: this.sessionId,
userId: this.userId
})
}
}SDK 架构设计
核心监控 SDK
// monitor/core/sdk.ts
export interface MonitorConfig {
// 必填
endpoint: string // 数据上报地址
appId: string // 应用标识
// 可选
userId?: string
version?: string
env?: 'development' | 'staging' | 'production'
// 开关
enableJsError?: boolean // 默认 true
enableApiError?: boolean // 默认 true
enablePerformance?: boolean // 默认 true
enableBehavior?: boolean // 默认 true
enableResourceTiming?: boolean // 默认 false
// 采样
sampleRate?: number // 0-1,默认 1
performanceSampleRate?: number // 性能数据采样率,默认 0.5
// 上报
maxBatchSize?: number // 批量上报大小,默认 10
flushInterval?: number // 上报间隔 ms,默认 5000
maxQueueSize?: number // 最大队列,默认 50
}
export class MonitorSDK {
private config: Required<MonitorConfig>
private queue: any[] = []
private plugins: any[] = []
private flushTimer: ReturnType<typeof setInterval> | null = null
constructor(config: MonitorConfig) {
this.config = {
endpoint: config.endpoint,
appId: config.appId,
userId: config.userId || '',
version: config.version || '1.0.0',
env: config.env || 'production',
enableJsError: config.enableJsError ?? true,
enableApiError: config.enableApiError ?? true,
enablePerformance: config.enablePerformance ?? true,
enableBehavior: config.enableBehavior ?? true,
enableResourceTiming: config.enableResourceTiming ?? false,
sampleRate: config.sampleRate ?? 1,
performanceSampleRate: config.performanceSampleRate ?? 0.5,
maxBatchSize: config.maxBatchSize ?? 10,
flushInterval: config.flushInterval ?? 5000,
maxQueueSize: config.maxQueueSize ?? 50
}
this.init()
}
private init(): void {
// 注册插件
if (this.config.enableJsError) {
this.use(new JSErrorPlugin(this.addToQueue.bind(this)))
}
if (this.config.enableApiError) {
this.use(new ApiErrorPlugin(this.addToQueue.bind(this)))
}
if (this.config.enablePerformance) {
this.use(new WebVitalsPlugin(this.addToQueue.bind(this)))
}
if (this.config.enableBehavior) {
this.use(new UserBehaviorPlugin(this.addToQueue.bind(this), this.config.userId))
}
if (this.config.enableResourceTiming) {
this.use(new ResourceTimingPlugin(this.addToQueue.bind(this)))
}
// 启动定时上报
this.startFlush()
// 页面关闭时上报
this.bindUnload()
}
// 注册插件
use(plugin: { init: () => void }): void {
plugin.init()
this.plugins.push(plugin)
}
// 添加到上报队列
private addToQueue(data: any): void {
// 采样检查
if (!this.shouldSample(data)) return
// 添加公共字段
const enrichedData = {
...data,
appId: this.config.appId,
version: this.config.version,
env: this.config.env,
timestamp: data.timestamp || Date.now()
}
this.queue.push(enrichedData)
// 队列满时立即上报
if (this.queue.length >= this.config.maxBatchSize) {
this.flush()
}
// 超过最大队列丢弃旧数据
if (this.queue.length > this.config.maxQueueSize) {
this.queue.shift()
}
}
// 采样判断
private shouldSample(data: any): boolean {
if (data.type === 'performance') {
return Math.random() < this.config.performanceSampleRate
}
return Math.random() < this.config.sampleRate
}
// 启动定时上报
private startFlush(): void {
this.flushTimer = setInterval(() => {
if (this.queue.length > 0) {
this.flush()
}
}, this.config.flushInterval)
}
// 批量上报
private flush(): void {
if (this.queue.length === 0) return
const batch = this.queue.splice(0, this.config.maxBatchSize)
this.report(batch)
}
// 页面卸载时上报
private bindUnload(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.report(this.queue)
this.queue = []
}
})
}
// 数据上报
private report(data: any[]): void {
if (data.length === 0) return
const payload = JSON.stringify(data)
// 优先使用 sendBeacon
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' })
const sent = navigator.sendBeacon(this.config.endpoint, blob)
if (sent) return
}
// 降级使用 Image
this.reportByImage(payload)
}
// Image 方式上报
private reportByImage(data: string): void {
const img = new Image()
const encodedData = encodeURIComponent(data)
// URL 过长时截断
const url = `${this.config.endpoint}?data=${encodedData}`
if (url.length > 2048) {
// 使用 XHR POST 代替
this.reportByXHR(data)
return
}
img.src = url
}
// XHR 方式上报
private reportByXHR(data: string): void {
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', this.config.endpoint, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(data)
} catch {
// 上报失败静默处理
}
}
// 手动上报自定义事件
track(name: string, extra?: Record<string, any>): void {
this.addToQueue({
type: 'custom',
action: name,
extra,
timestamp: Date.now()
})
}
// 销毁
destroy(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
}
this.flush()
}
}
// 初始化使用
export function initMonitor(config: MonitorConfig): MonitorSDK {
return new MonitorSDK(config)
}SourceMap 集成
错误堆栈还原
// monitor/utils/sourcemap.ts
// 注意:SourceMap 还原通常在服务端完成,以下为服务端处理逻辑
import { SourceMapConsumer } from 'source-map'
interface StackFrame {
filename: string
lineno: number
colno: number
functionName?: string
}
interface OriginalPosition {
source: string | null
line: number | null
column: number | null
name: string | null
}
export class SourceMapResolver {
private cache = new Map<string, SourceMapConsumer>()
// 解析错误堆栈
async resolveStackTrace(
stack: string,
stackTraceLimit = 10
): Promise<OriginalPosition[]> {
const frames = this.parseStackFrames(stack, stackTraceLimit)
const resolved: OriginalPosition[] = []
for (const frame of frames) {
const consumer = await this.getSourceMapConsumer(frame.filename)
if (consumer) {
const position = consumer.originalPositionFor({
line: frame.lineno,
column: frame.colno
})
resolved.push(position)
} else {
resolved.push({
source: frame.filename,
line: frame.lineno,
column: frame.colno,
name: frame.functionName || null
})
}
}
return resolved
}
// 解析堆栈帧
private parseStackFrames(stack: string, limit: number): StackFrame[] {
const frameRegex = /at\s+(.+?)\s+\(?(https?:\/\/.+?):(\d+):(\d+)\)?/g
const frames: StackFrame[] = []
let match: RegExpExecArray | null
while ((match = frameRegex.exec(stack)) !== null && frames.length < limit) {
frames.push({
functionName: match[1],
filename: match[2],
lineno: parseInt(match[3], 10),
colno: parseInt(match[4], 10)
})
}
return frames
}
// 获取 SourceMap Consumer(带缓存)
private async getSourceMapConsumer(filename: string): Promise<SourceMapConsumer | null> {
if (this.cache.has(filename)) {
return this.cache.get(filename)!
}
try {
const mapUrl = await this.fetchSourceMapUrl(filename)
if (!mapUrl) return null
const response = await fetch(mapUrl)
const mapData = await response.json()
const consumer = await new SourceMapConsumer(mapData)
this.cache.set(filename, consumer)
return consumer
} catch {
return null
}
}
// 获取 SourceMap URL
private async fetchSourceMapUrl(filename: string): Promise<string | null> {
const response = await fetch(filename)
const content = await response.text()
const match = content.match(/\/\/[#@]\s*sourceMappingURL=([^\s]+)/)
if (match) {
const mapFile = match[1]
const base = filename.substring(0, filename.lastIndexOf('/'))
return `${base}/${mapFile}`
}
return null
}
}告警规则配置
告警规则设计
// monitor/alerting/rules.ts
export interface AlertRule {
id: string
name: string
metric: string
condition: 'gt' | 'lt' | 'gte' | 'lte' | 'eq'
threshold: number
window: number // 时间窗口(秒)
severity: 'critical' | 'warning' | 'info'
channels: string[] // 通知渠道
enabled: boolean
}
// 预置告警规则
export const defaultAlertRules: AlertRule[] = [
{
id: 'js-error-rate',
name: 'JS 错误率过高',
metric: 'js_error_rate',
condition: 'gt',
threshold: 1, // 错误率 > 1%
window: 300, // 5 分钟窗口
severity: 'critical',
channels: ['webhook', 'email'],
enabled: true
},
{
id: 'api-error-rate',
name: 'API 错误率过高',
metric: 'api_error_rate',
condition: 'gt',
threshold: 5, // 错误率 > 5%
window: 300,
severity: 'critical',
channels: ['webhook', 'email'],
enabled: true
},
{
id: 'lcp-slow',
name: 'LCP 过慢',
metric: 'lcp_p75',
condition: 'gt',
threshold: 4000, // P75 > 4s
window: 600, // 10 分钟
severity: 'warning',
channels: ['webhook'],
enabled: true
},
{
id: 'cls-high',
name: 'CLS 过高',
metric: 'cls_p75',
condition: 'gt',
threshold: 0.25, // P75 > 0.25
window: 600,
severity: 'warning',
channels: ['webhook'],
enabled: true
},
{
id: 'resource-error',
name: '资源加载失败过多',
metric: 'resource_error_count',
condition: 'gt',
threshold: 50, // 5分钟内 > 50 次
window: 300,
severity: 'warning',
channels: ['webhook'],
enabled: true
}
]
// 告警评估引擎
export class AlertEvaluator {
private rules: AlertRule[]
constructor(rules: AlertRule[] = defaultAlertRules) {
this.rules = rules.filter(r => r.enabled)
}
evaluate(
metric: string,
value: number,
onAlert: (rule: AlertRule, value: number) => void
): void {
for (const rule of this.rules) {
if (rule.metric !== metric) continue
const triggered = this.evaluateCondition(
value,
rule.condition,
rule.threshold
)
if (triggered) {
onAlert(rule, value)
}
}
}
private evaluateCondition(
value: number,
condition: string,
threshold: number
): boolean {
switch (condition) {
case 'gt': return value > threshold
case 'lt': return value < threshold
case 'gte': return value >= threshold
case 'lte': return value <= threshold
case 'eq': return value === threshold
default: return false
}
}
}告警通知
// monitor/alerting/notifier.ts
export interface NotificationPayload {
rule: AlertRule
value: number
timestamp: number
dashboardUrl: string
}
export class AlertNotifier {
private webhookUrl: string
private emailEndpoint: string
constructor(config: { webhookUrl: string; emailEndpoint: string }) {
this.webhookUrl = config.webhookUrl
this.emailEndpoint = config.emailEndpoint
}
async notify(
rule: AlertRule,
value: number,
channels: string[]
): Promise<void> {
const payload: NotificationPayload = {
rule,
value,
timestamp: Date.now(),
dashboardUrl: `https://monitor.example.com/dashboard?metric=${rule.metric}`
}
const tasks = channels.map(channel => {
switch (channel) {
case 'webhook':
return this.sendWebhook(payload)
case 'email':
return this.sendEmail(payload)
default:
return Promise.resolve()
}
})
await Promise.allSettled(tasks)
}
private async sendWebhook(payload: NotificationPayload): Promise<void> {
const { rule, value } = payload
await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `[${rule.severity.toUpperCase()}] ${rule.name}`,
content: `指标 ${rule.metric} 当前值 ${value},阈值 ${rule.threshold},窗口 ${rule.window}s`,
severity: rule.severity,
dashboardUrl: payload.dashboardUrl,
timestamp: payload.timestamp
})
})
}
private async sendEmail(payload: NotificationPayload): Promise<void> {
// 调用邮件服务
await fetch(this.emailEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: `[前端监控告警] ${payload.rule.name}`,
severity: payload.rule.severity,
body: this.formatEmailBody(payload)
})
})
}
private formatEmailBody(payload: NotificationPayload): string {
return [
`告警规则: ${payload.rule.name}`,
`告警级别: ${payload.rule.severity}`,
`监控指标: ${payload.rule.metric}`,
`当前数值: ${payload.value}`,
`阈值: ${payload.rule.threshold}`,
`检测窗口: ${payload.rule.window} 秒`,
`触发时间: ${new Date(payload.timestamp).toLocaleString()}`,
`监控面板: ${payload.dashboardUrl}`
].join('\n')
}
}隐私合规
数据脱敏
// monitor/utils/privacy.ts
export class PrivacyGuard {
private sensitiveFields: string[]
private urlPatterns: RegExp[]
constructor() {
// 敏感字段列表
this.sensitiveFields = [
'password', 'token', 'secret', 'creditCard',
'phone', 'email', 'idCard', 'address',
'accessToken', 'refreshToken'
]
// 需要脱敏的 URL 模式
this.urlPatterns = [
/token=[^&]+/gi,
/key=[^&]+/gi,
/secret=[^&]+/gi
]
}
// 脱敏数据
sanitize<T>(data: T): T {
if (typeof data !== 'object' || data === null) return data
const sanitized = { ...data } as any
for (const key of Object.keys(sanitized)) {
if (this.isSensitiveField(key)) {
sanitized[key] = this.maskValue(sanitized[key])
} else if (typeof sanitized[key] === 'object') {
sanitized[key] = this.sanitize(sanitized[key])
}
}
return sanitized
}
// 脱敏 URL
sanitizeUrl(url: string): string {
let result = url
for (const pattern of this.urlPatterns) {
result = result.replace(pattern, (match) => {
const [key] = match.split('=')
return `${key}=***`
})
}
return result
}
private isSensitiveField(field: string): boolean {
const lower = field.toLowerCase()
return this.sensitiveFields.some(s => lower.includes(s.toLowerCase()))
}
private maskValue(value: any): string {
if (typeof value !== 'string') return '***'
if (value.length <= 4) return '****'
return value.slice(0, 2) + '***' + value.slice(-2)
}
}优点
- 全面的错误捕获:覆盖 JS 错误、Promise 拒绝、资源加载、接口异常
- 真实用户体验量化:Web Vitals 指标直接关联用户感受
- 完整行为链路:从页面访问到操作行为完整追踪
- 插件化架构:按需加载监控模块,最小化性能影响
- 采样策略:灵活控制数据量,降低服务端压力
- 隐私合规:敏感数据自动脱敏
缺点
- SDK 性能开销:监控代码本身会带来少量性能损耗
- 数据量大:全量采集时服务端存储压力较大
- SourceMap 管理复杂:版本迭代时需同步更新 SourceMap
- 跨域限制:部分监控场景受浏览器安全策略限制
- 兼容性差异:不同浏览器 Performance API 支持程度不同
- 维护成本:监控规则和告警需要持续调优
性能注意事项
- SDK 异步加载:监控脚本使用 async 加载,不阻塞页面渲染
- 批量上报:合并多条数据一次性上报,减少请求数
- 采样控制:根据流量动态调整采样率,高峰期降低采样
- Beacon 优先:使用 sendBeacon 确保页面关闭时数据不丢失
- 数据压缩:上报前对数据进行压缩(gzip 或自定义编码)
- 队列控制:限制本地队列大小,防止内存占用过高
- 节流采集:频繁事件(如滚动、resize)做节流处理
总结
前端监控体系是现代 Web 应用的基础设施。一套完善的监控包含错误监控(JS 异常、资源错误、API 错误)、性能监控(Web Vitals 指标)、用户行为追踪三大模块。SDK 采用插件化设计,支持灵活配置和按需加载。数据上报使用 sendBeacon + Image + XHR 三级降级策略,确保数据可靠性。配合告警规则和可视化面板,可以第一时间发现和定位线上问题。
关键知识点
- Web Vitals 核心指标:LCP、FID(INP)、CLS 以及辅助指标 TTFB、FCP
- 错误捕获三要素:window.error(JS 错误)、unhandledrejection(Promise)、资源 error 事件
- 数据上报三级降级:sendBeacon -> Image -> XHR
- 性能数据通过 PerformanceObserver 采集
- SourceMap 用于还原压缩后的错误堆栈
- 采样率控制是降低监控成本的关键手段
常见误区
- 采集全量数据 — 高流量场景下应使用采样策略控制数据量
- 监控 SDK 放在 head 同步加载 — 会阻塞首屏渲染,应使用 async 加载
- 只关注 JS 错误 — 资源加载失败和 API 错误同样影响用户体验
- FID 就是首屏交互性能的全部 — FID 只衡量首次交互,INP 更全面
- CLS 越低越好 — CLS 衡量的是意外偏移,正常动画不影响 CLS
- SourceMap 直接部署到生产环境 — SourceMap 应该只在服务端使用,不应暴露给用户
进阶路线
- 入门:JS 错误捕获、PV 采集、基础性能指标
- 进阶:Web Vitals 全套采集、API 拦截、用户行为追踪
- 高级:SDK 插件化设计、采样策略、SourceMap 还原
- 专家:实时告警引擎、异常检测算法、A/B 实验监控
- 架构:监控平台搭建、多维度数据分析、智能告警
适用场景
- 所有面向用户的 Web 应用
- 需要量化用户体验指标的场景
- 高频迭代的敏捷项目(快速发现回归问题)
- 对可用性要求高的核心业务系统
- 需要用户行为分析的数据驱动产品
落地建议
- 分阶段实施:先上错误监控,再加性能监控,最后完善行为追踪
- 选择合适的上报策略:小流量全量采集,大流量采样采集
- 做好数据脱敏:确保上报数据不包含用户敏感信息
- 建立告警基线:先观察一周数据,再设置合理的告警阈值
- 定期复盘:每周分析 Top 错误和性能瓶颈,持续优化
- 与 CI/CD 集成:部署后自动关注错误率变化
排错清单
复盘问题
- 为什么资源加载错误需要使用捕获阶段(capture: true)?
- sendBeacon、Image、XHR 三种上报方式各自的优缺点是什么?
- CLS 的计算为什么使用会话窗口而不是简单累加?
- 如何设计采样策略才能在降低成本的同时保留关键数据?
- 监控 SDK 自身出错了怎么办?如何避免无限循环上报?
- 在 SPA 应用中如何准确统计 PV 和页面停留时长?
