🚀万字干货:手摸手教你大厂必备埋点监控

2,078 阅读17分钟

万字干货:手摸手教你大厂必备埋点监控💐

前言

什么是埋点

埋点是指在系统(通常是前端)中,通过在代码中插入采集点(或在后台动态配置规则),来记录用户行为数据的一种技术手段。
埋点按数据侧重分为两种:一种是前端埋点,一种是后端埋点,这篇文章当然是讲讲前端埋点叻。
后端埋点:那我走?

image.png 前端埋点一般有三种方案:代码埋点、可视化埋点以及无埋点(也就是全埋点)
代码埋点

代码埋点是最传统的埋点方式,开发者需要在代码中手动插入埋点逻辑。例如,当用户点击某个按钮时,代码中会显式地记录这一事件。

优点 精准,灵活可以根据业务需求自由定义采集内容和触发条件。

缺点 开发成本高,需要投入大量的开发资源;容易出现漏埋、重复埋点等问题。

示例

button.addEventListener('click', () => {
  sendTrackingEvent({
    eventName: 'button_click',
    buttonId: 'submit_button',
    timestamp: Date.now(),
  });
});

使用场景:埋点诉求复杂,需要为业务定制化的行为埋点

image.png
可视化埋点

可视化埋点通过可视化配置工具实现,用户可以在界面上直接选取交互点生成埋点规则,而无需修改代码。国内支持可视化埋点的工具有TalkingData、诸葛IO、腾讯MTA等

优点 配置简单,非技术人员也能参与埋点规则的配置。

缺点 灵活性较差,对于复杂场景支持不足。

使用场景:业务多变,但分析诉求比较轻量,不需要自定义事件的场景

无埋点

无埋点(或称全埋点)是一种通过拦截全局事件自动采集数据的方式,例如记录所有点击、输入或页面跳转操作。

优点 开发成本低,减少了手动埋点的工作量;数据全面

缺点 数据冗余,并且可能会对页面性能造成一定负担。

使用场景:业务页面多,且变动相对不是很频繁,有一定的分析场景, 用户行为可直接与页面可视元素挂钩

国内常用的埋点平台

神策数据:有全端埋点、用户分群、漏斗分析、路径分析、A/B测试等功能,支持私有化部署,数据可完全掌控,费用较高

友盟+:支持基础埋点、页面统计、事件分析、用户画像等基础功能,免费版功能有限

诸葛IO:可以进行行为分析、用户路径、留存分析、自定义报表,支持私有化部署,适合对数据安全敏感的企业

国内常用的监控平台

sentry :从监控错误、错误统计图表、多重标签过滤和标签统计到触发告警,这一整套都很完善,团队项目需要充钱,而且数据量越大钱越贵

fundebug:除了监控错误,还可以录屏,也就是记录错误发生的前几秒用户的所有操作,压缩后的体积只有几十 KB,但操作略微繁琐

webfunny:也是含有监控错误的功能,可以支持千万级别日PV量,额外的亮点是可以远程调试、性能分析,也可以docker私有化部署(免费),业务代码加密过,二次开发受限

总结:都要💰😖😖😖

这时候就应该有读者要问了:都要氪金还是太吃经济了,小爱同学有没有什么免费的推荐一下?有的兄弟有的,那就是自己动手搭一个埋点监控平台🔥🔥🔥

image.png

为什么需要埋点&监控

关于这个问题,看过最好、最简洁的解释就是:获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,指明产品优化的方向

不过有些同学可能不能立马get到这句话,这里小爱同学就结合一些情况/场景展开解释一下。

埋点,它的本质就是收集用户行为数据,帮你看清楚:用户来了、点了、看了、走了,甚至为什么走了。bug不可能被全部测试出来,而监控却能帮助我们从被动转为主动,在问题出现时开发人员可以第一时间得知并解决。

举两个用户场景

  • A 用户进入某电商 App,浏览了 10 分钟,商品都加满购物车了,却在结算页面悄无声息地退出了……他到底是卡住了,还是嫌运费太贵?
  • B 用户点了某个按钮,但这个按钮压根就没反应,气得他直接卸载了 App,甚至在应用商店留了个差评……但app团队内部却一直认为功能运作良好。
624566f492039f6b293ffa2887b27a3b.jpg

当然还有其他角度对于什么情况需要埋点的理解,这边我拿了@AboutIt的一张图,非常直观,如下:

很多公司使用埋点监控可以解决以上问题,而且大公司内部一般也会有自研的一套埋点监控方案,根据自身业务特点和数据需求进行深度定制。

题外话:美团外卖什么时候能通过这玩意能把我的天天好券膨胀到10+元😭😭😭而不是提高我的拼好饭价格

image.png

架构设计

整体架构

image.png 在应用层SDK上报的数据,要在接入层经过削峰限流数据清洗数据加工等之后,将原始日志存储于ES中,在经过聚合,将聚合过的数据(issue)持久化存储于MySQL,最后提供RESTful API给监控平台调用

  • 削峰限流:避免 激增的大数据量恶意用户访问等高并发数据导致的服务崩溃

  • 数据加工:将 IP运营商归属地等各种二次加工数据,封装进上报数据里

  • 数据清洗:为了经由白名单黑名单过滤等的业务需要,并避免已关闭的应用数据继续入库

  • 数据聚合:将相同信息的数据进行抽象聚合issue,以便查询和追踪

业务模块

通过前面的知识,我们应该对埋点&监控有了一个大致了解:它是什么,为什么要有这个东西,它要干什么。一个基础的埋点监控平台呢也就大致分为三个主要的业务板块:页面性能监控用户行为监控以及错误警告监控。为了提高健壮性和可拓展性,我们可以加上数据上报模块以及插件模块

image.png

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中初始化内核实例和插件。

image.png

参考: 腾讯三面:说说前端监控平台/监控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:提供了在加载和使用当前页面期间发生的各种事件的性能计时信息,精度只能到毫秒。已弃用

image.png

Paint Timing:与网页绘制相关的耗时信息。定义了接口 PerformancePaintTiming

此外,还可以使用第三方库(例如 Google 的 web-vitals)来获取核心指标。

FCP-首次内容绘制
首次内容绘制,FCP(First Contentful Paint),这个指标用于记录页面首次绘制文本、图片、非空白 Canvas 或 SVG 的时间。

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 });

  • 使用googleweb-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) => {
    ...
}

image.png 由于是属性赋值,只能存在一个全局处理函数,后面的赋值会覆盖之前的。

资源加载错误
对于图片、脚本、样式等资源加载失败的错误,可以通过捕获 error 事件(注意这里使用捕获阶段)进行上报

<!-- 局部处理 -->
<img src="invalid.jpg" onerror="handleImageError()">

<script>
// 全局监听
document.addEventListener('error', (e) => {
  reportError(e.target); // 全局上报
}, true);
</script>

数据上报

数据上报是整个 SDK 的核心。一般不做处理的情况下,单条实时上报;然而真实的项目需要上报的数据量会很多很多,为了不阻塞业务程序的执行,这个时候就要考虑进行批量上报延迟上报

无优化处理

image.png

优化处理

image.png

批量上报:将单条上报聚合成多条上报,大大减少了数量的请求;
延迟上报:先本地化存储数据,将上报请求延后,优先处理业务逻辑请求,在程序空闲时进行上报

🌟数据上报一般有三个方案:

上报方式优点 💡缺点 ⚠️适用场景 🎯
navigator.sendBeacon✅ 不阻塞主线程,适合页面关闭前上报
✅ 浏览器原生支持,性能开销低
✅ 可用于批量上报
❌ 只能 POST,不支持 GET
❌ 部分浏览器(Safari 旧版)可能不兼容
🔹 页面卸载/关闭前埋点
🔹 错误日志上报
fetchkeepalive: true✅ 灵活支持 GET/POST
✅ 可携带 credentials进行用户认证
✅ 可以同步响应结果
❌ 页面关闭时可能丢失请求  ❌ keepalive 只支持 POST,部分浏览器兼容性问题🔹 实时埋点(用户行为点击)
🔹 页面加载性能监控
img 像素上报✅ CDN 友好,静态资源请求✅ 适用于跨域请求,无需额外配置
✅ 适用于 GET 请求
❌ 只能 GET,无法携带复杂 JSON 数据
❌ 受浏览器缓存影响
🔹 广告点击跟踪
🔹 第三方数据采集(如 Google Analytics)

这篇文章好像是有点太长了,掘金本地显示2w+字了😰,本来打算按模块分为三个系列慢慢讲的,不过想来想去打算分为两个系列,这部分讲一个大体的介绍(比较肤浅T^T),另一部分打算写埋点监控平台面试题&部分技术的详细介绍👀,如果有好的想法可以评论/私聊提出来·v·💗大家就这样凑合看吧,感觉太干巴就多喝点水😉😉😉 b86a624dd16a268c4adbbd808b2e3a07.jpg