├── .gitignore ├── .prettierrc ├── README.md ├── dist └── myMonitor.js ├── eslint.config.mjs ├── examples ├── jsError.html ├── loadResourceError.html ├── loginTask.html ├── mock-server-error.js ├── performancePaint.html ├── promiseError.html ├── requestAPIError.html ├── timing.html └── whiteScreen.html ├── index.ts ├── interface └── index.ts ├── modules ├── jsError.ts ├── longTask.ts ├── paint.ts ├── reactErrorBoundary.ts ├── timing.ts └── xhr.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── tsconfig.json └── utils ├── checkWhiteScreen.ts ├── getLastEvent.ts ├── getLogBaseData.ts ├── getSelector.ts ├── index.ts └── tracker.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | 4 | "tabWidth": 2, 5 | 6 | "useTabs": false, 7 | 8 | "bracketSameLine": true, 9 | 10 | "semi": true, 11 | 12 | "singleQuote": false, 13 | 14 | "jsxSingleQuote": false, 15 | 16 | "arrowParens": "avoid", 17 | 18 | "singleAttributePerLine": false, 19 | 20 | "bracketSpacing": true, 21 | 22 | "htmlWhitespaceSensitivity": "css" 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 迈向前端 Leader - 落地前端监控 2 | 3 | 技术文章:https://juejin.cn/post/7482957797770756131 4 | -------------------------------------------------------------------------------- /dist/myMonitor.js: -------------------------------------------------------------------------------- 1 | !function(e){"function"==typeof define&&define.amd?define(e):e()}((function(){"use strict";function e(e,i){(null==i||i>e.length)&&(i=e.length);for(var t=0,r=Array(i);t0){for(var t in e)if(Ie(e[t])==Ie(i))return!0;return!1}return!!He(e)&&-1!==Ie(i).indexOf(Ie(e))},Ae=function(e,i){for(var t in e)return/^(browser|cpu|device|engine|os)$/.test(t)||!!i&&Ae(e[t])},He=function(e){return typeof e===y},Le=function(e){if(e){for(var i=[],t=Me(/\\?\"/g,e).split(","),r=0;r-1){var o=Ue(t[r]).split(";v=");i[r]={brand:o[0],version:o[1]}}else i[r]=Ue(t[r]);return i}},Ie=function(e){return He(e)?e.toLowerCase():e},ze=function(e){return He(e)?Me(/[^\d\.]/g,e).split(".")[0]:void 0},De=function(e){for(var i in e){var t=e[i];typeof t==g&&2==t.length?this[t[0]]=t[1]:this[t]=void 0}return this},Me=function(e,i){return He(i)?i.replace(e,m):i},Re=function(e){return Me(/\\?\"/g,e)},Ue=function(e,i){if(He(e))return e=Me(/^\s\s*/,e),typeof i===v?e:e.substring(0,500)},Fe=function(e,i){if(e&&i)for(var t,r,o,n,a,s,c=0;c0?2===n.length?typeof n[1]==h?this[n[0]]=n[1].call(this,s):this[n[0]]=n[1]:3===n.length?typeof n[1]!==h||n[1].exec&&n[1].test?this[n[0]]=s?s.replace(n[1],n[2]):void 0:this[n[0]]=s?n[1].call(this,s,n[2]):void 0:4===n.length&&(this[n[0]]=s?n[3].call(this,s.replace(n[1],n[2])):void 0):this[n]=s||void 0;c+=2}},Be=function(e,i){for(var t in i)if(typeof i[t]===g&&i[t].length>0){for(var r=0;r2&&this.set(x,"iPad").set(S,N);break;case re:!this.get(T)&&Ne&&Ne[R]&&this.set(T,Ne[R]);break;case oe:var e=this.data,i=function(i){return e[i].getItem().detectFeature().get()};this.set(Z,i(Z)).set(ee,i(ee)).set(ie,i(ie)).set(te,i(te)).set(re,i(re))}return this},this.parseUA=function(){return this.itemType!=oe&&Fe.call(this.data,this.ua,this.rgxMap),this.itemType==Z&&this.set(k,ze(this.get(E))),this},this.parseCH=function(){var e=this.uaCH,i=this.rgxMap;switch(this.itemType){case Z:var t,r=e[M]||e[z];if(r)for(var o in r){var n=Me(/(Google|Microsoft) /,r[o].brand||r[o]),a=r[o].version;/not.a.brand/i.test(n)||t&&(!/chrom/i.test(t)||/chromi/i.test(n))||(this.set(T,n).set(E,a).set(k,ze(a)),t=n)}break;case ee:var s=e[C];s&&(s&&"64"==e[F]&&(s+="64"),Fe.call(this.data,s+";",i));break;case ie:if(e[j]&&this.set(S,j),e[x]&&this.set(x,e[x]),"Xbox"==e[x]&&this.set(S,P).set(O,be),e[D]){var c;if("string"!=typeof e[D])for(var l=0;!c&&l=13?"11":"10"),this.set(T,u).set(E,d)}this.get(T)==Pe&&"Xbox"==e[x]&&this.set(T,"Xbox").set(E,void 0);break;case oe:var w=this.data,b=function(i){return w[i].getItem().setCH(e).parseCH().get()};this.set(Z,b(Z)).set(ee,b(ee)).set(ie,b(ie)).set(te,b(te)).set(re,b(re))}return this},De.call(this,[["itemType",e],["ua",i],["uaCH",r],["rgxMap",t],["data",We(this,e)]]),this}function Qe(e,i,t){if(typeof e===g?(Ae(e,!0)?(typeof i===g&&(t=i),i=e):(t=e,i=void 0),e=void 0):typeof e!==y||Ae(i,!0)||(t=i,i=void 0),t&&typeof t.append===h){var r={};t.forEach((function(e,i){r[i]=e})),t=r}if(!(this instanceof Qe))return new Qe(e,i,t).getResult();var o=typeof e===y?e:t&&t[I]?t[I]:je&&je.userAgent?je.userAgent:m,n=new Je(t,!0),a=i?function(e,i){var t={},r=i;if(!Ae(i))for(var o in r={},i)for(var n in i[o])r[n]=i[o][n].concat(r[n]?r[n]:[]);for(var a in e)t[a]=r[a]&&r[a].length%2==0?r[a].concat(e[a]):e[a];return t}(Xe,i):Xe,s=function(e){return e==oe?function(){return new Ke(e,o,a,n).set("ua",o).set(Z,this.getBrowser()).set(ee,this.getCPU()).set(ie,this.getDevice()).set(te,this.getEngine()).set(re,this.getOS()).get()}:function(){return new Ke(e,o,a[e],n).parseUA().get()}};return De.call(this,[["getBrowser",s(Z)],["getCPU",s(ee)],["getDevice",s(ie)],["getEngine",s(te)],["getOS",s(re)],["getResult",s(oe)],["getUA",function(){return o}],["setUA",function(e){return He(e)&&(o=e.length>500?Ue(e,500):e),this}]]).setUA(o),this}Qe.VERSION="2.0.0",Qe.BROWSER=_e([T,E,k,S]),Qe.CPU=_e([C]),Qe.DEVICE=_e([x,O,S,P,j,_,N,q,H]),Qe.ENGINE=Qe.OS=_e([T,E]);var Ye=Qe(navigator.userAgent),Ze=Ye.browser,ei=Ye.device,ii=Ye.os;var ti=function(){return e=function e(){!function(e,i){if(!(e instanceof i))throw new TypeError("Cannot call a class as a function")}(this,e),this.url="http://localhost:8080/send/monitor.gif"},o=[{key:"send",value:function(e){var i=function(e){for(var i=1;i-1&&i++}for(var r=1;r<=9;r++){var o=document.elementFromPoint(window.innerWidth/10*r,window.innerHeight/2),n=document.elementFromPoint(window.innerWidth/2,window.innerHeight/10*r);t(o),t(n)}return 18===i}(),a={type:"error",errorType:"jsError",message:e.message,filename:e.filename,position:"".concat(e.lineno,":").concat(e.colno),stack:c(e.error.stack),selector:o?f():"",isWhiteScreen:n};console.log("js error log: ",a),ri.send(a)}}),!0),window.addEventListener("unhandledrejection",(function(e){console.log("Promise error event: ",e);var i,t,r=d(),o=e.reason,n=0,s=0,l="";if("string"==typeof e.reason)i=o;else if("object"===a(o)&&(i=o.message,o.stack)){var u=o.stack.match(/at\s+(.+):(\d+):(\d+)/);t=u[1],n=u[2],s=u[3],l=c(o.stack)}var w={type:"error",errorType:"promiseError",message:i,filename:t,position:"".concat(n,":").concat(s),stack:l,selector:r?f():""};console.log("promise error log: ",w),ri.send(w)}),!0),function(){var e=window.XMLHttpRequest,i=e.prototype.setRequestHeader;e.prototype.setRequestHeader=function(e,t){return this.requestHeaders||(this.requestHeaders={}),this.requestHeaders[e]=t,i.apply(this,arguments)};var t=e.prototype.open;e.prototype.open=function(e,i,r){return-1===oi.indexOf(i)&&(this.logData={method:e.toUpperCase(),url:i}),t.apply(this,arguments)};var r=e.prototype.send;e.prototype.send=function(e){var i=this;if(this.logData){var t=Date.now(),n=function(r){return function(){var n=Date.now()-t,a=i.status,s=i.statusText,c=i.logData,l=c.url,u=c.method,d=["GET","DELETE"].indexOf(u)>-1?function(e){var i=e.split("?")[1],t={};return null==i||i.split("&").forEach((function(e){var i=o(e.split("="),2),r=i[0],n=i[1];t[r]=n})),t}(l):e;if("error"===r&&window.navigator.onLine||"load"===r&&a>=400){var w={type:"error",errorType:"xhrError",message:s,xhrData:{eventType:r,url:l,method:u,header:i.requestHeaders,params:d,duration:n,status:a,response:i.response?JSON.stringify(i.response):""}};console.log("XHR log: ",w),ri.send(w)}}};this.addEventListener("load",n("load"),!1),this.addEventListener("error",n("error"),!1)}return r.apply(this,arguments)}}(),function(){if(PerformanceObserver){var e=new PerformanceObserver((function(e){e.getEntries().forEach((function(e){if(e.duration>100){var i=d(),t={type:"longTask",startTime:e.startTime,duration:e.duration,selector:i?f():"",eventType:null==i?void 0:i.type};console.log("longTask log: ",t),ri.send(t)}}))}));e.observe({entryTypes:["longtask"]})}}(),function(){if(PerformanceObserver){var e=null,i=null,t=null,r=null,o=new PerformanceObserver((function(t){var r,n=function(e,i){var t="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!t){if(Array.isArray(e)||(t=s(e))||i&&e&&"number"==typeof e.length){t&&(e=t);var r=0,o=function(){};return{s:o,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var n,a=!0,c=!1;return{s:function(){t=t.call(e)},n:function(){var e=t.next();return a=e.done,e},e:function(e){c=!0,n=e},f:function(){try{a||null==t.return||t.return()}finally{if(c)throw n}}}}(t.getEntries());try{for(n.s();!(r=n.n()).done;){var a,c=r.value;if("first-paint"===c.name)e=c,console.log("首次像素绘制 时间:",null===(a=e)||void 0===a?void 0:a.startTime);else if("first-contentful-paint"===c.name){var l;i=c,console.log("首次内容绘制 时间:",null===(l=i)||void 0===l?void 0:l.startTime),o.disconnect()}}}catch(e){n.e(e)}finally{n.f()}}));o.observe({entryTypes:["paint"]});var n=new PerformanceObserver((function(e){var i,r=e.getEntries();t=r[0],console.log("首次有意义元素绘制 时间:",null===(i=t)||void 0===i?void 0:i.startTime),n.disconnect()}));n.observe({entryTypes:["element"]});var a=new PerformanceObserver((function(e){var i,t=e.getEntries();r=t[0],console.log("最大内容绘制 时间:",null===(i=r)||void 0===i?void 0:i.startTime,t)}));a.observe({entryTypes:["largest-contentful-paint"]}),window.addEventListener("load",(function(){setTimeout((function(){var o,n,s,c;a.disconnect();var l={type:"paint",FP:null===(o=e)||void 0===o?void 0:o.startTime,FCP:null===(n=i)||void 0===n?void 0:n.startTime,FMP:null===(s=t)||void 0===s?void 0:s.startTime,LCP:null===(c=r)||void 0===c?void 0:c.startTime};console.log("paint log: ",l),ri.send(l)}),3e3)}))}}(),window.addEventListener("load",(function(){var e=0,i=0;if(performance.getEntriesByType){var t=performance.getEntriesByType("navigation");if(t.length>0){var r=t[0],o=r.domContentLoadedEventStart,n=r.loadEventStart,a=r.fetchStart;e=o-a,i=n-a}}else{var s=performance.timing,c=s.fetchStart;e=s.domContentLoadedEventStart-c,i=s.loadEventStart-c}var l={type:"timing",DOMContentLoadedTime:e,loadTime:i};console.log("timing log: ",l),ri.send(l)}))})); 2 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 8 | { languageOptions: { globals: globals.browser } }, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | { 12 | rules: { 13 | "@typescript-eslint/no-explicit-any": "off", 14 | "@typescript-eslint/no-unused-vars": "off", 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/ban-ts-comment": "off", 17 | "prefer-rest-params": "off", 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /examples/jsError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 前端监控 SDK. 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/loadResourceError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 资源加载出错. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/loginTask.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 长任务 7 | 8 | 9 | 10 | 11 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/mock-server-error.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | 3 | const server = http.createServer((req, res) => { 4 | // 允许跨域 5 | res.setHeader("Access-Control-Allow-Origin", "*"); 6 | res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); 7 | res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); 8 | 9 | if (req.method === "OPTIONS") { 10 | // 处理预检请求 11 | res.writeHead(204); 12 | res.end(); 13 | } else { 14 | 15 | if (req.url.startsWith("/error")) { 16 | res.writeHead(500, { "Content-Type": "text/plain" }); 17 | res.end("Server Error, code is 500."); 18 | } else { 19 | // 处理正常请求 20 | res.writeHead(200, { "Content-Type": "text/plain" }); 21 | res.end("Hello, world!"); 22 | } 23 | } 24 | }); 25 | 26 | server.listen(3000, () => console.log("Server running on port 3000")); -------------------------------------------------------------------------------- /examples/performancePaint.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 |
11 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /examples/promiseError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 | 13 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/requestAPIError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | request API Error. 6 | 7 | 8 | 9 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/timing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | timing 7 | 8 | 9 | 10 | 11 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/whiteScreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 检测白屏 7 | 8 | 9 | 10 |
11 | 12 |
13 |
14 | 15 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import injectJSError from "./modules/jsError"; 2 | import injectXHR from "./modules/xhr"; 3 | import injectLongTask from "./modules/longTask"; 4 | import injectPaint from "./modules/paint"; 5 | import injectTiming from "./modules/timing"; 6 | 7 | injectJSError(); 8 | injectXHR(); 9 | injectLongTask(); 10 | injectPaint(); 11 | injectTiming(); 12 | -------------------------------------------------------------------------------- /interface/index.ts: -------------------------------------------------------------------------------- 1 | // 基础日志 2 | export interface BaseLog { 3 | // 网页标题 4 | title: string; 5 | // 网页地址 6 | url: string; 7 | // 用户环境完整信息 8 | userAgent: string; 9 | // 浏览器 10 | browser: string; 11 | // 设备 12 | device: string; 13 | // 操作系统 14 | os: string; 15 | } 16 | 17 | // 错误日志 18 | export interface ErrorLog { 19 | // type 监控类型:error(代码错误) 20 | type: "error"; 21 | // 错误类型:jsError(JS 代码错误)、promiseError(Promise 错误)、loadResourceError(加载资源错误)、xhrError(API 请求错误) 22 | errorType: "jsError" | "promiseError" | "loadResourceError" | "xhrError"; 23 | // 错误信息 24 | message: string; 25 | // 错误发生的文件 26 | filename?: string; 27 | // 错误发生的行列信息 28 | position?: string; 29 | // 错误堆栈信息 30 | stack?: string; 31 | // 错误发生在 DOM 到顶层元素的链路信息(使用选择器表示,如:body div#container input) 32 | selector?: string; 33 | // 是否白屏 34 | isWhiteScreen?: boolean; 35 | // 资源标签名称 36 | tagName?: string; 37 | // xhr 数据 38 | xhrData?: { 39 | eventType: "load" | "error"; // xhr 错误事件类型 40 | url: string; // api 路径 41 | method: string; // 请求方式 42 | header: Record; // 请求头 43 | params: string; // 请求参数 44 | duration: number; // 请求时长 45 | status: number; // 请求状态 46 | response: string; // 请求结果 47 | }; 48 | } 49 | 50 | // 性能指标日志 51 | export interface PaintLog { 52 | // type 监控类型:性能监控 53 | type: "paint"; 54 | // FP 55 | FP?: number; 56 | // FCP 57 | FCP?: number; 58 | // FMP 59 | FMP?: number; 60 | // LCP 61 | LCP?: number; 62 | } 63 | 64 | // timing 加载时间日志 65 | export interface TimingLog { 66 | // type 监控类型:性能监控 67 | type: "timing"; 68 | // DOMContentLoaded 的执行时间 69 | DOMContentLoadedTime: number; 70 | // load 页面完整的加载时间 71 | loadTime: number; 72 | } 73 | 74 | // longTask 长任务日志 75 | export interface LongTaskLog { 76 | // type 监控类型:性能监控 77 | type: "longTask"; 78 | // 开始时间 79 | startTime: number; 80 | // 持续时间 81 | duration: number; 82 | // 交互事件 83 | eventType?: string; 84 | // 操作 DOM 选择器链路 85 | selector?: string; 86 | } 87 | 88 | // 监控种类日志 89 | export type MonitorTypeLog = ErrorLog | PaintLog | TimingLog | LongTaskLog; 90 | 91 | // 上报监控日志 92 | export type MonitorLog = { baseLog: BaseLog } & MonitorTypeLog; 93 | -------------------------------------------------------------------------------- /modules/jsError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorLog } from "../interface"; 2 | import { formatStack } from "../utils"; 3 | import checkWhiteScreen from "../utils/checkWhiteScreen"; 4 | import getLastEvent from "../utils/getLastEvent"; 5 | import getSelector from "../utils/getSelector"; 6 | import tracker from "../utils/tracker"; 7 | 8 | export default function injectJSError() { 9 | window.addEventListener( 10 | "error", 11 | event => { 12 | console.log("error event: ", event); 13 | 14 | // 监听 JS/CSS 资源文件加载错误 15 | const target = event.target as HTMLScriptElement | HTMLImageElement | HTMLLinkElement | null; 16 | let filename; 17 | if ( 18 | target && 19 | (filename = 20 | (target as HTMLScriptElement | HTMLImageElement).src || (target as HTMLLinkElement).href) 21 | ) { 22 | // 1、数据建模存储 23 | const log: ErrorLog = { 24 | type: "error", 25 | errorType: "loadResourceError", // 错误类型 - JS/CSS 资源加载错误 26 | message: `${filename} resource loading fail.`, 27 | filename, // 报错的文件 28 | tagName: target.tagName, // 资源标签名称 29 | // body script 30 | selector: getSelector(event.target as HTMLElement), 31 | }; 32 | 33 | console.log("log: ", log); 34 | 35 | // 2、上报数据 36 | tracker.send(log); 37 | } 38 | // 监听 JS 代码执行出错 39 | else { 40 | const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件 41 | const isWhiteScreen = checkWhiteScreen(); // 检查是否白屏 42 | // 1.1、数据建模存储 43 | const errorLog: ErrorLog = { 44 | // kind: "stability", // 监控指标的大类 45 | type: "error", 46 | errorType: "jsError", 47 | message: event.message, 48 | filename: event.filename, 49 | position: `${event.lineno}:${event.colno}`, 50 | stack: formatStack(event.error.stack), 51 | selector: lastEvent ? getSelector() : "", 52 | isWhiteScreen, 53 | }; 54 | console.log("js error log: ", errorLog); 55 | 56 | // 1.2、上报数据 57 | tracker.send(errorLog); 58 | } 59 | }, 60 | // !!! 使用捕获 61 | true, 62 | ); 63 | 64 | // 2、监听未被捕获的 Promise 错误 65 | window.addEventListener( 66 | "unhandledrejection", 67 | event => { 68 | console.log("Promise error event: ", event); 69 | const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件 70 | 71 | let message; 72 | const reason = event.reason; // Promise 失败的原因 73 | let filename; 74 | let line = 0; 75 | let column = 0; 76 | let stack = ""; 77 | if (typeof event.reason === "string") { 78 | // 情况 1、是 Promise reject 抛出的错误(没有办法获取 stack 等信息) 79 | message = reason; 80 | } else if (typeof reason === "object") { 81 | message = reason.message; 82 | // 情况 2、是 Promise 中 JS 代码执行出错 83 | if (reason.stack) { 84 | // 从错误信息中匹配到关键信息。stack 示例:at http://localhost:8080/examples/promiseError.html:29:32 85 | const matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/); 86 | filename = matchResult[1]; 87 | line = matchResult[2]; 88 | column = matchResult[3]; 89 | stack = formatStack(reason.stack); 90 | } 91 | } 92 | 93 | // 2.1、数据建模存储 94 | const errorLog: ErrorLog = { 95 | type: "error", 96 | errorType: "promiseError", // 错误类型 - Promise 代码错误 97 | message, 98 | filename, 99 | position: `${line}:${column}`, 100 | stack, 101 | selector: lastEvent ? getSelector() : "", 102 | }; 103 | 104 | console.log("promise error log: ", errorLog); 105 | 106 | // 2.2、上报数据 107 | tracker.send(errorLog); 108 | }, 109 | true, 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /modules/longTask.ts: -------------------------------------------------------------------------------- 1 | import { LongTaskLog } from "../interface"; 2 | import getLastEvent from "../utils/getLastEvent"; 3 | import getSelector from "../utils/getSelector"; 4 | import tracker from "../utils/tracker"; 5 | 6 | export default function injectLongTask() { 7 | if (PerformanceObserver) { 8 | const observerLongTask = new PerformanceObserver(list => { 9 | list.getEntries().forEach(entry => { 10 | // 执行时长大于 100 ms 11 | if (entry.duration > 100) { 12 | const lastEvent = getLastEvent(); 13 | 14 | const log: LongTaskLog = { 15 | type: "longTask", 16 | startTime: entry.startTime, // 开始时间 17 | duration: entry.duration, // 持续时间 18 | selector: lastEvent ? getSelector() : "", 19 | eventType: lastEvent?.type, 20 | }; 21 | console.log("longTask log: ", log); 22 | 23 | tracker.send(log); 24 | } 25 | }); 26 | }); 27 | 28 | observerLongTask.observe({ entryTypes: ["longtask"] }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /modules/paint.ts: -------------------------------------------------------------------------------- 1 | import { PaintLog } from "../interface"; 2 | import tracker from "../utils/tracker"; 3 | 4 | export default function injectPaint() { 5 | if (PerformanceObserver) { 6 | let FP: PerformanceEntry | null = null; 7 | let FCP: PerformanceEntry | null = null; 8 | let FMP: PerformanceEntry | null = null; 9 | let LCP: PerformanceEntry | null = null; 10 | 11 | // 1、监控性能指标 FP(First Paint) 和 FCP(First Contentful Paint) 12 | const observerFPAndFCP = new PerformanceObserver(function (entryList) { 13 | const perfEntries = entryList.getEntries(); 14 | for (const perfEntry of perfEntries) { 15 | if (perfEntry.name === "first-paint") { 16 | FP = perfEntry; 17 | console.log("首次像素绘制 时间:", FP?.startTime); 18 | } else if (perfEntry.name === "first-contentful-paint") { 19 | FCP = perfEntry; 20 | console.log("首次内容绘制 时间:", FCP?.startTime); 21 | observerFPAndFCP.disconnect(); // 得到 FCP 后,断开观察,不再观察了 22 | } 23 | } 24 | }); 25 | // 观察 paint 相关性能指标 26 | observerFPAndFCP.observe({ entryTypes: ["paint"] }); 27 | 28 | // 2、监控性能指标:FMP(First Meaningful Paint) 29 | const observerFMP = new PerformanceObserver(entryList => { 30 | const perfEntries = entryList.getEntries(); 31 | FMP = perfEntries[0]; 32 | console.log("首次有意义元素绘制 时间:", FMP?.startTime); 33 | observerFMP.disconnect(); // 断开观察,不再观察了 34 | }); 35 | observerFMP.observe({ entryTypes: ["element"] }); 36 | 37 | // 3、创建性能观察者,观察 LCP 38 | const observerLCP = new PerformanceObserver(entryList => { 39 | const perfEntries = entryList.getEntries(); 40 | LCP = perfEntries[0]; 41 | console.log("最大内容绘制 时间:", LCP?.startTime, perfEntries); 42 | }); 43 | // 观察页面中最大内容的绘制 44 | observerLCP.observe({ entryTypes: ["largest-contentful-paint"] }); 45 | 46 | // 上送性能指标 47 | window.addEventListener("load", () => { 48 | setTimeout(() => { 49 | // 在上报性能指标数据的时候,停止 LCP 的观察。 50 | observerLCP.disconnect(); 51 | const log: PaintLog = { 52 | type: "paint", 53 | FP: FP?.startTime, // FP 54 | FCP: FCP?.startTime, // FCP 55 | FMP: FMP?.startTime, // FMP 56 | LCP: LCP?.startTime, // LCP 57 | }; 58 | console.log("paint log: ", log); 59 | tracker.send(log); 60 | }, 3000); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /modules/reactErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { ErrorLog } from "../interface"; 2 | import { formatStack } from "../utils"; 3 | import tracker from "../utils/tracker"; 4 | 5 | /** 6 | * TODO... 暂时没有使用到。 7 | * 收集 react render 流程上发生的错误 8 | */ 9 | export default function reactErrorBoundary(error: { message: string; stack: string }) { 10 | const matchResult = error.stack.match(/at\s+(.+):(\d+):(\d+)/) as RegExpMatchArray; 11 | const filename = matchResult[1], 12 | line = matchResult[2], 13 | column = matchResult[3]; 14 | 15 | // 1、数据建模存储 16 | const errorLog: ErrorLog = { 17 | type: "error", 18 | errorType: "reactErrorBoundary", // 错误类型 - React render 发生的错误 19 | message: error.message, 20 | filename, 21 | position: `${line}:${column}`, 22 | stack: formatStack(error.stack), 23 | }; 24 | 25 | console.log("react error log: ", errorLog); 26 | 27 | // 2.2、上报数据 28 | // tracker.send(errorLog); 29 | } 30 | -------------------------------------------------------------------------------- /modules/timing.ts: -------------------------------------------------------------------------------- 1 | import { TimingLog } from "../interface"; 2 | import tracker from "../utils/tracker"; 3 | 4 | export default function injectTiming() { 5 | window.addEventListener("load", () => { 6 | let DOMContentLoadedTime = 0, 7 | loadTime = 0; 8 | 9 | // 新版浏览器 API:PerformanceNavigationTiming 提供了关于页面加载性能的详细信息,替代旧的 performance.timing 10 | if (performance.getEntriesByType) { 11 | const perfEntries = performance.getEntriesByType("navigation"); 12 | if (perfEntries.length > 0) { 13 | const navigationEntry = perfEntries[0]; 14 | const { domContentLoadedEventStart, loadEventStart, fetchStart } = 15 | navigationEntry as PerformanceNavigationTiming; 16 | 17 | // DOM 树构建完成后触发 DOMContentLoaded 事件 18 | DOMContentLoadedTime = domContentLoadedEventStart - fetchStart; 19 | // console.log(`DOMContentLoaded 的执行时间:${DOMContentLoadedTime}ms`); 20 | 21 | // 页面完整的加载时间 22 | loadTime = loadEventStart - fetchStart; 23 | // console.log(`load 页面完整的加载时间:${loadTime}ms`); 24 | } 25 | } 26 | // 旧版浏览器降级使用 performance.timing 27 | else { 28 | const { fetchStart, domContentLoadedEventStart, loadEventStart } = performance.timing; 29 | 30 | // DOM 树构建完成后触发 DOMContentLoaded 事件 31 | DOMContentLoadedTime = domContentLoadedEventStart - fetchStart; 32 | // console.log(`---DOMContentLoaded 的执行时间:${DOMContentLoadedTime}ms`); 33 | 34 | // 页面完整的加载时间 35 | loadTime = loadEventStart - fetchStart; 36 | // console.log(`load 页面完整的加载时间:${loadTime}ms`); 37 | } 38 | 39 | // 1、数据建模存储 40 | const log: TimingLog = { 41 | type: "timing", 42 | DOMContentLoadedTime, 43 | loadTime, 44 | }; 45 | 46 | console.log("timing log: ", log); 47 | 48 | // 2、上报数据 49 | tracker.send(log); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /modules/xhr.ts: -------------------------------------------------------------------------------- 1 | // xhr.js 2 | // @ts-nocheck 3 | import { ErrorLog } from "../interface"; 4 | import { parseQueryString } from "../utils"; 5 | import tracker from "../utils/tracker"; 6 | 7 | // 不需要监控的接口白名单 8 | const whiteList = [ 9 | "http://localhost:8080/send/monitor.gif", // 日志服务接口 10 | ]; 11 | 12 | // 增强 XHR:通过重写 XHR 主要方法,实现拦截和增强 XHR 13 | export default function injectXHR() { 14 | const XMLHttpRequest = window.XMLHttpRequest; 15 | 16 | // 1、重写 setRequestHeader 方法增强功能 - 记录 request headers 数据 17 | const oldSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; 18 | XMLHttpRequest.prototype.setRequestHeader = function (key, value) { 19 | if (!this.requestHeaders) (this.requestHeaders = {}); 20 | this.requestHeaders[key] = value; 21 | return oldSetRequestHeader.apply(this, arguments); 22 | }; 23 | 24 | // 2、重写 open 方法增强功能 - 记录请求方式和 url 25 | const oldOpen = XMLHttpRequest.prototype.open; // 记录老的 open 方法 26 | XMLHttpRequest.prototype.open = function (method, url, async) { 27 | // 跳过 白名单接口 防止出现死循环。 28 | if (whiteList.indexOf(url) === -1) { 29 | this.logData = { method: method.toUpperCase(), url }; // 存储数据 30 | } 31 | return oldOpen.apply(this, arguments); 32 | }; 33 | 34 | // 3、重写 send 方法增强功能 - 监控上报数据 35 | const oldSend = XMLHttpRequest.prototype.send; // 记录老的 send 方法 36 | XMLHttpRequest.prototype.send = function (body) { 37 | if (this.logData) { 38 | // 在 send 发送之前,记录请求开始时间 39 | const startTime = Date.now(); 40 | const handler = (type: "load" | "error") => { 41 | return () => { 42 | const duration = Date.now() - startTime; // 持续的时间 43 | const status = this.status; // 200 | 400 | 500 44 | const statusText = this.statusText; // OK | Server Error 45 | const { url, method } = this.logData; 46 | const params = ['GET', 'DELETE'].indexOf(method) > -1 ? parseQueryString(url) : body; 47 | 48 | // 当请求发生错误时,上报数据(忽略无网络的错误,处理像 跨域错误、404、500 等错误) 49 | if ((type === "error" && window.navigator.onLine) || (type === "load" && status >= 400)) { 50 | const log: ErrorLog = { 51 | type: "error", 52 | errorType: "xhrError", // 错误类型是 xhr 53 | message: statusText, // 错误信息 54 | xhrData: { 55 | eventType: type, // load | error 56 | url, // api 路径 57 | method, // 请求方式 58 | header: this.requestHeaders, // 请求头 59 | params, // 请求参数 60 | duration, // 请求时长 61 | status, 62 | response: this.response ? JSON.stringify(this.response) : "", // 请求结果 63 | }, 64 | }; 65 | console.log("XHR log: ", log); 66 | tracker.send(log); 67 | } 68 | }; 69 | }; 70 | // 服务端返回 status 为 500 也会进入 load,需要进一步判断 status 71 | this.addEventListener("load", handler("load"), false); 72 | this.addEventListener("error", handler("error"), false); 73 | } 74 | return oldSend.apply(this, arguments); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monitor-sdk", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=16.0.0" 8 | }, 9 | "scripts": { 10 | "build": "rollup -c rollup.config.js -w" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@babel/cli": "^7.26.4", 17 | "@babel/core": "^7.26.0", 18 | "@babel/preset-env": "^7.26.0", 19 | "@babel/preset-typescript": "^7.26.0", 20 | "@eslint/js": "^9.16.0", 21 | "@rollup/plugin-babel": "^6.0.4", 22 | "@rollup/plugin-commonjs": "^28.0.1", 23 | "@rollup/plugin-node-resolve": "^15.3.0", 24 | "eslint": "^9.16.0", 25 | "globals": "^15.13.0", 26 | "prettier": "^3.4.2", 27 | "rollup": "^2.79.2", 28 | "rollup-plugin-terser": "^7.0.2", 29 | "typescript": "^5.7.2", 30 | "typescript-eslint": "^8.18.0" 31 | }, 32 | "dependencies": { 33 | "ua-parser-js": "^2.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import { babel } from "@rollup/plugin-babel"; 4 | import { terser } from "rollup-plugin-terser"; 5 | import path from "path"; 6 | 7 | const extensions = [".ts", ".tsx", ".js", ".jsx"]; 8 | 9 | export default () => ({ 10 | input: path.resolve(__dirname, "index.ts"), 11 | output: { 12 | file: path.resolve(__dirname, "dist/myMonitor.js"), 13 | format: "umd", 14 | name: "myMonitor", 15 | }, 16 | plugins: [ 17 | resolve({ 18 | extensions, // 指定 import 模块后缀解析规则 19 | }), 20 | commonjs(), 21 | babel({ 22 | extensions, 23 | presets: [ 24 | "@babel/preset-env", 25 | [ 26 | "@babel/preset-typescript", 27 | { 28 | isTSX: true, 29 | allExtensions: true, 30 | }, 31 | ], 32 | ], 33 | babelHelpers: "bundled", 34 | }), 35 | terser(), 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /utils/checkWhiteScreen.ts: -------------------------------------------------------------------------------- 1 | export default function checkWhiteScreen() { 2 | // 最顶层的空白元素(判断是白屏的依据) 3 | const wrapperElements = ["html", "body", "#root"]; 4 | let emptyPoints = 0; // 记录空白的点的个数 5 | 6 | function getSelector(element: Element) { 7 | let selector; 8 | if (element.id) { 9 | selector = `#${element.id}`; 10 | } else if (element.className && typeof element.className === "string") { 11 | // prettier-ignore 12 | selector = "." + element.className.split(" ").filter(item => !!item).join("."); 13 | } else { 14 | selector = element.nodeName.toLowerCase(); 15 | } 16 | return selector; 17 | } 18 | 19 | function isWrapper(element: Element) { 20 | const selector = getSelector(element); 21 | if (wrapperElements.indexOf(selector) > -1) { 22 | emptyPoints++; // 是空白点 23 | } 24 | } 25 | 26 | for (let i = 1; i <= 9; i++) { 27 | // 在高度一半的位置,横坐标均分取 9 个点,查看这 9 个点上的元素 28 | const xElements = document.elementFromPoint( 29 | (window.innerWidth / 10) * i, 30 | window.innerHeight / 2, 31 | ); 32 | // 在宽度一半的位置,纵坐标均分取 9 个点,查看这 9 个点上的元素 33 | const yElements = document.elementFromPoint( 34 | window.innerWidth / 2, 35 | (window.innerHeight / 10) * i, 36 | ); 37 | 38 | // 判断点的位置,是否是空白元素 39 | isWrapper(xElements!); 40 | isWrapper(yElements!); 41 | } 42 | 43 | // 定义阈值,比如 当所有的点(18个)都是空白点,那么就认为是空白页面,有一个点上有元素,就认为不是空白页面。 44 | if (emptyPoints === 18) { 45 | return true; 46 | } 47 | return false; 48 | } 49 | -------------------------------------------------------------------------------- /utils/getLastEvent.ts: -------------------------------------------------------------------------------- 1 | // getLastEvent.ts 记录发生错误时,最后一个交互事件 2 | let lastEvent: Event | null; 3 | let lastEventPath: any[]; 4 | 5 | ["click", "touchstart", "mousedown", "keydown"].forEach(eventType => { 6 | // 埋点方式:无痕埋点 -> 全部埋点 7 | document.addEventListener( 8 | eventType, 9 | event => { 10 | lastEvent = event; 11 | // 新版浏览器中 event.path 已被废弃,改用 event.composedPath() 12 | // @ts-ignore 13 | lastEventPath = event.path || event.composedPath(); 14 | }, 15 | { 16 | capture: true, // 以捕获形式监听(因为默认元素的事件都是冒泡形式,如果出现阻止默认事件,在这里将监听不到) 17 | passive: true, // 不阻止默认事件(在第二参数 listener 中,强制不使用 preventDefault() 阻止默认事件) 18 | }, 19 | ); 20 | }); 21 | 22 | // 获取最近一次的事件调用栈 23 | export function getLastEventPath() { 24 | // !!! 如果要获取 event path,请使用这个方法。因为在 addListenerEvent unhandledrejection 中, 25 | // 通过 lastEvent.path 来获取,将无法拿到实际数据,可能跟此事件 emit 异步机制有关。 26 | return lastEventPath; 27 | } 28 | 29 | // 获取最近一次的事件 30 | export default function getLastEvent() { 31 | return lastEvent; 32 | } 33 | -------------------------------------------------------------------------------- /utils/getLogBaseData.ts: -------------------------------------------------------------------------------- 1 | import { UAParser } from "ua-parser-js"; 2 | import { BaseLog } from "../interface"; 3 | 4 | // 获取设备信息 5 | const { browser, device, os } = UAParser(navigator.userAgent); 6 | 7 | /** 8 | * getLogBaseData 获取日志基本信息 9 | */ 10 | export default function getLogBaseData(): BaseLog { 11 | return { 12 | title: document.title, 13 | url: location.href, 14 | userAgent: navigator.userAgent, 15 | browser: `${browser.name} ${browser.version}`, 16 | device: `${device.model} ${device.vendor}`, 17 | os: `${os.name} ${os.version}`, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /utils/getSelector.ts: -------------------------------------------------------------------------------- 1 | // getSelector.ts 获取当前事件链路上的元素选择器 2 | import { getLastEventPath } from "./getLastEvent"; 3 | 4 | function filterTopLevelNode(element: Window | Document | HTMLElement) { 5 | // 过滤掉 window、document 和 html 6 | return element !== window && element !== document && element !== document.documentElement; 7 | } 8 | 9 | function getEleSelector(element: HTMLElement) { 10 | if (element.id) { 11 | return `${element.nodeName.toLowerCase()}#${element.id}`; // 返回 标签名#id 12 | } else if (element.className && typeof element.className === "string") { 13 | return `${element.nodeName.toLowerCase()}#${element.className}`; // 返回 标签名.class 14 | } else { 15 | return element.nodeName.toLowerCase(); // 返回 标签名 16 | } 17 | } 18 | 19 | function getSelectorByPath(path: any[]) { 20 | return path 21 | .reverse() // 翻转 Path 中的元素 22 | .filter(filterTopLevelNode) 23 | .map(getEleSelector) 24 | .join(" "); 25 | } 26 | 27 | function getSelectorByEle(ele: HTMLElement) { 28 | let node: HTMLElement | null = ele; 29 | const path: string[] = []; 30 | while (node && filterTopLevelNode(node)) { 31 | path.unshift(getEleSelector(node)); 32 | node = node.parentElement; 33 | } 34 | return path.join(" "); 35 | } 36 | 37 | export default function getSelector(ele?: HTMLElement) { 38 | const path = getLastEventPath(); 39 | if (Array.isArray(path)) { 40 | return getSelectorByPath(path); 41 | } else if (ele) { 42 | return getSelectorByEle(ele); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化 error stack 栈错误信息 3 | */ 4 | export function formatStack(stack: string) { 5 | // -------- 原 stack: 6 | // TypeError: Cannot set properties of undefined (setting 'error') 7 | // at errorClick (http://localhost:8080/:23:30) 8 | // at HTMLInputElement.onclick (http://localhost:8080/:11:70) 9 | // -------- 格式化为: 10 | // errorClick (http://localhost:8080/:23:30) 11 | // HTMLInputElement.onclick (http://localhost:8080/:11:70) 12 | return stack 13 | .split("\n") 14 | .slice(1) 15 | .map(item => item.replace(/^\s+at\s+/g, "")) 16 | .join("\n"); 17 | } 18 | 19 | /** 20 | * 解析 url 中的 query string 21 | */ 22 | export function parseQueryString(url: string) { 23 | const queryString = url.split("?")[1]; 24 | const queryParams: Record = {}; 25 | queryString?.split("&").forEach(item => { 26 | const [key, value] = item.split("="); 27 | queryParams[key] = value; 28 | }) 29 | return queryParams; 30 | } -------------------------------------------------------------------------------- /utils/tracker.ts: -------------------------------------------------------------------------------- 1 | import { MonitorLog, MonitorTypeLog } from "../interface"; 2 | import getLogBaseData from "./getLogBaseData"; 3 | 4 | class Tracker { 5 | url: string; 6 | 7 | constructor() { 8 | // 上报日志服务器地址(服务器上的 gif 图片) 9 | this.url = "http://localhost:8080/send/monitor.gif"; 10 | } 11 | 12 | send(data: MonitorTypeLog) { 13 | // 获取基础日志数据 14 | const baseData = getLogBaseData(); 15 | const log: MonitorLog = { 16 | baseLog: baseData, 17 | ...data, 18 | }; 19 | console.log("send log", log); 20 | 21 | // 进行数据上报 22 | const img = new window.Image(); 23 | img.src = `${this.url}?data=${encodeURIComponent(JSON.stringify(log))}`; 24 | } 25 | } 26 | 27 | export default new Tracker(); 28 | --------------------------------------------------------------------------------