import ErrorStackParser from "error-stack-parser"; import localforage from "localforage"; import { IOptions, IListenParams, EventType, IData } from "./types"; import { getMemory, getTrackUaInfo, getUA, isMobile } from "./ua"; import { getWebVitals, performanceText } from "./performance"; import { throttle, getTargetDomByPointerEvent, findParentBySelector, isHttpProtocol, getCurrentDate, compareDatesByTimestamp, } from "./utils"; const defaultReportUrl = "/api/zmdms-log/event-tracking/submit"; const defaultHistoryUrlsNum = 4; // 历史路由记录数量 const defaultCacheMaxNum = 15; // 缓存数据最大数量 const defaultReportMaxNum = 20; // 上报数据最大数量 const defaultCountPvType = "repeat"; // 页面pv类型 const defaultUvMaxTime = 60 * 60 * 24 * 1000; // uv最大时间 一天就会重新统计 const defaultMaxRetryNum = 3; // 上报失败最大重试次数 const defaultRemoveRetryTime = 10 * 60 * 1000; // 上报失败移除时间,达到最大失败次数后,30秒内不再上报。30秒后继续上报。 /** * 埋点上报类 */ export default class ZmdmsTrack { static EventType = EventType; /** 外部不要操作这个变量 */ private __remove__listeners__: (() => void)[]; private options: IOptions & { historyUrlsNum: number; cacheMaxNum: number; reportMaxNum: number; }; private storage: typeof localforage; private queue: IData[] = []; private isProcessing: boolean = false; private PV_URL_SET = new Set(); private currentRetryNum = 0; // 当前重试次数 private removeRetryTimer: any = null; constructor(options: IOptions) { this.options = this.parseOptions(options); this.log("初始解析数据", "group"); this.log(this.options); this.log("初始解析数据", "groupEnd"); this.__remove__listeners__ = []; this.init(); // 初始化存储对象 this.storage = localforage.createInstance({ driver: [localforage.INDEXEDDB, localforage.LOCALSTORAGE], name: this.options.cacheKey || "zmdms-track", }); this.pv(); this.uv(); } /** * 解析外部传入的参数,得到 options */ parseOptions(options: IOptions) { const { basicInfo, report = {}, errorReport = true, performanceReport = false, hashchangeReport = true, historyReport = true, isDebugger = false, historyUrlsNum = defaultHistoryUrlsNum, cacheMaxNum = defaultCacheMaxNum, reportMaxNum = defaultReportMaxNum, cacheKey, countPvType = defaultCountPvType, pvReport = true, globalClickListeners = [], uvReport = true, uvByDay = true, uvMaxTime = defaultUvMaxTime, maxRetryNum = defaultMaxRetryNum, removeRetryTime = defaultRemoveRetryTime, dataRewrite = (data) => data, customUvReport = null, getUVKey = () => "zmdms-track_UV", isTriggerPathUv = null, ...resetOptions } = options; let newOptions: Required = { basicInfo, report, errorReport, performanceReport, hashchangeReport, historyReport, isDebugger, historyUrlsNum, cacheMaxNum, cacheKey, reportMaxNum, countPvType, pvReport, globalClickListeners, uvReport, uvByDay, uvMaxTime, maxRetryNum, removeRetryTime, dataRewrite, customUvReport, getUVKey, isTriggerPathUv, ...resetOptions, }; newOptions.report = this.parseReport(newOptions.report); newOptions.basicInfo = this.parseBasicInfo(newOptions.basicInfo); return newOptions; } /** * 修改options.basicInfo的配置 */ updateBasicInfo(basicInfo: Partial = {}) { this.options.basicInfo = { ...this.options.basicInfo, ...basicInfo, runtimeInfo: { ...this.options.basicInfo.runtimeInfo, ...basicInfo.runtimeInfo, }, }; } /** * 生成上报数据 */ createData(data: IData): IData { const newData = { client: isMobile() ? "h5" : "web", memory: getMemory(), ...this.options.basicInfo, routeUrl: window.location.href, ...data, requestTime: getTimestamp(), }; return this.options.dataRewrite ? this.options.dataRewrite(newData) : newData; } /** * 上报数据 */ async send(data: IData | IData[]) { const { report = {} } = this.options; let reportData: IData[] = []; if (Array.isArray(data)) { reportData = data; } else { reportData.push(data); } // 是否决定上报数据 if (report.isReport && !report.isReport(reportData)) { // 不上报当次数据 return Promise.resolve(); } // 上报前对数据做最终处理 if (report.format) { reportData = report.format(reportData); } // 上报数据 if (report.customReport) { return report.customReport(reportData); } if (!report.url) { report.url = defaultReportUrl; } // 如果当前协议是非 http https协议 if (!isHttpProtocol() && report.nonHttpMethod) { return report .nonHttpMethod({ url: report.url, method: "POST", headers: { "Content-Type": "application/json", ...report.headers, }, body: JSON.stringify({ eventTrackings: reportData, }), }) .then((res) => { if (res.status !== 200) { throw new Error("上报失败"); } }); } if (report.reportType === "http") { return fetch(report.url, { method: "POST", headers: { "Content-Type": "application/json", ...report.headers, }, body: JSON.stringify({ eventTrackings: reportData, }), }).then((res) => { if (res.status !== 200) { throw new Error("上报失败"); } }); } if (report.reportType === "beacon") { return navigator.sendBeacon( report.url, JSON.stringify({ eventTrackings: reportData, }) ); } } /** * 添加上报数据 */ async add(data: IData) { const newData = this.createData(data); this.log("存储上报数据", "group"); this.log(newData); this.log("", "groupEnd"); // 将数据添加到队列中 this.queue.push(newData); if (!this.isProcessing) { this.processQueue(); return; } } async processQueue() { if (this.queue.length === 0) { this.isProcessing = false; return; } this.isProcessing = true; const data = this.queue.shift()!; // 获取当前缓存数据。 let currentCacheData = await this.storage.getItem(this.options.cacheKey); let newCacheData: IData[] | null = [data]; if (Array.isArray(currentCacheData)) { newCacheData = [...currentCacheData, data]; } // 如果缓存数据长度大于阈值,则发送缓存数据并清空队列 if (newCacheData.length >= this.options.cacheMaxNum) { this.log("===========上报达到阈值,发送缓存数据============"); // 如果上报失败次数达到最大值,那么不再继续上报。直接存储数据 if ( this.currentRetryNum >= (this.options.maxRetryNum || defaultMaxRetryNum) ) { // 一定时间后接触限制,继续上报 if (!this.removeRetryTimer) { this.removeRetryTimer = setTimeout(() => { this.log("=============解除限制,继续上报============="); this.currentRetryNum = 0; this.removeRetryTimer = null; }, this.options.removeRetryTime); } this.log("=============达到最大上报次数限制,不再上报============="); this.log(`=============5分钟后解除限制,继续上报=============`); await this.storage.setItem(this.options.cacheKey, newCacheData); this.processQueue(); // 继续处理下一个任务。因为数据是一条条存储的 return; } try { const sliceData = newCacheData.slice(); const maxData = sliceData.splice(0, this.options.reportMaxNum); await this.send(maxData); this.currentRetryNum = 0; // 重置上报失败次数 await this.storage.setItem(this.options.cacheKey, sliceData); } catch (err) { console.log("上报失败", err); this.currentRetryNum++; // 如果这里上报失败了 那么直接将数据存储 等待下一轮上报 await this.storage.setItem(this.options.cacheKey, newCacheData); } } else { await this.storage.setItem(this.options.cacheKey, newCacheData); } this.processQueue(); // 继续处理下一个任务 } /** * 直接上报数据 */ report(data: IData) { const newData = this.createData(data); this.log("最终提交数据", "group"); this.log(newData); this.log("", "groupEnd"); // 如果达到最大重视次数,那么不在直接上报,而是将数据存储到本地,等待下次上报 if ( this.currentRetryNum >= (this.options.maxRetryNum || defaultMaxRetryNum) ) { this.add(newData); return; } this.send(newData) .then((res) => { this.currentRetryNum = 0; // 重置上报失败次数 }) .catch((err) => { this.currentRetryNum++; this.add(newData); // 上报失败,直接把数据存储到本地中,等待下次上报 console.log("上报失败", err); }); } /** * 解析默认请求参数 */ parseBasicInfo(basicInfo: IOptions["basicInfo"] = {}) { let newBasicInfo = { ...basicInfo }; // 浏览器信息,web端专用 newBasicInfo.navigator = newBasicInfo.navigator || getUA(); // 系统信息、运行环境信息 const currentDeviceInfo = getTrackUaInfo(); newBasicInfo.runtimeInfo = { ...currentDeviceInfo, ...newBasicInfo.runtimeInfo, }; return newBasicInfo; } /** * 解析上报请求相关参数 */ parseReport(report: IOptions["report"] = {}) { let newReport = { ...report }; newReport.url = newReport.url || defaultReportUrl; newReport.reportType = newReport.reportType || "http"; newReport.headers = newReport.headers || {}; return newReport; } /** * 监听 */ addListen(listenParams: IListenParams) { const { type, callback, options } = listenParams; window.addEventListener(type, callback, options); return () => { window.removeEventListener(type, callback, options); }; } /** * 移除监听 */ removeListen() { if (Array.isArray(this.__remove__listeners__)) { this.__remove__listeners__.forEach((removeListen) => { removeListen?.(); }); this.__remove__listeners__ = []; } } /** * 通常默认情况下PV取值是当前的location.href即可。 * 但是在单页应用中,我们要记录PV的值,就需要监听:hashchange、popstate...等事件 * 但是在Vue中有个奇怪的现象(qiankun中),vue-router使用的是hash模式; * 但是页面跳转时,触发的事件是pushstate事件。并且触发事件时的location.href是跳转之前的值。 * 即触发事件时,跳转还未完成。所以routeUrl不能一味的取值location.href,这里PV的产生直接主动传递routeUrl会更加准确 * @param url pv产生的地址 * @returns */ pv(url?: string) { if (!this.options.pvReport) { return; } const href = url ? url.startsWith(window.location.origin) ? url : `${window.location.origin}${url}` : location.href; if (this.options.countPvType === "single" && this.PV_URL_SET.has(href)) { return; } this.add({ eventKey: "pv", eventValue: href, routeUrl: href, extra: "", }); if (this.options.countPvType === "single") { this.PV_URL_SET.add(href); } } // UV的统计要优化一下。如果通过基座访问。UV只 统计基座的UV会导致系统访问信息出现偏差。 async uv() { if (!this.options.uvReport) { return; } if (this.options.customUvReport) { this.options.customUvReport?.(); return; } let uvKey = this.options.getUVKey?.() || "zmdms-track_UV"; let cacheUVData = null; let isTrack = false; // 是否统计UV? // 如果当前设备记录了UV缓存数据,并且没有过期,那么可以统计。 try { cacheUVData = JSON.parse(localStorage.getItem(uvKey) || ""); } catch (e) { console.log("解析UV缓存数据失败"); } if (!cacheUVData) { cacheUVData = { time: Date.now(), }; isTrack = true; localStorage.setItem(uvKey, JSON.stringify(cacheUVData)); } else { // 按天来统计UV if (this.options.uvByDay) { // 根据当前存储中的时间戳获取是哪一天的uv数据,如果小于今天那么可以统计。 if (compareDatesByTimestamp(cacheUVData.time)) { cacheUVData = { time: Date.now(), }; isTrack = true; localStorage.setItem(uvKey, JSON.stringify(cacheUVData)); } } else { const uvMaxTime = this.options.uvMaxTime || defaultUvMaxTime; // 如果当前设备记录了UV缓存数据,并且已经过期,那么可以统计。 if (Date.now() - cacheUVData.time >= uvMaxTime) { cacheUVData = { time: Date.now(), }; localStorage.setItem(uvKey, JSON.stringify(cacheUVData)); isTrack = true; } } } if (isTrack) { this.add({ eventKey: "uv", eventValue: "访客统计", extra: JSON.stringify({ uvTime: cacheUVData.time, uvStrTime: getCurrentDate(), }), }); } } /** * 数据初始化 */ private init() { const { errorReport, hashchangeReport, historyReport, performanceReport } = this.options; // 错误上报(发生错误时,立即上报) if (errorReport) { const ErrorRemoveListen = this.addListen({ type: EventType.Error, callback: (e: ErrorEvent) => { this.log("捕获到全局异常错误,上报数据...", "group"); this.log(e); this.log("", "groupEnd"); const { target, error, message } = e; const stackFrame = ErrorStackParser.parse(!target ? e : error)[0]; this.add({ eventKey: EventType.Error, eventValue: message, extra: JSON.stringify(stackFrame), }); }, }); const UnhandledRejectionRemoveListen = this.addListen({ type: EventType.UnhandledRejection, callback: (e: PromiseRejectionEvent) => { this.log("捕获到Promise 未 catch 的报错,上报数据...", "group"); this.log(e); this.log("", "groupEnd"); const error = e.reason; if (!error || typeof error != "object" || error == null) { this.add({ eventKey: EventType.UnhandledRejection, eventValue: error, extra: JSON.stringify({ message: "遇到一些未知的问题" }), }); return; } const stackFrame = ErrorStackParser.parse(e.reason)[0]; const message = e.reason?.message || e.reason?.stack; this.add({ eventKey: EventType.Error, eventValue: typeof message === "string" ? message : JSON.stringify(message), extra: JSON.stringify(stackFrame), }); }, }); this.__remove__listeners__.push(ErrorRemoveListen); this.__remove__listeners__.push(UnhandledRejectionRemoveListen); } // 记录路由改变 const addUrls = ( urls: string[], options: { from?: string; to?: string; eventKey: any; eventValue: string; extra?: string; } ) => { const { from, to, eventKey, eventValue, extra } = options; if (urls.length === 0 && from) { urls.push(from); } if (to) { this.pv(to); if (this.options.isTriggerPathUv?.(from, to)) { this.uv(); } urls.push(to); } if ( this.options.historyUrlsNum && urls.length > this.options.historyUrlsNum ) { urls.shift(); } this.add({ eventKey, eventValue, extra, }); }; if (hashchangeReport) { let urls: string[] = []; const HashChangeRemoveListen = this.addListen({ type: EventType.HashChange, callback: (e: HashChangeEvent) => { const { oldURL, newURL } = e; const { relative: from } = parseUrlToObj(oldURL); const { relative: to } = parseUrlToObj(newURL); addUrls(urls, { from, to, eventKey: EventType.HashChange, eventValue: "监听到hashchange事件", extra: JSON.stringify({ from, to, urls, }), }); }, }); this.__remove__listeners__.push(HashChangeRemoveListen); } if (historyReport) { const getHref = () => { return `${window.location.pathname}${window.location.hash}${window.location.search}`; }; let preHref = getHref(); let urls: string[] = []; const HistoryRemoveListen = this.addListen({ type: EventType.History, callback: (e: PopStateEvent) => { const from = preHref; const to = getHref(); // history路由这里有一个问题。 // 在qiankun的基座下面,history路由跳转,会触发一次pushstate 然后又会触发一次 popstate,这里会导致数据重复上报。 // 这里做一个简单的处理,如果 路由地址相同,认为不需要上报。 if (from === to) { return; } preHref = to; addUrls(urls, { from, to, eventKey: EventType.History, eventValue: "监听到popState事件", extra: JSON.stringify({ from, to, urls, }), }); }, }); // 处理hash路由带来的一些问题 const parseUrl = (url: string) => { let newUrl = url; // 如果url是hash路由,需要处理一下。replaceState、pushState参数不会携带pathname if (newUrl.startsWith("#")) { newUrl = `${window.location.pathname}${newUrl}`; } return newUrl; }; const replaceFn = (originalFn: any, eventKey: string): any => { return function (this: any, ...args: any[]) { const url = args?.[2]; if (url) { const from = preHref; // 通过preHref获取到的路由是包含 pathname、hash、search的 // 但是通过 args 取到的路由可能不包含这些信息(hash路由) // 这里不知道为啥vue的hash路由不触发hashchange呢,所以这里需要处理一下 const to = parseUrl(url); preHref = to; if (from === to) { return; } addUrls(urls, { from, to, eventKey: eventKey, eventValue: `监听到${eventKey}事件`, extra: JSON.stringify({ from, to, urls, }), }); } return originalFn.apply(this, args); }; }; // 代理 history.pushState history.replaceState const original = window.history.pushState; const wrapped = replaceFn(original, "pushState"); window.history.pushState = wrapped; const original1 = window.history.replaceState; const wrapped1 = replaceFn(original1, "replaceState"); window.history.replaceState = wrapped1; this.__remove__listeners__.push(HistoryRemoveListen); } // 页面性能上报 if (performanceReport) { getWebVitals((data: any) => { this.add({ eventKey: data?.name, eventValue: data?.rating || data?.value, extra: JSON.stringify({ value: data?.value, rating: data?.rating, name: data?.name, text: typeof data?.name === "string" ? performanceText?.[data.name] : "", }), }); }); } // 全局点击监听 const globalClickListeners = this.options.globalClickListeners; if (globalClickListeners) { const windowClickRemoveListen = this.addListen({ type: EventType.Click, callback: throttle((e: PointerEvent) => { const el = getTargetDomByPointerEvent(e); // 获取点击的元素 if (el) { // 循环globalClickListeners globalClickListeners.forEach((config) => { const { selector, elementText, callback } = config; let targetEl: any = null; if (selector) { targetEl = findParentBySelector(el, selector); } else if (el.textContent === elementText) { targetEl = el; } if (targetEl) { this.log("触发全局点击监听", "group"); this.log("当前点击的元素"); this.log(el); this.log("找到目标元素"); this.log(targetEl); this.log("", "groupEnd"); const innerText = targetEl.innerText; const result = callback && callback({ targetEl: targetEl, currentEl: el, innerText, }); if (!result || result.isTrack !== false) { this.add({ eventKey: "window-click", eventValue: "自动监听的全局点击事件", extra: JSON.stringify({ innerText, selector, elementText, ...result, }), }); } } }); } }, 300), options: { capture: true, }, }); this.__remove__listeners__.push(windowClickRemoveListen); } } log(msg: any, type: "log" | "error" | "group" | "groupEnd" = "log") { if (this.options.isDebugger) { let myType = type || "log"; console[myType](msg); } } } // 防止外部篡改原型 Object.freeze(ZmdmsTrack.prototype); const getTimestamp = () => Date.now(); const parseUrlToObj = (url: string) => { if (!url) { return {}; } const match = url.match( /^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/ ); if (!match) { return {}; } const query = match[6] || ""; const fragment = match[8] || ""; return { host: match[4], path: match[5], protocol: match[2], relative: match[5] + query + fragment, }; };