前端监控系统搭建
tip
TODO:用户行为监控还未完成
架构设计
- 项目预期
- 技术栈选型
- 项目架构
项目实现
项目搭建
项目通用工具
ts
export function numberPadStart(num: number, n: number = 2) {
return num.toString().padStart(n, '0');
}
/** 获取当前时间 YYYY-MM-DD HH:mm:ss */
export const formatDate = (date: string | number | Date = Date.now()) => {
const now = new Date(date);
const year = now.getFullYear();
const month = numberPadStart(now.getMonth() + 1);
const day = numberPadStart(now.getDate());
const hour = numberPadStart(now.getHours());
const minute = numberPadStart(now.getMinutes());
const second = numberPadStart(now.getSeconds());
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
/** 埋点触发日志 */
export const behaviorLoger = (actionId: string, desc?: string) => {
console.log('>====================================<');
console.log(`埋点:${actionId}`);
console.log(`埋点描述:${desc}`);
console.log('>====================================<');
};
/** 监听页面加载成功 */
export default function onLoad(callback: Function) {
if (document.readyState === 'complete') {
callback?.();
} else {
window.addEventListener('load', e => callback?.(e));
}
}
ts
/** 监控类型 */
export const MonitorTypeMap = {
/** 异常监控 */
ERROR_MONITOR: 'error_monitor',
/** 用户行为记录,即埋点 */
BEHAVIOR_STAT: 'behavior_stat',
/** 页面性能指标 */
PAGE_PERFORMANCE: 'page_performance',
} as const;
/** 上报数据的具体类型 */
export const DetailTypeMap = {
/** js 执行异常 */
JSERROR: 'jsError',
/** promise 异常,一般是网络请求异常 */
PROMISEERROR: 'promiseError',
/** 资源加载异常,可能是引入的 cdn 失效,获取 css、图片等资源下载失败 */
RESOURCERROR: 'resourceError',
/** 页面白屏 */
BLACKSCREEN: 'blankScreen',
/** 页面性能 */
PERFORMANCE: 'performance',
/** 埋点 */
BEHAVIORSTAT: 'behaviorStat',
} as const;
/** 埋点触发方式 */
export const BehaviorMap = {
CLICKSTAT: 'clickStat',
SHOWSTAT: 'showStat',
} as const;
/** 认为是空白元素的节点 */
export const warpperElement = ['html', 'body', '#root', '#app'] as const;
ts
/*
* @Author: wanghaofeng
* @Date: 2024-01-12 22:10:22
* @LastEditors: wanghaofeng
* @LastEditTime: 2024-01-17 00:29:34
* @FilePath: \sdk\src\types.ts
* @Description:
* Copyright (c) 2024 by wanghaofeng , All Rights Reserved.
*/
import { MonitorTypeMap, DetailTypeMap, BehaviorMap } from './common/constants';
/** 有 src 属性的标签 */
export type IHasSrcEle = HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLIFrameElement;
/** 有 href 属性的标签 */
export type IHasHrefEle = HTMLLinkElement;
/** 自定义上报方法 */
export type TrackerServer = (url: string, data: { data: any[] }) => void;
/** 监控基础配置 */
export interface MonitorConfig {
/** 项目id,必须唯一 */
projectId: string;
/** 项目名称 */
projectName?: string;
/** 数据上报的 url 地址 */
url: string;
/** 白屏监控延迟时间, 默认页面加载 3s 后进行判断 */
blankScreenDelay?: number;
/** 是否开启异常监控 */
openErrorMonitor?: boolean;
/** 是否开启页面性能监控 */
openPerformanceMonitor?: boolean;
/** 是否开启埋点 */
behaviorMonitor?: boolean;
/** 是否开启合并上报 */
mergeSendData?: boolean;
/** 合并上报的阈值,达到该阈值就上报 */
mergeSendDataLimit?: number;
}
/** 上报数据的具体类型 */
export type DetailTypeMapType = typeof DetailTypeMap;
export type DetailType = DetailTypeMapType[keyof DetailTypeMapType];
/** 监控类型:异常监控、埋点上报、页面性能指标 */
export type MonitorTypeMapType = typeof MonitorTypeMap;
export type MonitorType = MonitorTypeMapType[keyof MonitorTypeMapType];
/** 埋点-用户行为 */
export type BehaviorMapType = typeof BehaviorMap;
export type BehaviorType = BehaviorMapType[keyof BehaviorMapType];
export interface BaseMonitorDataInfo {
/** 项目 id, 必须唯一 */
projectId: string;
/** 项目命称 */
projectName?: string;
/** 系统 ua */
userAgent: string;
/** 异常出现时间 */
date: Date | number | string;
/** 发生异常时,网页的 url */
href?: string;
}
/** 通用数据上报内容 */
export interface MonitorDataInfo {
/** 类型,是异常上报还是埋点上报 */
type: MonitorType;
/** 具体异常信息 */
detailType?: DetailType;
/** 异常信息 */
message?: string;
}
/** 异常数据上报内容 */
export interface ErrorMonitorDataInfo extends MonitorDataInfo {
/** 异常出现位置所在文件 */
fileName?: string;
/** 异常出现的在代码中的行和列 */
errorCodePosition?: [number, number];
/** 异常堆栈 */
errorStack?: string;
}
/** 白屏判断设置条件 */
export interface BlankScreenOptions {
/** x 轴取点数量, 默认 5 */
XPointNum?: number;
/** y 轴取点数量, 默认 5 */
YPointNum?: number;
/** 处于白屏点的占比,取值 0.5 -- 1 默认 0.9, 即取到的点有 90% 处于空白节点则认为是白屏 */
blankRate?: number;
/** 自定义认为是空白二点元素 */
customWarpperElement?: string[];
/** 页面加载多久后才开始判断是否白屏, 默认 3 秒 */
blankScreenDelay?: number;
/** 忽略白屏统计的页面 */
ignorePageUrls?: string[];
}
/** 白屏上报数据内容 */
export interface BlankScreenMonitorDataInfo extends MonitorDataInfo {
/** 设备宽度 */
screenWidth: number;
/** 设备高度 */
screenHeight: number;
}
export type PerformanceData = {
data: {
/** 累积布局偏移 */
CLS: number;
/** 渲染出第一个内容 */
FCP: number;
/** */
INP: number;
/** 系统和用户首次首次交互时间 */
FID: number;
/** 最大内容渲染时间 */
LCP: number;
/** 首字节时间 */
TTFB: number;
};
};
/** 网页性能指标上报内容 */
export type PerformanceDataInfo = PerformanceData & MonitorDataInfo;
/** 埋点上报数据 */
export interface BehaviorStatDataInfo extends MonitorDataInfo {
/** 埋点类型: 点击埋点还是显示埋点 */
behaviorType: BehaviorType;
/** 埋点 id */
actionId: string;
/** 埋点额外描述 */
desc?: string;
}
/** 页面停留时间埋点 */
export interface PageViewDataInfo extends MonitorDataInfo {
/** 页面完整 url */
href: string;
/** 页面 url pathname */
pathName: string
/** 开始时间,格式 YYYY-MM-DD HH:mm:ss */
startTime: string
/** 结束时间,格式 YYYY-MM-DD HH:mm:ss */
endTime: string;
/** 页面停留时间 */
stayTime: number;
}
异常监控实现
ts
import { MonitorTypeMap } from './common/constants';
import errorMonitor from './modules/errorMonitor';
import performanceMonitor from './modules/performanceMonitor';
import SendTracker from './common/tracker';
import { BehaviorStatDataInfo, BehaviorType, MonitorConfig } from './index.type';
import { behaviorLoger } from './common/utils';
export * from './index.type';
/** 系统监控类 */
export default class Monitor {
private config: MonitorConfig;
private tracker: SendTracker;
constructor(config: MonitorConfig) {
this.config = config;
this.tracker = new SendTracker(config);
}
/** 手动埋点上报 */
public behaviorTracker(behaviorType: BehaviorType, actionId: string, desc?: string) {
const log: BehaviorStatDataInfo = {
type: MonitorTypeMap.BEHAVIOR_STAT,
behaviorType,
actionId,
desc,
};
behaviorLoger(actionId, desc);
this.tracker.send(log);
}
/** 监控 sdk 初始化 */
public init() {
const { openErrorMonitor = true, openPerformanceMonitor = true } = this.config;
if (openErrorMonitor) {
errorMonitor(this.config, this.tracker);
}
if (openPerformanceMonitor) {
performanceMonitor(this.config, this.tracker);
}
}
}
ts
import { DetailTypeMap, MonitorTypeMap, warpperElement } from '../common/constants'
import SendTracker from '../common/tracker'
import { BlankScreenMonitorDataInfo, BlankScreenOptions } from '../index.type'
let timer: NodeJS.Timeout | null = null
/** 从页面均匀取 (N * M) 个点,如果取到的这些点全部都在指定的空白节点内,即认为页面处于白屏状态 */
export default function blackScreenMonitor(options: BlankScreenOptions, tracker: SendTracker) {
if (timer) {
clearTimeout(timer)
}
let blankPointerNum: number = 0
const {
XPointNum = 5,
YPointNum = 5,
customWarpperElement = [],
blankRate = 0.9,
blankScreenDelay = 3000,
ignorePageUrls = [],
} = options
const screenHeight = window.innerHeight || document.body.clientHeight
const screenWidth = window.innerWidth || document.body.clientWidth
const emptyEles = [...warpperElement, ...customWarpperElement]
if (ignorePageUrls.includes(window.location.pathname)) {
return
}
timer = setTimeout(() => {
// 从屏幕均匀取 XPointNum * YPointNum 个点,遍历判断
// 如果取到的这些点是空白元素的占比大于等于 blankRate,即认为页面处于白屏状态
for (let n = 1; n <= XPointNum; n++) {
for (let m = 1; m <= YPointNum; m++) {
const x = (screenWidth * n) / XPointNum
const y = (screenHeight * m) / YPointNum
const [ele] = document.elementsFromPoint(x, y)
const { tagName = '', id = '' } = ele
const isBlank = emptyEles.includes(tagName.toLowerCase()) || emptyEles.includes(`#${id}`)
if (isBlank) {
blankPointerNum++
}
}
}
const rate = Math.max(Math.min(1, blankRate), 0.5)
if (blankPointerNum / (XPointNum * YPointNum) > rate) {
const log: BlankScreenMonitorDataInfo = {
type: MonitorTypeMap.ERROR_MONITOR,
detailType: DetailTypeMap.RESOURCERROR,
message: '页面白屏',
screenHeight,
screenWidth,
}
tracker.send(log)
}
}, blankScreenDelay)
}
ts
import { DetailTypeMap, MonitorTypeMap } from '../common/constants'
import SendTracker from '../common/tracker'
import type { ErrorMonitorDataInfo, IHasHrefEle, IHasSrcEle, MonitorConfig } from '../index.type'
import onLoad from '../common/utils'
import blackScreenMonitor from './blackScreenMonitor'
/** 异常监控 */
export default function errorMonitor(config: MonitorConfig, tracker: SendTracker) {
// 页面加载成功后,开启页面白屏监控
onLoad(() => {
blackScreenMonitor(config, tracker)
})
// js 异常监控
window.addEventListener('error', (err: ErrorEvent) => {
const { target = null } = err
if (target && ((target as IHasSrcEle)?.src || (target as IHasHrefEle)?.href)) {
resourceErrorHandler(err)
} else {
jsErrorhandler(err)
}
})
// promise 异常监控
window.addEventListener('unhandledrejection', promiseErrorHandler, {
capture: true,
passive: true,
})
/** js 代码执行异常 */
function jsErrorhandler(err: ErrorEvent) {
const log: ErrorMonitorDataInfo = {
type: MonitorTypeMap.ERROR_MONITOR,
detailType: DetailTypeMap.JSERROR,
fileName: err.filename,
errorCodePosition: [err.lineno, err.colno],
message: err.message,
errorStack: getErrorStack(err),
}
tracker.send(log)
}
/** 资源加载异常 */
function resourceErrorHandler(err: ErrorEvent) {
const log: ErrorMonitorDataInfo = {
type: MonitorTypeMap.ERROR_MONITOR,
detailType: DetailTypeMap.RESOURCERROR,
fileName: (err.target as IHasSrcEle).src || (err.target as IHasHrefEle).href || '',
message: '资源加载异常',
}
tracker.send(log)
}
/** promise 执行异常 */
function promiseErrorHandler(err: PromiseRejectionEvent) {
const { reason } = err
const log: ErrorMonitorDataInfo = {
type: MonitorTypeMap.ERROR_MONITOR,
detailType: DetailTypeMap.PROMISEERROR,
message: typeof reason === 'string' ? reason : reason?.message,
}
tracker.send(log)
}
function getErrorStack(err: ErrorEvent) {
return err.error?.stack?.split?.('\n').slice?.(1).join?.(' ') || ''
}
}
ts
import { onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals'
import { DetailTypeMap, MonitorTypeMap } from '../common/constants'
import SendTracker from '../common/tracker'
import { MonitorConfig, PerformanceDataInfo } from '../index.type'
import onLoad from '../common/utils'
export default function performanceMonitor(config: MonitorConfig, tracker: SendTracker) {
const pagePerformance = {
CLS: 0,
FCP: 0,
FID: 0,
INP: 0,
LCP: 0,
TTFB: 0,
}
onCLS((data) => (pagePerformance.CLS = data.value))
onFCP((data) => (pagePerformance.FCP = data.value))
onFID((data) => (pagePerformance.FID = data.value))
onINP((data) => (pagePerformance.INP = data.value))
onLCP((data) => (pagePerformance.LCP = data.value))
onTTFB((data) => (pagePerformance.TTFB = data.value))
onLoad(() => {
setTimeout(() => {
const log: PerformanceDataInfo = {
type: MonitorTypeMap.PAGE_PERFORMANCE,
detailType: DetailTypeMap.PERFORMANCE,
message: '页面性能指标',
data: pagePerformance,
}
tracker.send(log)
}, config.blankScreenDelay)
})
}
ts
ts
import { BaseMonitorDataInfo, MonitorConfig } from '../index.type';
import { formatDate } from './utils';
/** 数据上报 */
export default class SendTracker {
/** 数据上报-用户配置 */
config: MonitorConfig;
/** 上报数据栈 */
dataInfoList: any[] = [];
constructor(config: MonitorConfig) {
this.config = config;
// 页面关闭前上报数据
window.onbeforeunload = () => this.sendDataInfos(this.dataInfoList);
}
/** 格式化上报数据格式 */
private formatSendData<T>(data: T) {
return {
...data,
projectId: this.config.projectId,
projectName: this.config.projectName || '',
date: formatDate(),
userAgent: navigator.userAgent,
href: location.href,
} as T & BaseMonitorDataInfo;
}
/** 数据上报,合并上报 */
public send<T>(data: T) {
const _data = this.formatSendData(data);
const { mergeSendData = true, mergeSendDataLimit = 20 } = this.config;
// 数据不直接上报,合并数据之后在上报
if (mergeSendData && this.dataInfoList.length >= mergeSendDataLimit) {
this.sendDataInfos(this.dataInfoList, true);
} else {
this.dataInfoList.push(_data);
}
}
/** 数据上报,立即上报 */
public dictSend<T>(data: T | T[]) {
const _dataList = Array.isArray(data)
? data.map(item => this.formatSendData(item))
: [this.formatSendData(data)];
try {
this.sendDataInfos(_dataList);
} catch (error) {
console.log(error);
}
}
/** 页面销毁上报数据 */
private sendDataInfos(dataList: any[], clearDataList = false) {
if (dataList.length === 0) {
return;
}
try {
const _data = new Blob([JSON.stringify(dataList)], {
type: 'application/json;charset=UTF-8',
});
navigator.sendBeacon?.(this.config.url, _data);
if (clearDataList) this.dataInfoList = [];
} catch (error) {
console.log(error);
}
}
}