生产级前端埋点方案:技术选型与完整实现

0 阅读17分钟

生产级前端埋点方案:技术选型与完整实现

结合 Vue + Spring Boot 技术栈,以及”后端自动从 Cookie 取当前登录用户”的核心诉求,本文档提供生产级的技术选型对比方案和完整实现样例。

核心区别一句话

  • GIF 埋点:靠浏览器加载图片资源时自动带 Cookie、页面卸载也能发、天生不跨域,是前端监控的”隐形守护者”。
  • RESTful 异步接口(fetch/axios):标准的 AJAX 请求,业务可控性强,但页面关闭时极易丢失数据,且跨域和拦截配置相对繁琐。

两种方案的优缺点对比

为了让你更直观地评估,以下是针对核心维度的深度对比:

对比维度GIF 1x1 图片埋点 (GET)RESTful 异步接口 (POST/Fetch)
用户身份获取极优。同源下浏览器自动带上 Cookie,Spring Boot 后端直接解析 Session/Cookie 拿用户,前端无需传参,最安全省心。中等。同源可带 Cookie;若跨域或第三方站点,需严格配置 withCredentials + CORS,否则后端取不到当前用户。
页面关闭/跳转上报极稳。在 beforeunload / unload 阶段触发,浏览器会优先完成资源请求,数据几乎不丢。易丢。页面一关,异步请求会被浏览器强制中断。即使加 keepalive,兼容性和稳定性也不如图片请求。
性能与阻塞无感。图片加载属于浏览器底层异步行为,完全不阻塞主线程和页面渲染。轻微。JS 异步执行虽不阻塞,但相比浏览器底层的图片加载,代码逻辑和资源开销略重。
数据安全与防刷较弱。GET 请求参数暴露在 URL 中,容易被刷。需在 URL 中拼接签名和时间戳来防御。较强。支持 POST,参数放 Body 不暴露;可灵活添加自定义请求头、Token 和复杂签名。
后端处理模式一致。收到请求后立刻丢入 MQ/队列,并返回 1x1 像素的透明 GIF 响应。一致。收到请求后立刻丢入 MQ/队列,并返回标准 HTTP 200 状态码。

01-infographic-gif-vs-restful-comparison.png


使用场景与产品形态决策

在理解了两种方案的优缺点后,接下来需要根据你的使用场景产品形态来做技术选型决策。

一、使用场景分类

1. 前端监控类(高频、轻量、实时性要求高)
  • 性能监控:页面加载时间、首屏渲染、资源加载耗时、FCP/LCP/FID 等 Web Vitals 指标
  • 错误监控:JS 错误、Promise 异常、资源加载失败、接口报错
  • 用户行为:PV(页面浏览)、UV(独立访客)、页面停留时长、页面跳转路径
  • 页面生命周期:页面进入、页面离开、页面可见性变化

特点

  • 数据量大、频率高(每个用户每次访问都会产生)
  • 对实时性要求高(需要快速发现线上问题)
  • 数据相对简单(主要是时间戳、URL、错误信息等)
  • 页面关闭时也必须上报(如页面停留时长、离开事件)
2. 业务埋点类(中频、结构化、业务关联强)
  • 转化漏斗:注册流程、购买流程、表单提交各步骤的转化率
  • 功能使用:按钮点击、功能模块使用频率、特定功能的使用路径
  • 用户画像:用户偏好、行为标签、兴趣分类
  • A/B 测试:不同版本的功能效果对比、灰度发布数据收集

特点

  • 数据量中等(只在特定操作时触发)
  • 数据结构复杂(包含业务上下文、用户状态、操作详情)
  • 需要关联用户身份(用于用户画像和个性化推荐)
  • 对安全性要求较高(可能包含敏感业务数据)
3. 敏感数据上报类(低频、高安全、强防刷)
  • 支付行为:支付金额、支付方式、订单信息
  • 账户操作:登录、注册、密码修改、实名认证
  • 资金操作:充值、提现、转账
  • 权限操作:角色变更、权限授予、敏感数据访问

特点

  • 数据量小但价值高(每条数据都很重要)
  • 必须关联用户身份(用于审计和风控)
  • 安全性要求极高(不能暴露在 URL 中)
  • 需要强防刷机制(防止恶意刷量)
4. 数据分析类(大数据量、离线处理)
  • 热力图:用户点击位置、滚动深度、鼠标轨迹
  • 会话录制:用户操作回放、DOM 变化记录
  • 完整表单数据:用户填写的完整表单内容(用于分析流失原因)
  • 批量日志:前端日志批量上报、离线数据同步

特点

  • 单次数据量大(可能包含大量 DOM 信息或用户操作序列)
  • 对实时性要求低(可以批量上报、离线处理)
  • 需要压缩和优化(避免影响用户体验)
  • 可能需要采样(不是所有用户都需要录制)

二、产品形态分类

1. Web 浏览器应用
  • PC 网站:传统桌面浏览器访问的网站(Chrome、Firefox、Safari、Edge)
  • H5 移动站:移动浏览器访问的响应式网站或移动版网站
  • 单页应用(SPA):Vue/React/Angular 构建的前后端分离应用
  • 多页应用(MPA):传统的服务端渲染应用(SSR)

技术特点

  • ✅ 完整的浏览器 Cookie 机制
  • ✅ 支持 <img> 标签自动携带同源 Cookie
  • ✅ 支持 beforeunload 事件(页面关闭前上报)
  • ✅ 同源策略下无需额外配置
2. 原生 APP
  • iOS APP:Swift/Objective-C 开发的原生应用
  • Android APP:Kotlin/Java 开发的原生应用

技术特点

  • ❌ 无浏览器 Cookie 机制
  • ❌ 使用 Token(存储在 Keychain/SharedPreferences)
  • ❌ 图片加载无法自定义请求头
  • ✅ 网络请求完全可控(可自定义所有请求头)
3. 混合 APP
  • React Native:使用 JS 开发,部分原生能力
  • Flutter:使用 Dart 开发,自绘 UI
  • Cordova/Ionic:WebView + 原生插件
  • uni-app:跨平台开发框架

技术特点

  • ⚠️ WebView 中有 Cookie,但与原生层隔离
  • ⚠️ 需要 JSBridge 同步用户信息
  • ⚠️ 跨域和 Cookie 策略更严格
  • ✅ 可以通过原生能力发送网络请求
4. 小程序
  • 微信小程序:微信生态内的轻应用
  • 支付宝小程序:支付宝生态内的轻应用
  • 抖音小程序:抖音生态内的轻应用
  • 百度小程序:百度 APP 内的轻应用

技术特点

  • ❌ 无浏览器 Cookie 机制
  • ❌ 使用独立的 storage 存储
  • <image> 组件无法自定义请求头
  • wx.request 可以自定义请求头
5. 桌面应用
  • Electron:基于 Chromium 的桌面应用(如 VS Code、Slack)
  • Tauri:基于 WebView 的轻量级桌面应用

技术特点

  • ✅ 完整的浏览器环境(Electron)
  • ✅ 支持 Cookie 和本地存储
  • ✅ 可以使用 Node.js 能力发送请求
6. 后台管理系统
  • 内部管理后台:企业内部使用的管理系统
  • SaaS 平台:多租户的云端管理平台

技术特点

  • ✅ 通常是 Web 浏览器环境
  • ✅ 用户身份明确(登录后才能访问)
  • ⚠️ 可能需要更严格的权限控制和审计日志

三、技术决策矩阵

根据产品形态 × 使用场景,以下是推荐的技术方案:

产品形态 ↓ / 使用场景 →前端监控类业务埋点类敏感数据上报类数据分析类
Web 浏览器(PC/H5)GIF 埋点
自动带 Cookie
页面关闭不丢数据
⚠️ GIF 或 POST
简单业务用 GIF
复杂业务用 POST
POST 接口
参数放 Body
支持复杂签名
POST 接口
支持大数据量
可批量压缩
原生 APP(iOS/Android)POST 接口
请求头传 Token
统一架构
POST 接口
请求头传 Token
数据结构化
POST 接口
请求头传 Token
高安全性
POST 接口
支持大数据量
可离线缓存
混合 APP(RN/Flutter)POST 接口
避免 Cookie 同步
统一架构
POST 接口
请求头传 Token
跨平台一致
POST 接口
请求头传 Token
高安全性
POST 接口
支持大数据量
可离线缓存
小程序(微信/支付宝)POST 接口
wx.request 传 Token
无 Cookie 机制
POST 接口
请求头传 Token
数据结构化
POST 接口
请求头传 Token
高安全性
POST 接口
支持大数据量
可批量上报
Electron 桌面应用GIF 埋点
完整浏览器环境
也可用 Node.js
⚠️ GIF 或 POST
简单业务用 GIF
复杂业务用 POST
POST 接口
可用 Node.js
高安全性
POST 接口
可用 Node.js
支持大数据量
后台管理系统GIF 埋点
自动带 Cookie
实现简单
POST 接口
需要审计日志
权限控制
POST 接口
必须审计
高安全性
POST 接口
操作日志
可追溯

02-infographic-decision-matrix.png

图例说明:

  • 推荐方案:最适合该场景的技术选型
  • ⚠️ 可选方案:根据具体需求灵活选择

四、典型场景推荐

场景 1:纯 Web 电商网站(PC + H5)

需求:监控页面性能、用户行为、商品点击、加购、下单流程

推荐方案

  • 前端监控:GIF 埋点(PV、UV、页面停留、错误日志)
  • 商品曝光/点击:GIF 埋点(高频、轻量)
  • 加购/下单:POST 接口(业务数据复杂、需要防刷)

理由:充分利用浏览器 Cookie 机制,高频监控用 GIF,关键业务用 POST。


场景 2:跨平台电商 APP(iOS + Android + 小程序)

需求:统一的埋点体系,支持用户行为分析、转化漏斗、A/B 测试

推荐方案

  • 所有埋点统一使用 POST 接口
  • 请求头传递 JWT TokenAuthorization: Bearer <token>
  • 后端统一处理:一套接口服务所有平台

理由:APP 和小程序无 Cookie 机制,统一架构降低维护成本。


场景 3:SaaS 后台管理系统(纯 Web)

需求:用户操作审计、权限变更日志、敏感数据访问记录

推荐方案

  • 页面访问监控:GIF 埋点(自动带 Cookie)
  • 操作审计日志:POST 接口(需要完整的操作上下文)
  • 敏感操作:POST 接口 + 数字签名(防篡改)

理由:监控用 GIF 简化实现,审计日志必须用 POST 确保数据完整性。


场景 4:混合 APP(WebView + 原生)

需求:WebView 页面和原生页面的埋点统一管理

推荐方案

  • 统一使用 POST 接口
  • WebView 通过 JSBridge 获取原生 Token
  • 原生页面直接使用本地 Token

理由:避免 WebView Cookie 与原生 Token 的同步问题,统一架构。


场景 5:Electron 桌面应用

需求:用户行为分析、崩溃日志、性能监控

推荐方案

  • 渲染进程(Web):GIF 埋点(利用浏览器环境)
  • 主进程(Node.js):直接使用 HTTP 库发送 POST 请求
  • 崩溃日志:主进程捕获后通过 POST 上报

理由:充分利用 Electron 的双进程架构,渲染进程用 GIF,主进程用 Node.js。


五、快速决策流程图

开始
  ↓
是否为 Web 浏览器环境?
  ├─ 是 → 是否为高频监控类埋点?
  │        ├─ 是 → 使用 GIF 埋点 ✅
  │        └─ 否 → 是否包含敏感数据?
  │                 ├─ 是 → 使用 POST 接口 ✅
  │                 └─ 否 → 数据量是否很大?
  │                          ├─ 是 → 使用 POST 接口 ✅
  │                          └─ 否 → 使用 GIF 埋点 ✅
  │
  └─ 否 → 是否为 APP/小程序?
           ├─ 是 → 统一使用 POST 接口 + Authorization 头 ✅
           └─ 否 → 是否为 Electron?
                    ├─ 是 → 渲染进程用 GIF,主进程用 POST ✅
                    └─ 否 → 根据具体环境评估

05-infographic-decision-flowchart.png


⚠️ GIF 方案在 APP/小程序场景的重要局限

03-infographic-cookie-vs-token-limitation.png

核心问题:无法自动获取用户身份

GIF 埋点方案的最大优势是"浏览器自动携带 Cookie",但这个优势仅限于 Web 浏览器环境。在以下场景中,GIF 方案会失效:

1. 原生 APP(iOS/Android)

问题:

  • 原生 APP 中的 HTTP 请求(包括图片加载)不会自动携带浏览器 Cookie
  • APP 通常使用 Token(存储在本地 SharedPreferences/Keychain)进行身份认证
  • <img> 标签或原生 ImageView 加载图片时,无法自定义请求头(无法传递 Authorization Token)

结果:

// 后端代码
Long userId = (Long) session.getAttribute("userId");  // ❌ 永远为 null
String token = request.getHeader("Authorization");     // ❌ 拿不到 Token
2. 混合 APP(WebView)

问题:

  • WebView 中的 Cookie 与原生 APP 的 Token 体系是隔离
  • 即使 WebView 设置了 Cookie,也需要手动同步原生层的用户信息
  • 跨域场景下,WebView 的 Cookie 策略更加严格

结果:

  • 如果用户在原生页面登录,WebView 中的 GIF 请求拿不到用户信息
  • 需要额外的 JSBridge 通信来同步用户标识
3. 微信小程序 / 支付宝小程序

问题:

  • 小程序没有浏览器 Cookie 机制
  • 小程序使用 wx.requestmy.request 发起网络请求,使用独立的 storage 存储数据
  • <image> 组件加载图片时,无法携带自定义请求头

结果:

// 小程序代码
<image src="https://api.example.com/track?event=page_view" />
// ❌ 后端无法获取用户信息,因为没有 Cookie,也无法传 Token

技术原因分析

环境Cookie 支持自定义请求头用户身份获取方式
Web 浏览器✅ 自动携带同源 Cookie<img> 标签不支持后端从 Cookie/Session 获取
原生 APP❌ 无 Cookie 机制❌ ImageView 不支持需在 URL 参数或请求体传递 Token
WebView⚠️ 有 Cookie 但与原生隔离<img> 标签不支持需 JSBridge 同步或 URL 传参
小程序❌ 无 Cookie 机制<image> 组件不支持需在 URL 参数传递 Token

关键限制:

  • <img> 标签、原生 ImageView、小程序 <image> 组件在加载图片时,都无法自定义 HTTP 请求头
  • 这意味着无法通过 Authorization: Bearer <token> 的标准方式传递用户身份

针对 APP/小程序的解决方案

方案 A:URL 参数传递用户标识(不推荐)

实现方式:

// 小程序示例
const userId = wx.getStorageSync('userId');
const token = wx.getStorageSync('token');

// 在 URL 中拼接用户信息
const trackUrl = `https://api.example.com/track?event=page_view&userId=${userId}&token=${token}`;

// 使用 image 组件加载
<image src="{{trackUrl}}" style="width:1px;height:1px;" />

后端处理:

@GetMapping("/track")
public ResponseEntity<byte[]> track(
    @RequestParam String userId,
    @RequestParam String token,
    @RequestParam Map<String, String> params
) {
    // 验证 Token 有效性
    if (!tokenService.validate(userId, token)) {
        return ResponseEntity.status(403).body(TRANSPARENT_GIF);
    }
    
    // 处理埋点...
}

缺点:

  • 安全性极差:Token 暴露在 URL 中,会被记录到服务器日志、代理日志、浏览器历史
  • 容易被劫持:URL 参数容易被中间人攻击截获
  • 不符合安全规范:违反 OAuth 2.0 和 JWT 的最佳实践

方案 B:使用 POST 接口 + 自定义请求头(强烈推荐)

实现方式:

小程序示例:

// utils/tracker.js
class MiniProgramTracker {
  track(event, data = {}) {
    const token = wx.getStorageSync('token');
    
    wx.request({
      url: 'https://api.example.com/api/track/post',
      method: 'POST',
      header: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`  // ✅ Token 放在请求头
      },
      data: {
        event,
        timestamp: Date.now(),
        ...data
      },
      success: () => console.log('埋点成功'),
      fail: (err) => console.error('埋点失败', err)
    });
  }
}

export default new MiniProgramTracker();

原生 APP 示例(Android):

// TrackUtil.java
public class TrackUtil {
    public static void track(String event, JSONObject data) {
        String token = SharedPreferencesUtil.getToken();
        
        OkHttpClient client = new OkHttpClient();
        
        JSONObject payload = new JSONObject();
        payload.put("event", event);
        payload.put("timestamp", System.currentTimeMillis());
        payload.put("data", data);
        
        RequestBody body = RequestBody.create(
            payload.toString(),
            MediaType.parse("application/json")
        );
        
        Request request = new Request.Builder()
            .url("https://api.example.com/api/track/post")
            .addHeader("Authorization", "Bearer " + token)  // ✅ Token 放在请求头
            .post(body)
            .build();
        
        client.newCall(request).enqueue(new Callback() {
            @Override
            public void onResponse(Call call, Response response) {
                Log.d("Track", "埋点成功");
            }
            
            @Override
            public void onFailure(Call call, IOException e) {
                Log.e("Track", "埋点失败", e);
            }
        });
    }
}

后端处理(Spring Boot):

@PostMapping("/api/track/post")
public Map<String, Object> trackPost(
    @RequestBody Map<String, Object> payload,
    @RequestHeader("Authorization") String authHeader
) {
    try {
        // 从 Authorization 头提取 Token
        String token = authHeader.replace("Bearer ", "");
        
        // 验证 Token 并获取用户信息
        Long userId = jwtService.getUserIdFromToken(token);
        String username = jwtService.getUsernameFromToken(token);
        
        // 构建埋点数据
        payload.put("userId", userId);
        payload.put("username", username);
        
        // 异步处理
        trackService.asyncTrack(payload);
        
        return Map.of("code", 200, "message", "success");
        
    } catch (Exception e) {
        log.error("埋点处理失败", e);
        return Map.of("code", 500, "message", "error");
    }
}

优势:

  • 安全性高:Token 在请求头中传输,不会被记录到 URL 日志
  • 符合标准:遵循 RESTful API 和 OAuth 2.0 最佳实践
  • 灵活性强:可以传递复杂的 JSON 数据结构
  • 统一架构:Web、APP、小程序使用同一套后端接口

最终建议

场景推荐方案原因
Web 浏览器(PC/H5)GIF 埋点自动携带 Cookie,实现简单,性能最优
原生 APPPOST 接口 + Authorization 头无 Cookie 机制,必须通过请求头传递 Token
混合 APP(WebView)POST 接口 + Authorization 头避免 Cookie 同步问题,统一使用 Token
微信/支付宝小程序POST 接口 + Authorization 头无 Cookie 机制,必须通过请求头传递 Token

核心原则:

  • Web 浏览器:优先使用 GIF 埋点,利用浏览器的 Cookie 机制
  • 非浏览器环境:统一使用 POST 接口 + JWT Token,确保安全性和一致性

最终选型结论

1. Web 浏览器环境(PC/H5)

做前端监控、PV、UV、错误日志、页面离开事件

  • 优先用 GIF 埋点
  • 理由:同源自动带 Cookie,后端能无缝拿到当前登录用户;页面关闭也能保证上报成功;实现极简,兼容性无敌(从古董 IE 到现代浏览器通吃)。

做重要业务埋点、敏感数据上报、需要强防刷、大数据量传输

  • 用 RESTful POST + fetch(keepalive)
  • 理由:支持 POST,参数放在请求体(Body)中,不会暴露在 URL 里;可以加复杂的 Token 和签名机制,防刷能力更强;且没有 URL 长度限制,适合传输大量结构化数据。
2. APP/小程序环境

所有埋点场景(包括监控、业务埋点)

  • 统一使用 POST 接口 + Authorization 请求头
  • 理由
    • APP 和小程序没有 Cookie 机制,GIF 方案无法自动获取用户身份
    • <img> 标签和原生图片组件无法自定义请求头,无法传递 Token
    • POST 接口可以在请求头中安全传递 JWT Token,符合安全规范
    • 统一的接口设计,便于跨平台维护

针对不同场景的最佳组合(生产标准)

Web 浏览器项目(Vue + Spring Boot)

推荐采用 ”GIF 为主,Fetch 兜底” 的组合拳:

  • 常规监控与身份关联:前端统一使用 GIF 1x1 埋点。利用浏览器特性自动携带 Cookie,让你的 Spring Boot 后端能够零成本、高安全地自动提取当前登录用户信息。
  • 后端架构:无论前端发来的是图片请求还是普通接口,后端统一做成异步非阻塞。接收到请求后,迅速将数据抛入消息队列(MQ)进行削峰填谷,然后立刻返回(图片或 200 状态码),确保不影响主业务接口的性能。
跨平台项目(Web + APP + 小程序)

推荐采用 ”统一 POST 接口” 方案:

  • 所有平台统一使用 POST 接口:Web 端虽然可以用 GIF,但为了保持架构一致性,建议也使用 POST 接口
  • 统一的身份认证:所有平台都通过 Authorization: Bearer <token> 请求头传递 JWT Token
  • 后端统一处理:一套接口服务所有平台,降低维护成本

这也是目前绝大多数大厂在做前端监控系统时的通用标准方案。


完整实现样例

04-infographic-architecture-flow.png

方案一:GIF 1x1 图片埋点(推荐用于常规监控)

前端实现(Vue 3)

1. 创建埋点工具类 tracker.js

// src/utils/tracker.js

class GifTracker {
  constructor(baseUrl = '/api/track') {
    this.baseUrl = baseUrl;
    this.queue = [];
    this.isProcessing = false;
  }

  /**
   * 发送埋点数据
   * @param {string} eventType - 事件类型(如 'page_view', 'click', 'error')
   * @param {object} data - 埋点数据
   */
  track(eventType, data = {}) {
    const params = {
      event: eventType,
      timestamp: Date.now(),
      url: window.location.href,
      referrer: document.referrer,
      ...data
    };

    this._sendGif(params);
  }

  /**
   * 通过 GIF 图片发送数据
   */
  _sendGif(params) {
    const queryString = this._buildQueryString(params);
    const img = new Image(1, 1);
    
    // 图片加载成功或失败都不影响业务
    img.onload = img.onerror = () => {
      img = null;
    };
    
    // 发送请求(浏览器会自动带上 Cookie)
    img.src = `${this.baseUrl}?${queryString}`;
  }

  /**
   * 构建查询字符串
   */
  _buildQueryString(params) {
    return Object.keys(params)
      .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(params[key]))}`)
      .join('&');
  }

  /**
   * 页面浏览埋点
   */
  trackPageView(pageName) {
    this.track('page_view', {
      page: pageName,
      title: document.title
    });
  }

  /**
   * 点击事件埋点
   */
  trackClick(elementId, elementName, extraData = {}) {
    this.track('click', {
      element_id: elementId,
      element_name: elementName,
      ...extraData
    });
  }

  /**
   * 错误埋点
   */
  trackError(error, errorInfo = {}) {
    this.track('error', {
      message: error.message,
      stack: error.stack,
      ...errorInfo
    });
  }

  /**
   * 页面离开埋点(在 beforeunload 时调用)
   */
  trackPageLeave(duration) {
    this.track('page_leave', {
      duration,
      page: window.location.pathname
    });
  }
}

// 导出单例
export default new GifTracker();

2. 在 Vue 应用中集成

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import tracker from './utils/tracker';

const app = createApp(App);

// 全局注册埋点工具
app.config.globalProperties.$tracker = tracker;

// 路由切换时自动上报 PV
router.afterEach((to) => {
  tracker.trackPageView(to.path);
});

// 页面加载时记录进入时间
let pageEnterTime = Date.now();

// 页面离开时上报停留时长
window.addEventListener('beforeunload', () => {
  const duration = Date.now() - pageEnterTime;
  tracker.trackPageLeave(duration);
});

// 全局错误捕获
app.config.errorHandler = (err, instance, info) => {
  tracker.trackError(err, { info });
};

app.use(router).mount('#app');

3. 在组件中使用

<!-- src/views/ProductDetail.vue -->
<template>
  <div class="product-detail">
    <h1>{{ product.name }}</h1>
    <button @click="handleAddToCart">加入购物车</button>
    <button @click="handleBuyNow">立即购买</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();
const product = ref({ name: '商品A', id: 123 });

// 页面加载时上报商品曝光
onMounted(() => {
  window.$tracker.track('product_view', {
    product_id: product.value.id,
    product_name: product.value.name,
    source: route.query.from || 'direct'
  });
});

// 加入购物车埋点
const handleAddToCart = () => {
  window.$tracker.trackClick('btn-add-cart', '加入购物车', {
    product_id: product.value.id,
    action: 'add_to_cart'
  });
  
  // 业务逻辑...
};

// 立即购买埋点
const handleBuyNow = () => {
  window.$tracker.trackClick('btn-buy-now', '立即购买', {
    product_id: product.value.id,
    action: 'buy_now'
  });
  
  // 业务逻辑...
};
</script>
后端实现(Spring Boot)

1. 创建埋点控制器

// TrackController.java
package com.example.tracker.controller;

import com.example.tracker.service.TrackService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/track")
public class TrackController {

    @Autowired
    private TrackService trackService;

    /**
     * GIF 埋点接口
     * 浏览器会自动带上 Cookie,后端可直接从 Session 获取用户信息
     */
    @GetMapping
    public ResponseEntity<byte[]> track(
            @RequestParam Map<String, String> params,
            HttpServletRequest request,
            HttpSession session
    ) {
        try {
            // 从 Session 中获取当前登录用户(Spring Security 或自定义登录都可以)
            Long userId = (Long) session.getAttribute("userId");
            String username = (String) session.getAttribute("username");

            // 获取客户端信息
            String ip = getClientIp(request);
            String userAgent = request.getHeader("User-Agent");

            // 构建埋点数据
            Map<String, Object> trackData = new HashMap<>();
            trackData.put("userId", userId);
            trackData.put("username", username);
            trackData.put("ip", ip);
            trackData.put("userAgent", userAgent);
            trackData.putAll(params);

            // 异步处理埋点数据(投递到 MQ)
            trackService.asyncTrack(trackData);

            // 立即返回 1x1 透明 GIF
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_GIF_VALUE)
                    .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate")
                    .header(HttpHeaders.PRAGMA, "no-cache")
                    .header(HttpHeaders.EXPIRES, "0")
                    .body(TRANSPARENT_GIF);

        } catch (Exception e) {
            log.error("埋点处理失败", e);
            // 即使失败也返回 GIF,不影响前端
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_GIF_VALUE)
                    .body(TRANSPARENT_GIF);
        }
    }

    /**
     * 1x1 透明 GIF 的字节数组
     */
    private static final byte[] TRANSPARENT_GIF = new byte[]{
            0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00,
            0x01, 0x00, (byte) 0x80, 0x00, 0x00, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF,
            0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x01, 0x00,
            0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00,
            0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44,
            0x01, 0x00, 0x3B
    };

    /**
     * 获取客户端真实 IP
     */
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

2. 创建异步处理服务

// TrackService.java
package com.example.tracker.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.Map;

@Slf4j
@Service
public class TrackService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    private static final String TRACK_TOPIC = "user-track-events";

    /**
     * 异步处理埋点数据
     * 使用 @Async 注解确保不阻塞主线程
     */
    @Async("trackExecutor")
    public void asyncTrack(Map<String, Object> trackData) {
        try {
            String jsonData = objectMapper.writeValueAsString(trackData);

            // 发送到 RocketMQ
            rocketMQTemplate.convertAndSend(TRACK_TOPIC, jsonData);

            log.debug("埋点数据已投递到 RocketMQ: {}", jsonData);

        } catch (Exception e) {
            log.error("埋点数据投递失败", e);
            // 可以考虑写入本地日志文件作为兜底
        }
    }
}

3. 配置异步线程池

// AsyncConfig.java
package com.example.tracker.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("trackExecutor")
    public Executor trackExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("track-");
        executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

方案二:RESTful POST 接口(用于敏感数据上报)

前端实现(Vue 3)

1. 创建 Fetch 埋点工具类

// src/utils/fetchTracker.js

class FetchTracker {
  constructor(baseUrl = '/api/track/post') {
    this.baseUrl = baseUrl;
  }

  /**
   * 发送埋点数据(POST 方式)
   * @param {string} eventType - 事件类型
   * @param {object} data - 埋点数据
   */
  async track(eventType, data = {}) {
    const payload = {
      event: eventType,
      timestamp: Date.now(),
      url: window.location.href,
      referrer: document.referrer,
      ...data
    };

    try {
      await fetch(this.baseUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
        credentials: 'include', // 重要:携带 Cookie
        keepalive: true // 页面关闭时也尽量发送
      });
    } catch (error) {
      console.error('埋点发送失败:', error);
    }
  }

  /**
   * 敏感操作埋点(如支付、提现)
   */
  trackSensitiveAction(action, amount, extraData = {}) {
    this.track('sensitive_action', {
      action,
      amount,
      ...extraData
    });
  }

  /**
   * 大数据量埋点(如完整表单数据)
   */
  trackFormSubmit(formName, formData) {
    this.track('form_submit', {
      form_name: formName,
      form_data: formData
    });
  }
}

export default new FetchTracker();

2. 在组件中使用

<!-- src/views/PaymentPage.vue -->
<template>
  <div class="payment-page">
    <h1>支付页面</h1>
    <button @click="handlePay">确认支付</button>
  </div>
</template>

<script setup>
import fetchTracker from '@/utils/fetchTracker';

const handlePay = async () => {
  // 支付前上报敏感操作
  await fetchTracker.trackSensitiveAction('payment', 99.99, {
    order_id: 'ORDER123456',
    payment_method: 'alipay'
  });

  // 执行支付逻辑...
};
</script>
后端实现(Spring Boot)
// TrackPostController.java
package com.example.tracker.controller;

import com.example.tracker.service.TrackService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/track/post")
public class TrackPostController {

    @Autowired
    private TrackService trackService;

    /**
     * POST 埋点接口
     * 用于敏感数据上报
     */
    @PostMapping
    public Map<String, Object> trackPost(
            @RequestBody Map<String, Object> payload,
            HttpServletRequest request,
            HttpSession session
    ) {
        try {
            // 从 Session 获取用户信息
            Long userId = (Long) session.getAttribute("userId");
            String username = (String) session.getAttribute("username");

            // 获取客户端信息
            String ip = getClientIp(request);
            String userAgent = request.getHeader("User-Agent");

            // 构建埋点数据
            payload.put("userId", userId);
            payload.put("username", username);
            payload.put("ip", ip);
            payload.put("userAgent", userAgent);

            // 异步处理
            trackService.asyncTrack(payload);

            return Map.of("code", 200, "message", "success");

        } catch (Exception e) {
            log.error("POST 埋点处理失败", e);
            return Map.of("code", 500, "message", "error");
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

使用场景对照表

场景推荐方案示例代码
页面浏览(PV)GIF 埋点tracker.trackPageView('/home')
页面离开GIF 埋点tracker.trackPageLeave(duration)
按钮点击GIF 埋点tracker.trackClick('btn-id', '按钮名')
错误日志GIF 埋点tracker.trackError(error)
支付操作POST 接口fetchTracker.trackSensitiveAction('payment', 99.99)
表单提交POST 接口fetchTracker.trackFormSubmit('register', formData)
大数据上报POST 接口fetchTracker.track('bulk_data', largeObject)

生产环境注意事项

1. RocketMQ 依赖与配置

Maven 依赖:

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.2.3</version>
</dependency>

application.yml 配置:

rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    group: track-producer-group
    send-message-timeout: 3000
    retry-times-when-send-failed: 2

2. Cookie 配置

确保后端 Session Cookie 配置正确:

# application.yml
server:
  servlet:
    session:
      cookie:
        http-only: true
        secure: false  # 生产环境改为 true(需要 HTTPS)
        same-site: lax  # 防止 CSRF 攻击
        path: /

3. CORS 配置(如果前后端分离)

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://your-frontend-domain.com")
                .allowedMethods("GET", "POST")
                .allowCredentials(true); // 重要:允许携带 Cookie
    }
}

4. 防刷机制

// 在 TrackController 中添加防刷逻辑
@Autowired
private RedisTemplate<String, String> redisTemplate;

private boolean isRateLimited(String userId, String ip) {
    String key = "track:limit:" + userId + ":" + ip;
    Long count = redisTemplate.opsForValue().increment(key);
    
    if (count == 1) {
        redisTemplate.expire(key, 1, TimeUnit.MINUTES);
    }
    
    // 每分钟最多 100 次请求
    return count > 100;
}

5. 数据消费端(RocketMQ Consumer 示例)

@Component
@Slf4j
@RocketMQMessageListener(
    topic = "user-track-events",
    consumerGroup = "track-consumer-group"
)
public class TrackConsumer implements RocketMQListener<String> {

    @Autowired
    private TrackRepository trackRepository;

    @Override
    public void onMessage(String message) {
        try {
            ObjectMapper mapper = new ObjectMapper();
            Map<String, Object> trackData = mapper.readValue(message, Map.class);

            TrackEvent event = new TrackEvent();
            event.setUserId((Long) trackData.get("userId"));
            event.setEvent((String) trackData.get("event"));
            event.setTimestamp((Long) trackData.get("timestamp"));

            trackRepository.save(event);

            log.info("埋点数据已入库: {}", message);

        } catch (Exception e) {
            log.error("埋点数据消费失败", e);
        }
    }
}

总结

  • GIF 埋点:适合高频、轻量、需要自动携带用户身份的场景,是前端监控的首选方案
  • POST 接口:适合敏感数据、大数据量、需要强防刷的业务埋点场景
  • 组合使用:在实际生产中,两种方案配合使用,既保证了监控的完整性,又满足了业务的安全性需求