Skip to content
霞露小伙 — HfWang
On this page

前端监控系统搭建

tip

TODO:用户行为监控还未完成

架构设计

  1. 项目预期
  2. 技术栈选型
  3. 项目架构

项目实现

项目搭建

项目通用工具

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);
		}
	}
}

npm 发包

实战项目引用

本站中引用到的其他资料,如有侵权,请联系本人删除