万字干货:手摸手教你大厂必备埋点监控💐
前言
什么是埋点
埋点是指在系统(通常是前端)中,通过在代码中插入采集点(或在后台动态配置规则),来记录用户行为数据的一种技术手段。
埋点按数据侧重分为两种:一种是前端埋点,一种是后端埋点,这篇文章当然是讲讲前端埋点叻。
后端埋点:那我走?
代码埋点
代码埋点是最传统的埋点方式,开发者需要在代码中手动插入埋点逻辑。例如,当用户点击某个按钮时,代码中会显式地记录这一事件。
优点 精准,灵活可以根据业务需求自由定义采集内容和触发条件。
缺点 开发成本高,需要投入大量的开发资源;容易出现漏埋、重复埋点等问题。
示例
button.addEventListener('click', () => {
sendTrackingEvent({
eventName: 'button_click',
buttonId: 'submit_button',
timestamp: Date.now(),
});
});
使用场景:埋点诉求复杂,需要为业务定制化的行为埋点
可视化埋点
可视化埋点通过可视化配置工具实现,用户可以在界面上直接选取交互点生成埋点规则,而无需修改代码。国内支持可视化埋点的工具有TalkingData、诸葛IO、腾讯MTA等
优点 配置简单,非技术人员也能参与埋点规则的配置。
缺点 灵活性较差,对于复杂场景支持不足。
使用场景:业务多变,但分析诉求比较轻量,不需要自定义事件的场景
无埋点
无埋点(或称全埋点)是一种通过拦截全局事件自动采集数据的方式,例如记录所有点击、输入或页面跳转操作。
优点 开发成本低,减少了手动埋点的工作量;数据全面
缺点 数据冗余,并且可能会对页面性能造成一定负担。
使用场景:业务页面多,且变动相对不是很频繁,有一定的分析场景, 用户行为可直接与页面可视元素挂钩
国内常用的埋点平台
神策数据:有全端埋点、用户分群、漏斗分析、路径分析、A/B测试等功能,支持私有化部署,数据可完全掌控,费用较高
友盟+:支持基础埋点、页面统计、事件分析、用户画像等基础功能,免费版功能有限
诸葛IO:可以进行行为分析、用户路径、留存分析、自定义报表,支持私有化部署,适合对数据安全敏感的企业
国内常用的监控平台
sentry :从监控错误、错误统计图表、多重标签过滤和标签统计到触发告警,这一整套都很完善,团队项目需要充钱,而且数据量越大钱越贵
fundebug:除了监控错误,还可以录屏,也就是记录错误发生的前几秒用户的所有操作,压缩后的体积只有几十 KB,但操作略微繁琐
webfunny:也是含有监控错误的功能,可以支持千万级别日PV量,额外的亮点是可以远程调试、性能分析,也可以docker私有化部署(免费),业务代码加密过,二次开发受限
总结:都要💰😖😖😖
这时候就应该有读者要问了:都要氪金还是太吃经济了,小爱同学有没有什么免费的推荐一下?有的兄弟有的,那就是自己动手搭一个埋点监控平台🔥🔥🔥
为什么需要埋点&监控
关于这个问题,看过最好、最简洁的解释就是:获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,指明产品优化的方向。
不过有些同学可能不能立马get到这句话,这里小爱同学就结合一些情况/场景展开解释一下。
埋点,它的本质就是收集用户行为数据,帮你看清楚:用户来了、点了、看了、走了,甚至为什么走了。bug不可能被全部测试出来,而监控却能帮助我们从被动转为主动,在问题出现时开发人员可以第一时间得知并解决。
举两个用户场景:
- A 用户进入某电商 App,浏览了 10 分钟,商品都加满购物车了,却在结算页面悄无声息地退出了……他到底是卡住了,还是嫌运费太贵?
- B 用户点了某个按钮,但这个按钮压根就没反应,气得他直接卸载了 App,甚至在应用商店留了个差评……但app团队内部却一直认为功能运作良好。
当然还有其他角度对于什么情况需要埋点的理解,这边我拿了@AboutIt的一张图,非常直观,如下:
很多公司使用埋点监控可以解决以上问题,而且大公司内部一般也会有自研的一套埋点监控方案,根据自身业务特点和数据需求进行深度定制。
题外话:美团外卖什么时候能通过这玩意能把我的天天好券膨胀到10+元😭😭😭而不是提高我的拼好饭价格
架构设计
整体架构
在应用层SDK上报的数据,要在接入层经过削峰限流、数据清洗、数据加工等之后,将原始日志存储于ES中,在经过聚合,将聚合过的数据(issue)持久化存储于MySQL,最后提供RESTful API给监控平台调用
-
削峰限流:避免激增的大数据量、恶意用户访问等高并发数据导致的服务崩溃 -
数据加工:将IP、运营商、归属地等各种二次加工数据,封装进上报数据里 -
数据清洗:为了经由白名单、黑名单过滤等的业务需要,并避免已关闭的应用数据继续入库 -
数据聚合:将相同信息的数据进行抽象聚合成issue,以便查询和追踪
业务模块
通过前面的知识,我们应该对埋点&监控有了一个大致了解:它是什么,为什么要有这个东西,它要干什么。一个基础的埋点监控平台呢也就大致分为三个主要的业务板块:页面性能监控、用户行为监控以及错误警告监控。为了提高健壮性和可拓展性,我们可以加上数据上报模块以及插件模块。
sdk设计架构
SDK 主要负责数据采集的实现,它通过嵌入到前端应用中来采集用户行为数据,然后通过 API 上报至后端系统进行处理。
SDK示例:
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: "https://e19d714e725e453caac128286a1f0645@o4505508596350976.ingest.sentry.io/4505508608278528",
integrations: [
new Sentry.BrowserTracing({
tracePropagationTargets: ["localhost", "https:yourserver.io/api/"],
}),
new Sentry.Replay(),
],
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
const container = document.getElementById(“app”);
const root = createRoot(container);
root.render(<App />)
但是在前端监控方面,我们可不仅仅只是监控web环境下的数据,其余环境如:Node.js、微信小程序、Electron等都是有监控需求的。所以,为了支持多平台、可插拔,整体SDK架构应该是内核+插件:每个SDK首先继承于Core层代码,然后在自身SDK中初始化内核实例和插件。
参考: 腾讯三面:说说前端监控平台/监控SDK的架构设计和难点亮点?如何选择上报策略?监控SDK如何设计成多平台支持?如何进行S - 掘金
自定义一个sdk一般围绕以下几个核心思想展开: (拿接下来要讲的性能监控举例)
1. 全局配置
为 SDK 提供一个统一、可定制的入口,使得后续的功能实现(如数据上报、路由监听、用户识别等)都可以基于这些配置项进行操作,从而提高了 SDK 的灵活性和可维护性
const defaultConfig = {
// 标识当前应用,方便后端进行区分统计
appId: ,
// 当前用户标识,用于追踪用户行为
userId: ,
// 上报数据的接口地址
reportUrl:,
// 用户代理信息,可用于分析客户端环境
ua: navigation.userAgent,
};
let config = { ...defaultConfig };
export function getConfig(){
return config;
}
// 更新全局配置
export function setConfig(options = {}) {
config = {
...defaultConfig,
...options,
};
}
export default config;
2. 定义数据结构
type commonType = {
type: string // 类型 如“performance”
subType: string // 一级类型 如“resource”
timestamp: number //记录采集的时间戳
}
export type PerformanceResourceType = commonType & {
/** 资源的名称或 URL */
name: string
/** DNS 查询所花费的时间 */
dns: number
/** 请求的总持续时间,从开始到结束 */
duration: number
/** 请求使用的协议,如 HTTP 或 HTTPS */
protocol: string
/** 重定向所花费的时间 */
redirect: number
/** 资源的大小 */
resourceSize: number
/** 响应体的大小 */
responseBodySize: number
/** 响应头的大小 */
responseHeaderSize: number
/** 资源类型,如 "script", "css" 等 */
sourceType: string
/** 请求开始的时间,通常是一个高精度的时间戳 */
startTime: number
/** 资源的子类型,用于进一步描述资源 */
subType: string
/** TCP 握手时间 */
tcp: number
/** 传输过程中实际传输的字节大小 */
transferSize: number
/** 首字节时间 (Time to First Byte),从请求开始到接收到第一个字节的时间,单位为毫秒 */
ttfb: number
/** 类型,通常用于描述性能记录的类型,如 "performance" */
type: string
/** 页面路径" */
pageUrl: string
}
3. 核心类封装
import { getConfig } from './config';
import type { PerformanceResourceType } from './types';
export class PerformanceMonitor {
// 存储采集到的性能数据
private metrics: PerformanceResourceType[] = [];
constructor() {
this.init();
}
// 初始化监控:绑定 load 事件以及利用 PerformanceObserver 自动采集指标
init() {
// 页面加载完成后采集基于 performance.timing 的数据(可选)
window.addEventListener('load', () => {
this.collectTimingMetrics();
// 自动上报(可以根据配置决定是否自动上报)
setTimeout(() => this.sendMetrics(), 3000);
});
// 利用 PerformanceObserver 自动采集 resource 类型的性能数据
if (window.PerformanceObserver) {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === 'resource') {
const resourceEntry = entry as PerformanceResourceTiming;
// 这里我们构造符合 PerformanceResourceType 数据结构的对象
const metric: PerformanceResourceType = {
........
};
this.metrics.push(metric);
}
});
});
observer.observe({ entryTypes: ['resource'] });
} else {
console.warn(...);
}
}
// 采集页面 Timing 数据
collectTimingMetrics() {
if (window.performance && window.performance.timing) {
const timing = window.performance.timing;
// 可计算页面加载时间、TTFB 等指标,构造自定义性能数据
const metric: PerformanceResourceType = {
...
};
this.metrics.push(metric);
}
}
// 获取当前采集到的性能数据
getMetrics(): PerformanceResourceType[] {
return this.metrics;
}
// 将采集到的数据上报到服务器
...
// 上报成功后可清空数据
this.metrics = [];
}
} catch (err) {
console.error(...);
}
}
}
一般来说,埋点监控sdk还有一个数据上报的环节,也就是要确定数据发送的方式。以上代码仅作示例,后面会另开一篇文章聊一聊数据上报~
性能监控
Performance API
Performance API 是浏览器提供给我们的用于检测网页性能的 API,有以下几个主要的API:
Resource Timing API:与网页资源(脚本、样式、图片等)加载相关的耗时信息,定义了接口 PerformanceResourceTiming
Navigation Timing API:定义了接口 PerformanceNavigationTiming,此接口继承自 PerformanceResourceTiming 接口,精确到纳秒。
Performance.timing:提供了在加载和使用当前页面期间发生的各种事件的性能计时信息,精度只能到毫秒。已弃用
Paint Timing:与网页绘制相关的耗时信息。定义了接口 PerformancePaintTiming
此外,还可以使用第三方库(例如 Google 的 web-vitals)来获取核心指标。
FCP-首次内容绘制
首次内容绘制,FCP(First Contentful Paint),这个指标用于记录页面首次绘制文本、图片、非空白 Canvas 或 SVG 的时间。
- 通过w3c/paint-timing获取
new PerformanceObserver((entryList) => {
// 这里你可以拿到所有名为 'first-contentful-paint' 的条目,条目中包含 startTime、duration 等详细数据
for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
console.log('fcp', entry);
}
}).observe({ type: 'paint', buffered: true });
- 使用
google的web-vitals
import {getFCP} from 'web-vitals';
// 当 FCP 可用时立即进行测量和记录。
getFCP(console.log);
FP-首次绘制
首次绘制,FP(First Paint),这个指标用于记录页面第一次绘制像素的时间。FP发生的时间一定小于等于FCP
W3C标准化在w3c/paint-timing定义了fp,我们可以直接去取
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntriesByName('first-paint')) {
console.log('fp', entry);
}
}).observe({ type: 'paint', buffered: true });
LCP-最大内容绘制
LCP 其实能比前两个指标更能体现一个页面的性能好坏程度,因为这个指标会持续更新:当页面出现骨架屏时 FCP 其实已经被记录下来了,但是此时用户希望看到的内容其实并未呈现,我们更想知道的是页面主要的内容是何时呈现出来的。
new PerformanceObserver((entryList) => {
entryList.getEntries().forEach((entry) => {
console.log('LCP:', entry);
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
CLS-累计布局偏移
CLS 记录了页面上非预期的位移波动,为了测量视觉稳定性,以便提供良好的用户体验,应保持在0.1或更少
new PerformanceObserver((entryList) => {
entryList.getEntries().forEach((entry) => {
// 仅记录没有用户交互引起的布局偏移
if (!entry.hadRecentInput) {
console.log('Layout Shift (CLS entry):', entry);
}
});
}).observe({ type: 'layout-shift', buffered: true });
用户行为
常见的用户行为数据包括:
- PV/UV
- 用户在每一个页面的停留时间
- 用户通过什么入口来访问该网页
- 用户在相应的页面中触发的行为 如此等等
PV(page view)
即页面浏览量或点击量,多次访问同一个页面,pv会累计,需要注意SPA和非SPA页面
非SPA比较简单,因为每次页面刷新都会重新加载,可以在页面加载的时候发送一次PV统计请求:
window.addEventListener('load', () => {
const img = new Image();
img.src = 'https://yourserver.com/track?event=pv&url=' + encodeURIComponent(window.location.href);
});
对于SPA来说,由于页面不会真正刷新,所以不能仅依赖页面加载事件来统计 PV,需要在路由变化时捕获“虚拟页面浏览”。单页路由又分为hash路由和history路由,这两种路由的原理也不一样。
hash路由通过url的#变化来实现页面切换,原生提供了hashchange时间,可以用来监听统计pv:
window.addEventListener('hashchange', () => {
// 当 URL 中的 hash 发生变化时触发
const pvData = {
event: 'pv',
url: window.location.href,
timestamp: Date.now()
};
fetch('https://yourserver.com/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pvData)
});
});
history路由依赖pushState和replaceState实现,但这两方法都不能被popState监听,需要重写:
// 重写 history.pushState 和 history.replaceState 方法
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(state, title, url) {
originalPushState.apply(history, arguments);
handleRouteChange(url);
};
history.replaceState = function(state, title, url) {
originalReplaceState.apply(history, arguments);
handleRouteChange(url);
};
// 监听 popstate 事件(用户点击浏览器的前进/后退按钮时触发)
window.addEventListener('popstate', () => {
handleRouteChange(window.location.href);
});
// 处理路由变化的函数
function handleRouteChange(url) {
const pvData = {
event: 'pv',
url: url || window.location.href,
timestamp: Date.now()
};
fetch('https://yourserver.com/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pvData)
});
}
UV(unique visitor)
指访问某个站点或点击某条新闻的不同IP地址的人数,一般来说同一个客户端或者用户账号在24h内多次访问只会被记录为1个uv,计算策略视具体情况而定。
| 方案 | 优点 | 缺点 |
|---|---|---|
基于 Cookie | - 实现简单 - 可在客户端存储唯一标识符 | - 用户可能禁用或清除 Cookie - 无法跨设备识别同一用户 |
基于 IP 地址 | - 无需在客户端存储数据 - 实现简单 | - 同一局域网用户共享 IP,可能导致统计不准确 - 用户使用代理或 VPN 时,IP 可能变化 |
用户登录信息 | - 可精确识别用户 - 可跨设备识别同一用户 | - 仅适用于需要用户登录的网站 - 无法统计未登录用户的访问 |
浏览器指纹 | - 可在用户禁用 Cookie 时仍能识别用户 - 提高 UV 统计的准确性 | - 涉及用户隐私,可能引发法律和道德问题 - 实现复杂度高 - 指纹可能随浏览器或设备变化 |
如果有读者想看这部分的详细介绍&代码,可以列入第二篇文章计划内容www。
页面停留时间
const pageStartTime = Date.now(); // 页面加载时的时间戳
window.addEventListener('beforeunload', () => {
const pageEndTime = Date.now(); // 页面卸载时的时间戳
stayDuration = (pageEndTime - pageStartTime) / 1000; // 页面停留时间(秒)
});
用户入口来源
const referrer = document.referrer;
console.log('用户入口来源:', referrer);
用户行为(以点击为例)
document.addEventListener('click', (event) => {
const target = event.target;
const clickData = {
event: 'click',
tag: target.tagName,
id: target.id,
className: target.className,
timestamp: Date.now()
};
console.log('点击数据:', clickData);
// 将 clickData 发送到服务器进行记录
fetch('https://yourserver.com/track', {
method: 'POST',
body: JSON.stringify(clickData),
headers: { 'Content-Type': 'application/json' }
});
});
错误监控
vue错误
在vue中可以用app.config.errorHandler来处理全局的错误
app.config.errorHandler = (err) => {
navigator.sendBeacon(url, {error: error.message, text: 'vue运行异常' })
}
React错误
在 React 中,可以使用Error Boundary来捕获子组件的错误
同步错误
同步错误指的是在js同步执行过程中的错误,比如变量未定义等,同步错误是可以被 try-catch 给捕获到的。
promise未处理错误
对于异步代码中未捕获的 Promise 错误,可以通过监听 unhandledrejection 事件来上报错误:
window.addEventListener('unhandledrejection', (ev) => {
console.log('unhandledrejection', ev)
})
错误栈在event的reason字段里。
JS运行时错误
用window.addEventListener监听window的error事件来收集js报错
window.addEventListener('error', (event) => {
console.log('args window error', event)
})
可以添加多个错误监听器而不会互相覆盖;
也可以用window.onerror来监听事件:
//参数通常包括错误消息、来源、行号、列号和错误对象
window.onerror = (message, source, lineno, colno, error) => {
...
}
由于是属性赋值,只能存在一个全局处理函数,后面的赋值会覆盖之前的。
资源加载错误
对于图片、脚本、样式等资源加载失败的错误,可以通过捕获 error 事件(注意这里使用捕获阶段)进行上报
<!-- 局部处理 -->
<img src="invalid.jpg" onerror="handleImageError()">
<script>
// 全局监听
document.addEventListener('error', (e) => {
reportError(e.target); // 全局上报
}, true);
</script>
数据上报
数据上报是整个 SDK 的核心。一般不做处理的情况下,单条实时上报;然而真实的项目需要上报的数据量会很多很多,为了不阻塞业务程序的执行,这个时候就要考虑进行批量上报和延迟上报
无优化处理
优化处理
批量上报:将单条上报聚合成多条上报,大大减少了数量的请求;
延迟上报:先本地化存储数据,将上报请求延后,优先处理业务逻辑请求,在程序空闲时进行上报
🌟数据上报一般有三个方案:
| 上报方式 | 优点 💡 | 缺点 ⚠️ | 适用场景 🎯 |
|---|---|---|---|
navigator.sendBeacon | ✅ 不阻塞主线程,适合页面关闭前上报 ✅ 浏览器原生支持,性能开销低 ✅ 可用于批量上报 | ❌ 只能 POST,不支持 GET❌ 部分浏览器(Safari 旧版)可能不兼容 | 🔹 页面卸载/关闭前埋点 🔹 错误日志上报 |
fetch(keepalive: true) | ✅ 灵活支持 GET/POST✅ 可携带 credentials进行用户认证✅ 可以同步响应结果 | ❌ 页面关闭时可能丢失请求 ❌ keepalive 只支持 POST,部分浏览器兼容性问题 | 🔹 实时埋点(用户行为点击) 🔹 页面加载性能监控 |
img 像素上报 | ✅ CDN 友好,静态资源请求✅ 适用于跨域请求,无需额外配置 ✅ 适用于 GET 请求 | ❌ 只能 GET,无法携带复杂 JSON 数据❌ 受浏览器缓存影响 | 🔹 广告点击跟踪 🔹 第三方数据采集(如 Google Analytics) |
这篇文章好像是有点太长了,掘金本地显示2w+字了😰,本来打算按模块分为三个系列慢慢讲的,不过想来想去打算分为两个系列,这部分讲一个大体的介绍(比较肤浅T^T),另一部分打算写埋点监控平台面试题&部分技术的详细介绍👀,如果有好的想法可以评论/私聊提出来·v·💗大家就这样凑合看吧,感觉太干巴就多喝点水😉😉😉