前端埋点方案的目的是通过在页面中嵌入代码,收集用户行为数据、性能数据、错误日志等信息,以便后端或第三方平台进行分析和处理。常见的前端埋点方案包括事件埋点、页面埋点、性能埋点和错误日志埋点等。
以下将通过代码示例,介绍如何构建一个简单的前端埋点 SDK,帮助你实现自定义的埋点数据收集与传输。
1.monorepo+pnpm构建项目
1.1 monorepo的优势
这里采用monorepo+pnpm的方式,它的好处如下:
- 统一管理依赖:所有子项目共享一个
node_modules
,减少了重复安装和磁盘占用,依赖版本保持一致。 - 更高效的包管理:pnpm 通过软链接的方式优化了依赖的安装速度,避免了 npm 和 yarn 的重复安装和冗余文件。
- 简化跨项目开发:多个项目可以相互引用,方便在不同的包之间进行共享和重用代码。
- 简化版本控制:单一仓库便于管理版本、发布和更新,减少了多仓库管理的复杂性。
- 便于集成和自动化:支持跨项目的构建和测试,可以快速实现 CI/CD 流程。
这种方式使得多项目的管理更加高效、清晰,并且减少了重复劳动。
1.2 monorepo实际应用
在根目录下使用pnpm init构建一个包管理工具,在根路径下新建packages,packages下新建web和server目录,web为前端sdk埋点测试项目,server为埋点测试服务上报
根目录下新建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的主文件
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)
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) : ""
})
})
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)
3.3 页面白屏监控
白屏监控采用将页面按中心点切分,划分为横9竖9,共计 18 个点的方式。设置一个阈值,假设为 15,如果这 18 个点中有 15 个点对应的是页面的初始元素(如 body
、html
或最外层容器 div
节点),则判断该页面处于白屏状态,并进行数据上报。
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)
})
}
})
}
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
,并设置hasVisited
为true
,表示该用户已经访问过。 - 如果
hasVisited
存在,则说明是回访用户,uvCount
为0
。
- 如果
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)
})
3.5网络请求监控
通过重写浏览器的 XMLHttpRequest
对象的 open
和 send
方法,拦截和监控所有网络请求。它在请求发起时记录基本信息(如方法、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);
};
}
请求成功
请求失败
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);
});
}
3.6.2 Web Vitals指标监控
字段 | 描述 | 含义 |
---|---|---|
FP | First Paint(首次绘制) | 包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻 |
FCP | First ContentPaint(首次内容绘制) | 是浏览器将第一个DOM渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间 |
FMP | First Meaningful Paint(首次有意义绘制) | 页面有意义的内容渲染的时间 |
LCP | (Largest Contentful Paint)(最大内容渲染) | 代表在viewport中最大的页面元素加载的时间 |
DCL | (DomContentLoaded)(DOM加载完成) | 当HTML文档被完全加载和解析完成之后,DOMContentLoaded事件被触发,无需等待样式表、图像和子框架的完成加载 |
L | OnLoad | 当依赖的资源全部加载完毕之后才会触发 |
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();