Skip to content

前端监控SDK

背景

  • 作为C端产品,我们是直接与用户打交道,一个产品的稳定性会直接影响到用户的留存,所以前端监控尤为重要,可以及时发生问题,解决问题,同时也可以根据产品的需求做业务数据埋点,通过分析埋点信息,及时调整产品的走向

js错误监控

TIP

在promise中发生的异常,window.onerror无法捕获

  • 通常情况一般用window.onerror来捕获js运用时异常,onerror事件不仅能捕获到同步异常,还能捕获异步的异常
  • try,catch只能捕获js同步运用时异常
js
// 同步异常可以捕获
try {
	a.x;
} catch (error) {
	console.log('error');
}

// 异步异常无法捕获
try {
	setTimeout(() => {
		a.x;
	});
} catch (error) {
	console.log('error');
}

// 同步/异步都能捕获
window.onerror = (...arg) => {
  	const [message, sourceURL, line, column, errorObj] = arg;
	console.log('onerror');
};

promise错误监控

  • window.onunhandledrejection可以捕获到Promise的运用时异常
  • 这里需要注意的时,Promise在reject时,也会被捕获,这种情况需要过滤
js
new Promise(() => {
	a.x;
});

new Promise((resolve,reject) => {
	reject(false)
});

window.onunhandledrejection = ()=>{
  // reject 错误不上报
	if (!event?.reason?.stack) return;
	console.log('onunhandledrejection');
}

资源加载异常

TIP

1.为捕获状态时(第三个参数为true)能捕获到js执行错误,也能捕获带有src的标签元素的加载错误。

2.为冒泡状态时(第三个参数为false)能捕获到js执行错误,不能捕获带有src的标签元素的加载错误。

  • 如果资源加载异常会被 window.addEventListener('error',handler,true)捕获,同时它也可以js运用时异常
  • 对于非资源加载的异常,需要过滤掉
js
		window.addEventListener(
			'error',
			(event: any) => {
				// 只处理资源错误
				if (
					event.target &&
					(event.target.src || event.target.href || event.target.currentSrc)
				) {
					this.callback({
						type: ReportType.RESOURCES_ERROR,
						message: '资源加载异常',
						selector: Utils.getSelectors(),
						sourceURL:
							event.target.src || event.target.href || event.target.currentSrc,
					});
				}
			},
			true
		);

路由监听

Hash 路由

  • hash路由监听用hashchange来监听,hash路由发生变化会触发hashchange,同时也会触发popstate并且先触发
  • hashchange无法监听History 路由的变化

History 路由

  • History是html5出现的API,他有五个API,backgoforwardpushStatereplaceState
  • 可以用popstate来监听History路由的变化,但是遗憾的是只会backgoforward调用才会触发该事件,pushStatereplaceState无法触发
  • 浏览器前进、后退按钮也会被popstate监听

路由监听兼容性思路

  • 经过以上分析,可以用popstate来监听路由的变化
  • pushStatereplaceState无法触发,可以用AOP的方式重写这2个方法,创建新的全局Event事件。然后 window.addEventListener 去监听Event

VueRouter的坑

  • 1、VueRouter有一个特性,在打开页面的时候,会触发historyreplaceState

  • 2、VueRouter在push操作的时候会先触发history replaceState在触发pushState

  • 3、VueRouter路由内部会监听beforeunload事件,在刷新页面的时候会通过beforeunload事件,强制触发replaceState

  • 解决思路:通过js事件循环机制来实现

    • 1、首先定义一个标志位默认为true
    • 2、当事件被触发后,如果是true,进入异步队列,如果此时连续进来2个事件只会触发一个,同步任务完成后,处理异步事件重置标志位
ts
 // 重写pushState、replaceState路由 
	_patchRouter(type: keyof History) {
   	const orig = history[type];
   	const _this = this;
   	return function (this: unknown) {
   		_this.queueFlush(() => {
   			if (_this.isBeforeunload) return;
   			const e = new Event(type);
       // 创建新的全局Event事件
   			window.dispatchEvent(e);
   		});
   		return orig.apply(this, arguments);
   	};
   }

   rewriteHistory() {
   	// fix: vue路由在push会先触发replaceState在pushState
   	history.pushState = this._patchRouter('pushState');
   	history.replaceState = this._patchRouter('replaceState');
   }
   beforeunloadFix() {
   	// fix: vue路由会监听beforeunload事件,在刷新页面的时候会通过beforeunload事件,强制触发replaceState
   	window.addEventListener('beforeunload', () => {
   		this.isBeforeunload = true;
   	});
   }

   routerChangeListener(handler: (e: Event) => void) {
   	// 监听Hash、History路由变化
   	window.addEventListener('popstate', e => handler(e), true);
   	// 监听Event
   	window.addEventListener('replaceState', e => handler(e), true);
   	window.addEventListener('pushState', e => handler(e), true);
   }

产品爆光埋点

  • 产品爆光埋点就是,用户在浏览网页时,产品已经在网页可视区域内

  • 实际场景:如果一个产品的爆光率很高,但是下单率很低,说明产品转换率很低,产品需要调整策略

  • 实现思路:

    • 1、定义一个属性比如是data-appear
    • 2、当页面加载完成后,路由地址发生变化后,获取所有带data-appeardom节点,注意由于都是虚拟DOM,不一定立马会拿到真实的DOM,这里延迟递归十次每次100毫秒
    • 3、遍历DOM节点,通过IntersectionObserver监听,如果有data-appear属性,并且intersectionRatio>0,说明在可视区域,上报数据
ts
/**
 * 爆光上报(是否可见浏览过)
 */

import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Exposure {
	ob: IntersectionObserver;
	count: number;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('爆光监控初始化成功');
		this.count = 0;
		this.ob = new IntersectionObserver(entries => {
			entries.forEach(element => {
				const appearKey = element.target.getAttribute('data-appear');
				if (element.intersectionRatio > 0 && appearKey) {
					this.callback({
						type: ReportType.EXPOSURE,
						message: '曝光埋点',
						exposure: appearKey,
					});
				}
			});
		});
		this.init();
	}

	appear() {
		const appears = document.querySelectorAll('[data-appear]');
		if (this.count === 10) return;
		this.count++;
		if (appears.length === 0) {
			setTimeout(() => this.appear(), 100);
		} else {
			for (let index = 0; index < appears.length; index++) {
				// 订阅
				this.ob.observe(appears[index]);
			}
		}
	}

	init() {
		window.addEventListener('load', () => this.appear());
    // 路由监听
		Utils.routerChangeListener(() => {
			this.appear();
		});
	}
}

自动埋点(无痕埋点)

  • 实现思路:
    • 1、给document加一个点击事件,通过事件代理的方式,获取到事件源target
    • 2、如果事件源target有data-report属性,需要自动上报数据

TIP

注意点:由于现在都是虚拟的DOM,虚拟的DOM经过转换过后data-report属性可能并且不在事件源target上,所以没有data-report属性时,需要去父节点找到

  • 虚拟的DOM经过转换过后

point1

  • 事件源target

point2

用户停留时间

  • 实现思路:

    • 1、进入页面首先记录当前时间页面地址
    • 2、当路由发现变化,或者页面关闭的时候,获取当前的时间-记录的时间,上报数据
    • 3、最后用上一个页面的离开的时间更新为当前页面的当前时间,并且更新当前的页面地址
ts
/**
 * 用户页面停留时长
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';
export default class PageDwellTime {
	OFFLINE_MILL = 15 * 60 * 1000; // 15分钟不操作认为不在线
	lastTime = Date.now();
	prePageUrl = encodeURIComponent(location.href);
	isBeforeunload = false;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('用户页面停留时长初始化成功');
		this.init();
	}
	init() {
		const handler = () => {
			const now = Date.now();
			const duration = now - this.lastTime;
			this.lastTime = now;
			// 超过十五分钟,判定为掉线
			if (duration > this.OFFLINE_MILL) {
				this.lastTime = now;
				return;
			}
			this.callback({
				type: ReportType.PAGE_DWELL_TIME,
				message: '页面停留时长',
				durationMs: duration,
				pageUrl: this.prePageUrl,
			});
			this.prePageUrl = encodeURIComponent(location.href);
		};
		setTimeout(() => {
			Utils.routerChangeListener(() => {
				handler();
			});
		}, 100);

		// 页面关闭
		window.addEventListener('beforeunload', () => {
			handler();
		});
	}
}

PV

  • 浏览器每访问一个就是一个PV,逐渐累计成为PV总数

  • UA会根据PV做去重计算出来,一个自然日内一个IP只记录为一个UA

  • 实现思路:

    • 在初始化时上报一个PV,路由改变时也上报一个PV
    • VueRouter在打开页面的时候,会触发historyreplaceState,导致路由改变被监听
    • 如果这里不做处理会触发2次上报造成很多脏数据,经过多次测试,replaceState事件触发机制,在setTimeout之后,所以这里在初始化时,将路由监听放在setTimeout之后
ts
/**
 * pv,uv根据pv算
 */

import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Pv {
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('PV初始化成功');
		this.init();
	}
	init() {
		setTimeout(() => {
			this.callback({
				type: ReportType.PV,
				message: 'PV',
			});
			Utils.routerChangeListener(() => {
				this.callback({
					type: ReportType.PV,
					message: 'PV',
				});
			});
		}, 100);
	}
}

接口监控

  • 主要是用来记录接口请求状态,异常排查与错误处理,如请求失败、超时、返回错误等

  • 实现思路: 主要是对XMLHttpRequest进行AOP拦截,首先保留原有的函数,对函数进行重写实现自定义的逻辑,在通过apply执行原有的函数

    • 1、主要是重写了open,sendonreadystatechange函数
    • 2、在open时,记录接口的地址请求方式参数请求开始的时间等等
    • 3、在send函数中,监控接口状态,当readyState = 4 响应已经完成,开始上报数据
ts
/**
 * 接口监控
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Xhr {
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('接口监控监控初始化成功');
		this.xhrHook();
	}
	xhrHook() {
		const xhrSelf = this;
		const xhr = window.XMLHttpRequest;
		const _originOpen = xhr.prototype.open;
		// open AOP
		xhr.prototype.open = function (method, url) {
			(this as any).xhrInfo = {
				url,
				method,
			};
			return _originOpen.apply(this, arguments as any);
		};

		// send AOP
		const _originSend = xhr.prototype.send;
		xhr.prototype.send = function (value) {
			const _self = this;
			const xhrStartTime = Date.now();

			const ajaxEnd = (event: string) => () => {
				(this as any).xhrInfo.event = event;
				(this as any).xhrInfo.status = _self.status;
				(this as any).xhrInfo.success =
					(_self.status >= 200 && _self.status <= 206) || _self.status === 304;
				(this as any).xhrInfo.duration = Date.now() - xhrStartTime;

				if (_self.response) {
					let responseSize = null;
					let responseData = null;
					switch (_self.responseType) {
						case 'json':
							responseSize = JSON && JSON.stringify(_self.response).length;
							responseData = JSON && JSON.stringify(_self.response);
							break;
						case 'blob':
							responseSize = _self.response.size;
							break;
						case 'arraybuffer':
							responseSize = _self.response.byteLength;
						// eslint-disable-next-line no-fallthrough
						case 'document':
							responseSize =
								_self.response.documentElement &&
								_self.response.documentElement.innerHTML &&
								_self.response.documentElement.innerHTML.length + 28;
							break;
						default:
							try {
								responseSize = JSON && JSON.stringify(_self.response).length;
								responseData = JSON && JSON.stringify(_self.response);
							} catch (error) {
								console.log(error);
							}
					}

					(this as any).xhrInfo.responseSize = responseSize;
					(this as any).xhrInfo.responseData = responseData;
					(this as any).xhrInfo.requestData = Utils.isFormData(value)
						? 'Binary System'
						: value;
				}
				xhrSelf.callback({
					...(this as any).xhrInfo,
					type: ReportType.XHR,
					message: '接口监控',
				});
			};

			if (this.addEventListener) {
				// 监听请求事件
				this.addEventListener('load', ajaxEnd('load'), false); // 完成
				this.addEventListener('error', ajaxEnd('error'), false); // 出错
				this.addEventListener('abort', ajaxEnd('abort'), false); // 取消
			} else {
				const _origin_onreadystatechange = this.onreadystatechange;
				this.onreadystatechange = function () {
					if (_origin_onreadystatechange) {
						_origin_onreadystatechange.apply(this, arguments as any);
					}
					if (this.readyState === 4) {
						ajaxEnd('load')();
					}
				};
			}
			return _originSend.apply(this, arguments as any);
		};
	}
}

页面性能监控

  • 通过performance API和谷歌web-vitals来实现数据收集
  • 因为有些收集的数据并不是页面一加载完就能收集到,所以上报数需要后置,监听浏览器关闭事件beforeunload,进行上报
  • 页面在要关闭的时候普通请求是发不出去的,所以这里需要用sendBeacon

web

关键指标

指标计算方式说明
TTFBresponseStart-redirectStart首字节时间(页面重定向到服务器接收页面的第一个字节)
FPresponseEnd - fetchStart首次绘制时间-白屏时间(背景颜色绘制,它是将第一个像素点绘制到屏幕的时刻)
FCP谷歌web-vitals首次内容绘制时间(浏览器将第一个DOM渲染到屏幕的时间)
FMP谷歌web-vitals首次有意义绘制时间(FMP 是一个主观的指标,触发时机可能是主要内容的绘制用户页面交互)
LCP谷歌web-vitals最大内容渲染时间(LCP 是一个动态的指标,它可能在页面加载过程中发生变化触发时机可能是主要内容绘制完成用户页面交互)
DCLdomContentLoadedEventEnd - domContentLoadedEventStartDOMContentLoaded事件耗时
LloadEventStart - fetchStart页面完全加载总时间
TTI谷歌web-vitals首次可交互时间(页面从加载开始到用户可以进行有意义的交互操作)
FID谷歌web-vitals首次输入延迟时间(用户首次输入与页面响应之间的延迟时间)
  • TTI触发时机

    • 1、首次绘制(FP)已经完成:页面的第一个像素已经被绘制。
    • 2、主线程空闲时间达到一定阈值:在一段时间内,浏览器主线程没有耗时的任务在执行。这意味着页面的关键渲染路径已经完成,主要内容已经可见。
    • 3、页面元素的可操作性:页面上的交互元素(如按钮、链接、输入框等)可以响应用户的交互操作。这表示页面已经加载完成,并且用户可以进行有意义的交互。
    • 当以上条件都满足时,TTI 会被触发
  • FID触发时机

    • 1、用户首次与页面进行交互:用户执行了一个交互动作,如点击按钮、滚动页面、选择下拉菜单等。
    • 2、浏览器主线程忙于处理其他任务:当用户进行交互时,如果主线程正忙于处理其他任务(如执行 JavaScript、处理样式计算等),则会导致延迟。
ts
document.addEventListener('DOMContentLoaded', function() {
  // DOMContentLoaded事件耗时
});
ts
/**
 * 页面性能指标
 */
import { onFCP, onLCP, onFID } from 'web-vitals';
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Perf {
	cycleFreq = 100; // 循环轮询的时间
	timer: any = 0;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('页面渲染性能监控初始化成功');
		this.init();
	}
	runCheck() {
		let FCP = 0;
		let LCP = 0;
		let FID = 0;
		let FMP = 0;

		const p = performance.getEntriesByType(
			'navigation'
		)[0] as PerformanceNavigationTiming;
		new PerformanceObserver((entryList, observer) => {
			const perfEntries = entryList.getEntries();
			FMP = perfEntries[0].startTime;
			observer.disconnect(); //不再观察了
		}).observe({ entryTypes: ['element'] }); //观察页面中的意义的元素
		// 首次内容绘制
		onFCP(data => {
			FCP = data.value;
			callback();
		});
		// 最大内容绘制
		onLCP(data => {
			LCP = data.value;
			callback();
		});
		// 首次输入延迟
		onFID(data => {
			FID = data.value;
			callback();
		});
		const callback = () => {
			this.callback({
				type: ReportType.PERFORMANCE,
				message: '页面渲染性能',
				unload: (p.unloadEventEnd - p.unloadEventStart).toFixed(2), // 前一个页面卸载耗时
				redirect: (p.redirectEnd - p.redirectStart).toFixed(2), // 重定向的时间
				appCache: (p.domainLookupStart - p.fetchStart).toFixed(2), // 读取缓存的时间
				dns: (p.domainLookupEnd - p.domainLookupStart).toFixed(2), // DNS查询耗时
				tcp: (p.connectEnd - p.connectStart).toFixed(2), // TCP连接耗时
				ssl: (p.connectEnd - p.secureConnectionStart).toFixed(2), // SSL 安全连接耗时
				rst: (p.responseStart - p.requestStart).toFixed(2), // 请求响应耗时
				trans: (p.responseEnd - p.responseStart).toFixed(2), // 响应数据传输耗时
				ready: (p.domComplete - p.domInteractive).toFixed(2), // DOM解析耗时(不包含dom内嵌资源加载时间)
				resources: (p.loadEventStart - p.domContentLoadedEventEnd).toFixed(2), //资源加载耗时
				onLoad: (p.loadEventEnd - p.loadEventStart).toFixed(2), // onLoad事件耗时(所有资源已加载)
				// 指标
				TTFB: (p.responseStart - p.redirectStart).toFixed(2), // 首字节(重定向到->服务器接收页面的第一个字节)
				FP: (p.responseEnd - p.fetchStart).toFixed(2), // 白屏时间 首次绘制包括了任何用户自定义的背景颜色绘制,它是将第一个像素点绘制到屏幕的时刻
				FCP: FCP.toFixed(2), //	首次内容绘制 是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间
				FMP: FMP.toFixed(2), //	首次有意义绘制
				LCP: LCP.toFixed(2), //	最大内容渲染(事件响应时触发)
				DCL: (
					p.domContentLoadedEventEnd - p.domContentLoadedEventStart
				).toFixed(2), // DOMContentLoaded事件耗时
				L: (p.loadEventStart - p.fetchStart).toFixed(2), // 页面完全加载总时间(load)
				TTI: (p.domInteractive - p.fetchStart).toFixed(2), // 首次可交互时间
				FID: FID.toFixed(2), //	首次输入延迟时间(点击到响应的时间差)
			});
		};
		// 保证load事件结束
		if (p.loadEventEnd) {
			callback();
		} else {
			clearInterval(this.timer);
			this.timer = setTimeout(this.runCheck.bind(this), this.cycleFreq);
		}
	}

	init() {
		window.addEventListener('load', () => {
			this.runCheck();
		});
	}
}

资源性能监控

TIP

  • 跨域资源 redirectStartredirectEnddomainLookupStartdomainLookupEndconnectStartconnectEndsecureConnectionStartrequestStartresponseStarttransferSize的值都为0,需要设置跨域资源需要设置响应头 Timing-Allow-Origin:*
  • 资源监控主要是用PerformanceObserver API或者performance.getEntriesByType
  • PerformanceObserver是发布订阅者模式,加载一个或者多个资源通知一次
  • performance.getEntriesByType是在页面加载完成后,一次性或者到所有的资源,无法监听动态加载的资源
ts
/**
 * 资源加载性能指标
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class ResourcesPerf {
	constructor(
		private callback: (params: ReportParams) => void,
		private reportUrl?: string
	) {
		Utils.log('资源加载性能指标初始化成功');
		this.init();
	}
	resolveEntries(entries: PerformanceResourceTiming[]): Array<ReportParams> {
		const timings: Array<ReportParams> = [];
		entries.forEach((timing: PerformanceResourceTiming) => {
			if (this.checkReport(timing)) {
				timings.push({
					type: ReportType.RES_PERFORMANCE,
					message: '资源加载性能',
					resourcesUrl: timing.name,
					resourcesType: timing.initiatorType,
					redirect: (timing.redirectEnd - timing.redirectStart).toFixed(2), // 重定向
					appCache: (timing.domainLookupStart - timing.fetchStart).toFixed(2), // 缓存
					dns: (timing.domainLookupEnd - timing.domainLookupStart).toFixed(2),
					tcp: (timing.connectEnd - timing.connectStart).toFixed(2),
					ssl: (timing.connectEnd - timing.secureConnectionStart).toFixed(2), // https下有效
					rst: (timing.responseStart - timing.requestStart).toFixed(2), // 请求响应耗时
					trans: (timing.responseEnd - timing.responseStart).toFixed(2), // 内容传输耗时
					duration: timing.duration.toFixed(2), // 加载时长
					decodedBodySize: timing.decodedBodySize,
					encodedBodySize: timing.encodedBodySize,
					transferSize: timing.transferSize,
				});
			}
		});
		return timings;
	}
	// link(css) script img audio video css(字体)
	checkReport({ initiatorType, name }: PerformanceResourceTiming): boolean {
		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
		if (name.startsWith(this.reportUrl!)) {
			// 防止上报死循环
			return false;
		}
		if (
			initiatorType === 'link' ||
			initiatorType === 'script' ||
			initiatorType === 'img' ||
			initiatorType === 'audio' ||
			initiatorType === 'video' ||
			initiatorType === 'css'
		) {
			return true;
		}
		return false;
	}
	init() {
		if (window.PerformanceObserver) {
			const observer = new window.PerformanceObserver(performance => {
				const entries = performance.getEntries() as PerformanceResourceTiming[];
				const timings = this.resolveEntries(entries);
				timings.forEach(timing => {
					this.callback(timing);
				});
			});
			observer.observe({
				entryTypes: ['resource'],
			});
		} else {
			window.addEventListener('load', () => {
				const entries = performance.getEntriesByType(
					'resource'
				) as PerformanceResourceTiming[];
				const timings = this.resolveEntries(entries);
				timings.forEach(timing => {
					this.callback(timing);
				});
			});
		}
	}
}

用户链路追踪

用户页面行为录制