自定义构建前端监控埋点sdk

813 阅读9分钟

前端埋点方案的目的是通过在页面中嵌入代码,收集用户行为数据、性能数据、错误日志等信息,以便后端或第三方平台进行分析和处理。常见的前端埋点方案包括事件埋点页面埋点性能埋点错误日志埋点等。

以下将通过代码示例,介绍如何构建一个简单的前端埋点 SDK,帮助你实现自定义的埋点数据收集与传输。

1.monorepo+pnpm构建项目

1.1 monorepo的优势

这里采用monorepo+pnpm的方式,它的好处如下:

  1. 统一管理依赖:所有子项目共享一个 node_modules,减少了重复安装和磁盘占用,依赖版本保持一致。
  2. 更高效的包管理:pnpm 通过软链接的方式优化了依赖的安装速度,避免了 npm 和 yarn 的重复安装和冗余文件。
  3. 简化跨项目开发:多个项目可以相互引用,方便在不同的包之间进行共享和重用代码。
  4. 简化版本控制:单一仓库便于管理版本、发布和更新,减少了多仓库管理的复杂性。
  5. 便于集成和自动化:支持跨项目的构建和测试,可以快速实现 CI/CD 流程。

这种方式使得多项目的管理更加高效、清晰,并且减少了重复劳动。

1.2 monorepo实际应用

在根目录下使用pnpm init构建一个包管理工具,在根路径下新建packages,packages下新建web和server目录,web为前端sdk埋点测试项目,server为埋点测试服务上报

image.png 根目录下新建pnpm-workspace.yaml

packages:
  - 'packages/*'

在server,web以及根目录分别配置package.json的启动脚本

  "scripts": {
    "start": "nodemon ./src/main.js"
  },
  "scripts": {
    "start": "webpack-dev-server",
    "build":"webpack"
  },
  "scripts": {
    "start:server": "pnpm --filter server start",
    "start:web": "pnpm --filter web start",
    "build:web": "pnpm --filter web build"
  },

在根目录下分模块安装包:

server中需要使用koa

pnpm add koa koa-bodyparser koa-cors koa-router koa-static nodemon proper-lockfile util uuid --filter server

web中需要使用webpack

pnpm add html-webpack-plugin user-agent webpack webpack-cli webpack-dev-server --filter web

启动与打包

  • 启动上报数据服务:pnpm start:server
  • 启动埋点测试前端服务:pnpm start:web
  • 打包埋点测试前端服务:pnpm build:web

2.webpack工程化配置

这里采用webpack的方式构建工程化目录,也可以拥rollup、vite等,因为个人比较喜欢webpack就采用webpack的方式。

2.1配置webpack

需要在web目录下新建webpack.config.js

const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
    entry: "./src/index.js",
    context: process.cwd(),
    output: {
        path: path.resolve(__dirname, "dist"),
        filename: "monitor.js"
    },
    mode: "development",
    devServer: {
        static: {
            directory: path.resolve(__dirname, "dist"),
        },
        setupMiddlewares: (middlewares, devServer) => {
            devServer.app.get("/success", function (req, res) {
                res.json({
                    id: 1,
                    name: "ws",
                    code: 200
                })
            })
            devServer.app.post("/fail", function (req, res) {
                res.sendStatus(500)
            })
            return middlewares
        }
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/index.html",
            inject: "head"
        })
    ]
}

2.2构建测试文件

在web/src下新建index.html,index.js入口文件,构建一个monitor用于sdk的主文件

image.png

3.构建前端埋点sdk

3.1 构建前端数据上报请求

构建一个请求类,这里采用fetch进行数据上报,并在该类中使用send方法进行数据上报

// 直接使用浏览器内置的 navigator.userAgent
function getExtraData() {
    return {
        url: location.url,  // location.url 应改为 location.href
        timestamp: Date.now(),
        userAgent: navigator.userAgent  // 直接使用浏览器自带的 userAgent
    };
}

class SendTracker {
    constructor() {
        this.url = 'http://localhost:3200';
    }

    // 使用 async/await
    async send(path, data) {
        try {
            const extraData = getExtraData();
            const log = { ...extraData, ...data };
            const body = JSON.stringify(log);
            // 使用 fetch
            const response = await fetch(this.url + path, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'x-log-apiversion': '0.6.0',
                    'x-log-bodyrawsize': body.length,
                },
                body: body
            });

            // 可以根据响应的状态进行处理
            if (response.ok) {
                console.log('Request successful:', await response.json());
            } else {
                console.error('Request failed:', response.statusText);
            }
        } catch (error) {
            console.error('Error during request:', error);
        }
    }
}

export default new SendTracker();

3.2 错误日志埋点

主要通过监听error与unhandledrejection时间上报错误

3.2.1 js执行错误埋点

   window.addEventListener("error", (event) => {
        const lastEvent = getLastEvent()
            // JS 执行错误
            tracker.send("/jsErrorLog/addition", {
                title:"监控-js执行错误",
                kind: "stability",         // 监控指标大类
                type: "error",             // 小类型,js错误
                errorType: "jsError",      // JS 执行错误
                message: event.message,    // 错误信息
                filename: event.filename,  // 报错文件
                position: `${event.lineno}:${event.colno}`, // 报错位置
                stack: getLines(event?.error?.stack),       // 堆栈信息
                selector: lastEvent ? getSelector(lastEvent) : ""      // 最后一个操作的元素
            })
    }, true)

image.png

3.2.2 promise执行错误埋点

    window.addEventListener("unhandledrejection", (event) => {
        //获取最后一个交互事件
        const lastEvent = getLastEvent()
        let message
        let reason = event.reason
        let filename
        let line = 0
        let column = 0
        let stack = ""
        if (typeof reason == "string") {
            message = reason
        }
        //说明是一个错误对象
        else if (typeof reason == "object") {
            if (reason.stack) {
                let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
                filename = matchResult[1]
                line = matchResult[2]
                column = matchResult[3]
            }
            stack = getLines(reason.stack)
            message = reason.stack.message
        }
        tracker.send("/jsErrorLog/addition", {
            title:"监控-promise错误",
            kind: "stability",//监控指标大类
            type: "error",//小类型,js报错
            errorType: "promiseError",//promise执行错误
            //报错信息
            message,
            //哪个文件报错
            filename,
            //报错位置
            position: `${line}:${column}`,
            //堆栈信息
            stack,
            //最后一个操作的元素
            selector: lastEvent ? getSelector(lastEvent) : ""
        })
    })

image.png

3.2.3 资源加载错误埋点

 window.addEventListener("error", (event) => {
        const lastEvent = getLastEvent()
        // 判断是否是资源加载错误
        if (event.target && (event.target instanceof HTMLScriptElement ||
                             event.target instanceof HTMLLinkElement ||
                             event.target instanceof HTMLImageElement)) {
            tracker.send("/jsErrorLog/addition", {
                title:"监控-资源加载错误",
                kind: "stability",         // 监控指标大类
                type: "error",             // 小类型,资源加载错误
                errorType: "resourceError",// 资源加载错误
                message: `${event.target.tagName} resource load error`, // 错误信息
                filename: event.target.src || event.target.href,        // 出错的资源文件
                position: "0:0",           // 资源加载错误无行列号
                stack: "",                 // 无堆栈信息
                selector: getSelector(event.target)
            })
        } 
    }, true)

image.png

3.3 页面白屏监控

白屏监控采用将页面按中心点切分,划分为横9竖9,共计 18 个点的方式。设置一个阈值,假设为 15,如果这 18 个点中有 15 个点对应的是页面的初始元素(如 bodyhtml 或最外层容器 div 节点),则判断该页面处于白屏状态,并进行数据上报。

image.png

import tracker from "../utils/tracker"
import onload from "../utils/onload"

export function blankScreen() {
    const wrapperElements = ["html", "body", "#container", ".content"]
    //白屏阈值
    const threshold = 15
    let emptyPoints = 0

    // 获取元素选择器
    function getSelector(ele) {
        if (ele.id) return `#${ele.id}`
        if (ele.className) return `.${ele.className.trim().replace(/\s+/g, '.')}`
        return ele.nodeName.toLowerCase()
    }

    // 判断元素是否为白屏容器
    function isWrapper(element) {
        if (element && wrapperElements.includes(getSelector(element))) {
            emptyPoints++
        }
    }

    // 页面加载后执行检查
    onload(() => {
        // 检查水平和垂直的九个点
        for (let i = 1; i <= 9; i++) {
            const xElement = document.elementFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
            const yElement = document.elementFromPoint(window.innerWidth / 2, window.innerHeight * i / 10)
            isWrapper(xElement)
            isWrapper(yElement)
        }

        // 判断是否达到白屏标准
        if (emptyPoints >= threshold) {
            const centerElem = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
            tracker.send("/jsErrorLog/addition", {
                title: "监控-白屏错误",
                kind: "stability",
                type: "blankScreen",
                errorType: "blankScreenError",
                emptyPoints,
                screen: `${window.screen.width}X${window.screen.height}`,
                viewPoint: `${window.innerWidth}X${window.innerHeight}`,
                selector: getSelector(centerElem)
            })
        }
    })
}

image.png

3.4 pv与uv监控

3.4.1 PV(页面访问量)的监控

  • pvCount 变量用于存储页面的访问次数。

  • 页面加载时,每次访问都会增加 pvCount,并存储在 localStorage 中。

    pvCount = parseInt(localStorage.getItem('pvCount') || '0') + 1;
    localStorage.setItem('pvCount', pvCount);
    
    • 如果 localStorage 中没有 pvCount,则初始化为 0,然后加 1 表示一次访问。
    • 每次访问页面时,都将新的 pvCount 存回 localStorage 中,确保下次页面加载时能够获取到正确的访问次数。

3.4.2. UV(独立访客)的监控

  • uvCount 变量用于监控独立访客数量(即每个用户只计算一次)。

  • localStorage 用来判断用户是否是新访客,通过存储 hasVisited 标记。

    if (!localStorage.getItem('hasVisited')) {
        uvCount = 1;  // 新用户
        localStorage.setItem('hasVisited', 'true');
    } else {
        uvCount = 0;  // 已经访问过的用户
    }
    
    • 如果 localStorage 中没有 hasVisited,说明这是新用户,uvCount 设置为 1,并设置 hasVisitedtrue,表示该用户已经访问过。
    • 如果 hasVisited 存在,则说明是回访用户,uvCount0

3.4.3. 页面停留时间的监控

  • stayDuration 变量用于存储页面停留时间(单位:秒)。

  • 通过监听页面的 beforeunload 事件,记录用户在页面停留的时间。

    const pageStartTime = Date.now();  // 页面加载时的时间戳
    window.addEventListener('beforeunload', () => {
        const pageEndTime = Date.now();  // 页面卸载时的时间戳
        stayDuration = (pageEndTime - pageStartTime) / 1000;  // 页面停留时间(秒)
    });
    

    3.4.4 pv/uv监控完整代码


import tracker from "../utils/tracker";
import onload from "../utils/onload";
import { generateUniqueUserId } from "../utils/generateUniqueUserId"
export function userActivityMonitor() {
    // 存储 PV 和 UV 数据
    let pvCount = 0;
    let uvCount = 0;
    let stayDuration = 0; // 页面停留时间,单位为秒
    const userId = localStorage.getItem('userId') || generateUniqueUserId();
    localStorage.setItem('userId', userId);  // 存储用户 ID 用于识别 UV(独立访客)

    // 获取用户的 UV(通过检查用户是否存在于 localStorage)
    if (!localStorage.getItem('hasVisited')) {
        uvCount = 1;  // 新用户
        localStorage.setItem('hasVisited', 'true');
    } else {
        uvCount = 0;  // 已经访问过的用户
    }

    // 获取 PV:每次页面访问时,增加计数
    pvCount = parseInt(localStorage.getItem('pvCount') || '0') + 1;
    localStorage.setItem('pvCount', pvCount);

    // 记录页面停留时间
    const pageStartTime = Date.now();  // 页面加载时的时间戳
    window.addEventListener('beforeunload', () => {
        const pageEndTime = Date.now();  // 页面卸载时的时间戳
        stayDuration = (pageEndTime - pageStartTime) / 1000;  // 页面停留时间(秒)
    });

    onload(function () {
        setTimeout(() => {
            tracker.send("/jsErrorLog/addition", {
                title: "监控-PV-UV",
                kind: "metrics", // 监控指标大类
                type: "userActivity", // 小类型,js 报错
                userId,
                pv: pvCount,
                uv: uvCount,
                stayDuration: stayDuration && "0"
            });
        }, 1000)
    })

image.png

3.5网络请求监控

通过重写浏览器的 XMLHttpRequest 对象的 opensend 方法,拦截和监控所有网络请求。它在请求发起时记录基本信息(如方法、URL),并在请求完成时(成功、失败或中止)计算请求持续时间、状态码、响应内容等,最终将这些数据通过自定义的 tracker.send 方法发送到后台进行日志记录和监控

import tracker from "../utils/tracker";

export function injectXHR() {
    const XMLHttpRequest = window.XMLHttpRequest;
    const oldOpen = XMLHttpRequest.prototype.open;
    const oldSend = XMLHttpRequest.prototype.send;

    // 用于处理请求的监控函数
    const handleRequestEvent = (xhr, startTime, body, type) => {
        const duration = Date.now() - startTime;
        const { status, statusText, response, responseType } = xhr;
        const responseContent = responseType === 'json' ? JSON.stringify(response) : "";

        tracker.send("/jsErrorLog/addition", {
            title: "监控-网络请求",
            kind: "stability",  // 监控指标大类
            type: "xhr",        // 小类型,js 报错
            errorType: type,    // 错误类型
            pathname: xhr.logData.url,  // 请求路径
            status: `${status}-${statusText}`,  // 状态码
            duration,          // 持续时间
            response: responseContent,  // 响应内容
            params: body || "" // 请求参数
        });
    };

    XMLHttpRequest.prototype.open = function (method, url, async) {
        if (!url.match(/logstores/) && !url.match(/sockjs/)) {
            this.logData = { method, url, async };
        }
        return oldOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function (body) {
        if (this.logData) {
            const startTime = Date.now();
            
            // 统一处理事件,减少重复代码
            const eventHandler = (type) => (event) => handleRequestEvent(this, startTime, body, type);

            // 监听各种状态的事件
            ['load', 'error', 'abort'].forEach((eventType) => {
                this.addEventListener(eventType, eventHandler(eventType), false);
            });
        }
        return oldSend.apply(this, arguments);
    };
}

请求成功 image.png 请求失败

image.png

3.6性能监控

3.6.1监控浏览器时间性能

navigationEntry 中解构出多个性能时间戳:

  • fetchStart: 页面请求开始的时间。
  • connectStart: 建立连接的开始时间。
  • connectEnd: 建立连接的结束时间。
  • requestStart: 发送请求的开始时间。
  • responseStart: 服务器开始响应的时间。
  • responseEnd: 服务器响应结束的时间。
  • domInteractive: DOM 完成解析,用户可交互的时间。
  • domContentLoadedEventStart: DOMContentLoaded 事件开始时间。
  • domContentLoadedEventEnd: DOMContentLoaded 事件结束时间。
  • loadEventStart: 页面加载完成的时间。
import tracker from "../utils/tracker";
import onload from "../utils/onload";

export function timeMonitor() {
    onload(function () {
      
        setTimeout(() => {
            const [navigationEntry] = performance.getEntriesByType('navigation');
            if (navigationEntry) {
                const {
                    fetchStart,
                    connectStart,
                    connectEnd,
                    requestStart,
                    responseStart,
                    responseEnd,
                    domInteractive,
                    domContentLoadedEventStart,
                    domContentLoadedEventEnd,
                    loadEventStart,
                } = navigationEntry;
                // 发送保留两位小数的性能数据
                tracker.send("/jsErrorLog/addition", {
                    title:"监控-性能指标",
                    kind: "experience", // 监控指标大类
                    type: "timing", // 统计每个阶段时间
                    // 连接时间
                    connectTime: (connectEnd - connectStart).toFixed(2),
                    // 首字节到达时间
                    ttfb: (responseEnd - requestStart).toFixed(2),
                    // 响应读取时间
                    responseTime: (responseEnd - responseStart).toFixed(2),
                    // DOM 解析时间
                    parseDOMTime: (domInteractive - responseEnd).toFixed(2),
                    // DOMContentLoaded 事件时间
                    domContentLoadedTime: (domContentLoadedEventEnd - domContentLoadedEventStart).toFixed(2),
                    // 首次可交互时间
                    timeTodomInteractiveTime: (domInteractive - fetchStart).toFixed(2),
                    // 完整的加载时间
                    loadTime: (loadEventStart - fetchStart).toFixed(2),
                });
            } else {
                console.warn('PerformanceNavigationTiming 不支持');

            }
        }, 3000);
    });
}

image.png

3.6.2 Web Vitals指标监控

字段描述含义
FPFirst Paint(首次绘制)包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻
FCPFirst ContentPaint(首次内容绘制)是浏览器将第一个DOM渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间
FMPFirst Meaningful Paint(首次有意义绘制)页面有意义的内容渲染的时间
LCP(Largest Contentful Paint)(最大内容渲染)代表在viewport中最大的页面元素加载的时间
DCL(DomContentLoaded)(DOM加载完成)当HTML文档被完全加载和解析完成之后,DOMContentLoaded事件被触发,无需等待样式表、图像和子框架的完成加载
LOnLoad当依赖的资源全部加载完毕之后才会触发
import tracker from "../utils/tracker";
import onload from "../utils/onload";

export function performanceMonitor() {
    onload(function () {
        let FP, FMP, LCP, FCP, DCL;
        const data = {};  // 用来存储各个性能指标的时间
        FP = performance.getEntriesByType('paint').filter(entry => entry.name == 'first-paint')[0].startTime;
        FCP = performance.getEntriesByType('paint').filter(entry => entry.name == 'first-contentful-paint')[0].startTime;

        // 获取 DCL 时间
        const dclEntry = performance.getEntriesByType('navigation')[0];
        DCL = dclEntry.domContentLoadedEventEnd - dclEntry.startTime;

        // 创建 PerformanceObserver 观察所有绘制类型
        if ('PerformanceObserver' in window) {
            const observer = new PerformanceObserver((list) => {
                const entries = list.getEntries();
                entries.forEach(entry => {
                    if (entry.entryType === 'largest-contentful-paint' && !data.LCP) {
                        LCP = entry.startTime;
                        data.LCP = LCP;  // 设置标志,防止再次记录
                    }
                    if (entry.name === 'first-contentful-paint' && !data.FMP) {
                        FMP = entry.startTime;
                        data.FMP = FMP;  // 设置标志,防止再次记录
                    }
                });
            });

            // 观察所有绘制类型
            observer.observe({ type: 'paint', buffered: true });
            observer.observe({ type: 'largest-contentful-paint', buffered: true });

            // 发送性能数据
            setTimeout(() => {
                tracker.send("/jsErrorLog/addition", {
                    title:"监控-性能指标LCP-FP--",
                    kind: "experience",
                    type: "paint",
                    FMP:FMP.toFixed(2),  // 首次有意义绘制时间
                    LCP:LCP.toFixed(2),  // 最大内容绘制时间
                    FP:FP.toFixed(2),   // 首次绘制时间
                    FCP:FCP.toFixed(2),  // 首次内容绘制时间
                    DCL:DCL.toFixed(2)   // DOMContentLoaded 时间
                });
            }, 2000);
        }
    });
}

4.数据上报

数据上报采用koa+json的方式,后期可能会转为mangodb

4.1构建koa服务

const Koa = require("koa");
const jsErrorRouter = require("./router/jsError.router.js");
const cors = require("koa-cors");
const bodyParser = require("koa-bodyparser");
const app = new Koa();
//处理跨域问题中间件
app.use(cors());
//处理post参数解析中间件
app.use(bodyParser());
//自定义路由中间件
app.use(jsErrorRouter.routes()).use(jsErrorRouter.allowedMethods());

const port = 3200;
app.listen(port, () => {
  console.log(`3200端口服务启用`);
});

使用koa-router中间件进行路由分发

const Router = require('koa-router');
const router = new Router();
const jsErrorLog = require("../controller/jsError.controller.js")
router.post("/jsErrorLog/addition", async (ctx, next) => {
    // 这里可以调用控制器函数,传递 ctx 处理请求数据
    await jsErrorLog.additionJsLogs(ctx,next);

});

module.exports = router

在控制器层进行逻辑处理

把用户上报的数据写进json进行处理,这里由于可能涉及到多个请求并发读写json,由于网络原因,a请求还没写入成功,b请求就开始读,因此给文件进行加锁处理

const { v4: uuidv4 } = require('uuid');
const fs = require("fs");
const { promisify } = require("util");
const path = require("path");
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const isJsonString = require("../utils/isJsonString");
const lockFile = require('proper-lockfile');

class JsErrorLog {
    // js报错日志写入
    async additionJsLogs(ctx, next) {
        const data = ctx.request.body;
        const filePath = path.resolve(__dirname, '../db/jsError.json');
        try {
            // 加锁文件,设置超时(例如 5000 毫秒),如果不能获取锁会抛出错误
            await lockFile.lock(filePath, { retries: 5, retryWait: 200 });

            // 读取当前的 jsError.json 文件内容
            let fileContent = await readFile(filePath, "utf-8");

            // 检查文件内容是否为有效 JSON
            if (!isJsonString(fileContent)) {
                console.error("Invalid JSON format in jsError.json");
                ctx.throw(400, "Invalid JSON format in jsError.json");
                return;
            }

            let json = JSON.parse(fileContent);

            // 确保 `json.list` 是数组
            if (!Array.isArray(json.list)) {
                json.list = [];
            }

            // 给数据添加一个唯一 ID
            data.id = uuidv4();

            // 将新数据推入 `list`
            json.list.push(data);

            // 将更新后的内容写回文件
            await writeFile(filePath, JSON.stringify(json, null, 2), "utf-8");

            ctx.body = {
                code: 200,
                msg: "success"
            };

        } catch (error) {
            console.error("Error handling jsError.json file:", error);
            ctx.throw(500, "Internal Server Error");
        } finally {
            try {
                // 解锁文件
                await lockFile.unlock(filePath);
            } catch (unlockError) {
                console.error("Error unlocking file:", unlockError);
            }
        }

        await next();
    }

    // 日志读取
    async queryJsLogs(ctx, next) {
        const filePath = path.resolve(__dirname, '../db/jsError.json');
        try {
            // 加锁文件,设置超时(例如 5000 毫秒)
            await lockFile.lock(filePath, { retries: 5, retryWait: 200 });

            let fileContent = await readFile(filePath, 'utf-8');
            let json = JSON.parse(fileContent);
            ctx.body = {
                code: 200,
                msg: "查询日志成功",
                data: json.list
            };

        } catch (error) {
            console.error("Error reading jsError.json:", error);
            ctx.throw(500, "Internal Server Error");
        } finally {
            try {
                // 解锁文件
                await lockFile.unlock(filePath);
            } catch (unlockError) {
                console.error("Error unlocking file:", unlockError);
            }
        }

        await next();
    }

    // 日志删除
    async deleteJsLogs(ctx, next) {
        const filePath = path.resolve(__dirname, '../db/jsError.json');
        const { id } = ctx.request.body;  // 假设删除时通过 id 来删除
        try {
            // 加锁文件,设置超时(例如 5000 毫秒)
            await lockFile.lock(filePath, { retries: 5, retryWait: 200 });

            let fileContent = await readFile(filePath, 'utf-8');
            let json = JSON.parse(fileContent);

            // 过滤掉删除的日志
            json.list = json.list.filter(log => log.id !== id);

            // 更新文件
            await writeFile(filePath, JSON.stringify(json, null, 2), 'utf-8');
            ctx.body = {
                code: 200,
                msg: "删除日志成功"
            };

        } catch (error) {
            console.error("Error deleting jsError.json:", error);
            ctx.throw(500, "Internal Server Error");
        } finally {
            try {
                // 解锁文件
                await lockFile.unlock(filePath);
            } catch (unlockError) {
                console.error("Error unlocking file:", unlockError);
            }
        }

        await next();
    }
}

module.exports = new JsErrorLog();

image.png