我的文档库

前端埋点 SDK 设计与实现:从事件采集到可靠上报

Pulsar 是浏览器埋点 SDK,业务通过`track()`上报事件。自动采集设备、会话、环境信息,内存队列批量上报,依托 sendBeacon/fetch‑keepalive 防页面关闭丢数,支持重试,AES‑GCM 加密 + HMAC 签名,兼容 SSR,可扩展持久化队列。

在 Web 应用中,用户行为数据通常需要经过“事件采集 → 数据加工 → 批量聚合 → 加密签名 → 网络传输 → 服务端入库”这一完整链路。

如果直接在业务代码中使用 fetch 上报事件,很快就会遇到一系列工程问题:

  • 每个业务页面都需要重复编写上报逻辑
  • 页面关闭时请求容易丢失
  • 高频事件产生大量 HTTP 请求
  • 初始化设备信息可能阻塞业务代码
  • SSR 环境下访问 window / navigator 导致异常
  • 网络异常时缺少统一的重试策略
  • 埋点数据在传输过程中需要额外的完整性保护
  • 不同业务产生的事件结构难以统一

因此,可以将这些能力抽象为一个独立的前端埋点 SDK。

本文介绍一个浏览器端埋点 SDK 的设计思路,包括设备标识、Session、事件队列、批量上报、页面关闭可靠性、数据加密、签名、重试以及 SSR 兼容等核心设计。


一、整体设计目标

这个 SDK 的核心目标不是让业务代码“知道怎么发送数据”,而是将数据采集与传输完全封装起来。

业务侧只需要:

pulsar.track('button_click', {
  button: 'login',
})

SDK 负责后续的:

事件创建

公共参数注入

设备 / Session 信息补充

进入内存队列

达到批量阈值 ─────┐

定时刷新 ──────────┤

              数据序列化

              AES-GCM 加密

               HMAC 签名

        ┌──────────┴──────────┐
        ↓                     ↓
   sendBeacon              fetch
        ↓                     ↓
        └──────────┬──────────┘

                服务端

设计目标主要包括:

  1. 业务无感
  2. 初始化非阻塞
  3. 批量传输
  4. 页面关闭尽可能不丢数据
  5. 网络异常自动重试
  6. 浏览器端数据保护
  7. SSR 安全运行
  8. 可扩展的事件模型

二、整体架构

SDK 可以拆分为几个职责明确的模块:

┌────────────────────────────────────────────┐
│                  Pulsar                    │
│              SDK 对外 API                  │
│                                            │
│  track() / 初始化 / 事件组装 / 生命周期管理 │
└───────────────────┬────────────────────────┘

        ┌───────────┼───────────┐
        ↓           ↓           ↓
   Fingerprint   Device      Tracker
     Manager     Manager       │

                          Event Queue

                  ┌────────────┼────────────┐
                  ↓            ↓            ↓
               Timer        Batch Size    Page Hide
                  │            │            │
                  └────────────┼────────────┘

                           Crypto

                     AES-GCM + HMAC

                           Transport
                         ↙           ↘
                  sendBeacon        fetch

                                   Retry

可以将 SDK 的职责分为五个层次:

层级职责
API 层对业务暴露 track() 等接口
数据层Session、设备信息、页面信息、业务参数
队列层事件缓存、批量聚合、刷新
安全层加密、签名、时间戳
传输层Beacon、Fetch、Keepalive、重试

这种拆分能够避免将所有逻辑集中在 SDK 主类中,也方便后续替换设备标识、加密算法或者传输方式。


三、稳定设备标识

3.1 为什么需要设备标识

埋点数据通常不仅需要分析单次事件,还需要分析:

设备

Session

页面

事件

例如:

设备 A
├── Session 001
│   ├── page_view
│   ├── click
│   └── purchase

└── Session 002
    ├── page_view
    └── click

因此需要一个能够跨 Session 使用的设备标识。


3.2 FingerprintJS

SDK 使用 FingerprintJS 生成浏览器设备标识。

FingerprintJS 会综合浏览器环境中的多个特征,例如:

  • Canvas
  • WebGL
  • AudioContext
  • 字体
  • 屏幕信息
  • 浏览器环境
  • 操作系统环境

最终生成一个稳定的 visitor identifier。

SDK 可以进一步将该标识转换为业务侧使用的 UUID。

Browser

   ├── Canvas
   ├── WebGL
   ├── Audio
   ├── Fonts
   ├── Screen
   └── Browser

  FingerprintJS

   visitorId

    deviceId

3.3 降级策略

设备指纹初始化不能成为 SDK 上报链路的单点故障。

因此采用:

FingerprintJS

      ├── 成功 → 使用稳定 deviceId

      └── 失败 / 超时 → uuidv4()

原设计中设置了 30 秒超时。

需要注意的是:

UUID 降级能够保证事件继续上报,但无法保证跨 Session 的设备关联能力。

也就是说:

正常情况:

Session A ─┐
Session B ─┼── deviceId-123
Session C ─┘


降级情况:

Session A ── uuid-A
Session B ── uuid-B
Session C ── uuid-C

因此,设备标识应该被视为“归因能力”,而不是事件上报的前置条件。


四、Session 设计

设备标识用于跨 Session 关联,而 session_id 用于描述一次页面生命周期内的行为。

SDK 在实例创建时生成:

const sessionId = uuidv4()

随后该实例产生的所有事件共享同一个 session_id

例如:

session_id = 8e1...

page_view

button_click

tab_change

game_start

page_leave

服务端可以基于:

device_id
session_id
timestamp

重建用户行为路径。


4.1 为什么不为每个事件重新生成 Session

如果每次 track() 都创建新的 Session:

event A → session-1
event B → session-2
event C → session-3

那么事件之间就失去了天然的会话关系。

因此 Session 应该属于 SDK 实例生命周期,而不是单个事件。


五、初始化设计:同步构造,异步准备

SDK 初始化通常依赖异步操作,例如:

  • FingerprintJS
  • 前置参数获取
  • 浏览器环境信息初始化

如果构造函数直接等待这些操作:

await fingerprint()
await getPreParams()

会让 SDK 初始化变成异步 API,并增加业务接入复杂度。

因此采用:

构造函数同步完成,内部初始化异步执行。

结构如下:

new Pulsar()

     ├── session_id 同步生成

     └── 异步初始化
           ├── FingerprintJS
           └── getPreParams()


                 ready

业务可以立即调用:

const pulsar = new Pulsar(config)

pulsar.track('page_view')

初始化完成之前产生的事件不会直接丢弃,而是暂存在 SDK 内部。


5.1 Ready 状态

SDK 使用:

this.preParams !== undefined

判断初始化是否完成。

这里需要区分:

undefined

和:

{}

因为:

undefined

表示初始化尚未完成,而:

{}

表示初始化已经完成,只是没有额外参数。

因此:

undefined → not ready

{}       → ready

这种状态设计可以避免空对象被误判为“初始化失败”。


六、事件数据模型

一次事件最终可以由三部分组成:

事件基础信息
+
SDK 自动采集信息
+
业务自定义参数

例如:

{
  "event": "button_click",
  "timestamp": 1720000000000,
  "session_id": "xxx",
  "device_id": "xxx",
  "properties": {
    "button": "login"
  }
}

SDK 自动补充的环境信息包括:

字段数据来源
page_pathwindow.location.pathname
urlwindow.location.href
deviceua-parser-js
browser_nameua-parser-js
browser_majorua-parser-js
browser_versionua-parser-js
os_nameua-parser-js
os_versionua-parser-js
languagenavigator.language
timezoneIntl.DateTimeFormat
screenscreen.width / height
memorynavigator.deviceMemory
cpunavigator.hardwareConcurrency
uanavigator.userAgent

七、SSR 兼容

浏览器埋点 SDK 最大的运行环境差异之一,就是 SSR。

以下对象在 Node.js 环境中不存在:

window
navigator
screen
document

如果模块加载阶段直接执行:

window.location.href

SSR 就可能直接抛出异常。

因此所有 DOM / BOM 相关访问都需要进行环境判断:

if (typeof window !== 'undefined') {
  // Browser
}

例如:

const pagePath =
  typeof window !== 'undefined'
    ? window.location.pathname
    : ''

这样可以保证:

Browser

正常采集浏览器环境信息

SSR

安全降级为空值

这也意味着 SDK 可以被 Next.js 等 SSR 框架直接引用,而不要求业务层额外封装。


八、批量队列设计

如果每个事件都直接发 HTTP 请求:

click

HTTP

click

HTTP

scroll

HTTP

page_view

HTTP

会产生大量网络请求。

因此 SDK 使用内存队列:

track()

Event Queue

┌──────────────────────────────┐
│ event 1                      │
│ event 2                      │
│ event 3                      │
│ ...                          │
└──────────────────────────────┘

          flush()

          batch[]

九、双触发 Flush

队列刷新采用两种触发条件:

1. 容量触发

默认:

maxBatchSize = 200

当:

queue.length >= 200

立即发送。

2. 时间触发

默认:

flushInterval = 3000ms

即使队列没有达到 200 条,也会每 3 秒尝试刷新。

因此:

低流量场景
event → 等待 → 3s → flush


高流量场景
event × 200 → 立即 flush

这种设计同时兼顾了:

  • 请求数量
  • 数据实时性
  • 单次 Payload 大小

十、页面关闭时的数据可靠性

页面关闭是 Web 埋点最容易丢数据的场景。

例如:

用户点击按钮

fetch()

用户立即关闭页面

浏览器终止页面生命周期

请求可能被取消

因此 SDK 对页面生命周期进行了特殊处理。


10.1 生命周期监听

主要监听:

beforeunload
visibilitychange → hidden

当页面进入隐藏或即将关闭状态时:

flush()

立即发送剩余事件

十一、sendBeacon + fetch keepalive

页面关闭场景下,SDK 优先使用:

navigator.sendBeacon()

如果 sendBeacon 不可用或者返回失败,再降级到:

fetch(url, {
  keepalive: true
})

整体结构:

flush()


sendBeacon()

  ├── true → 完成

  └── false

     fetch()
     keepalive: true

11.1 为什么使用 sendBeacon

普通 fetch() 的生命周期依赖页面环境。

sendBeacon() 专门适用于:

  • 页面关闭
  • 页面隐藏
  • 页面跳转
  • 后台发送少量数据

因此非常适合分析类数据上报。


11.2 Payload 格式

Beacon 请求将签名信息放在 Body 中:

{
  "p": "<encrypted-payload>",
  "s": "<signature>",
  "t": "<timestamp>"
}

其中:

  • p:AES 加密后的数据
  • s:HMAC-SHA256 签名
  • t:客户端时间戳

Fetch 降级方案则可以将签名信息放入 Header:

X-Signature
X-Timestamp

十二、数据加密设计

SDK 使用浏览器原生 Web Crypto API 实现数据加密。

核心算法:

AES-256-GCM
+
HMAC-SHA256

加密过程:

events[]

JSON.stringify()

TextEncoder

生成 12 bytes nonce

AES-256-GCM

nonce + ciphertext

Base64

最终得到:

ciphertext_base64

十三、密钥派生

原方案使用一个基础 secret 派生两套密钥。

AES 密钥:

SHA-256(secret + ":aes")

HMAC 密钥:

SHA-256(secret)

结构:

                 secret

          ┌────────┴────────┐
          ↓                 ↓
   secret + ":aes"        secret
          ↓                 ↓
       SHA-256           SHA-256
          ↓                 ↓
      AES Key           HMAC Key

这样可以避免直接使用同一密钥同时承担加密和签名职责。


十四、AES-GCM 加密

AES-GCM 每次加密都生成随机 nonce:

const nonce = crypto.getRandomValues(
  new Uint8Array(12)
)

然后:

AES-GCM(
  key,
  nonce,
  plaintext
)

最终组合:

nonce + ciphertext

再进行 Base64 编码。

服务端收到数据后,可以根据相同的密钥进行解密。


十五、HMAC 完整性校验

仅仅加密数据并不能解决所有问题。

因此还需要对密文进行 HMAC 签名:

ciphertext_base64

HMAC-SHA256

signature

服务端收到:

payload
signature
timestamp

之后:

验证时间戳

重新计算 HMAC

比较 signature

验证通过

AES-GCM 解密

最终形成:

客户端

  ├── 加密 → Confidentiality

  └── 签名 → Integrity

十六、安全边界

这里需要特别说明一个容易被误解的问题:

浏览器端 SDK 中使用的 secret 并不是严格意义上的服务器私密密钥。

因为 JavaScript 最终需要运行在用户浏览器中,只要密钥参与浏览器端加密过程,就不能假设它对终端用户绝对不可见。

因此这里的加密主要用于:

  • 降低明文数据直接暴露的风险
  • 防止链路中间的简单篡改
  • 增加数据伪造成本
  • 与服务端建立统一的数据协议

它不应该被描述为“客户端无法获取密钥的端到端加密”。

如果系统需要真正的服务端机密性,应采用:

Browser

HTTPS

Ingestion Gateway

Server-side secret

Encryption / Verification

而不是将长期有效的服务器秘密直接暴露给浏览器。


十七、重试机制

网络环境始终是不可靠的。

一次请求失败并不意味着事件应该直接丢弃。

因此 SDK 对失败请求进行有限次数重试。

默认参数:

参数默认值
maxRetries3
retryBackoffBase1000ms
maxBackoff30000ms
fetchTimeout10000ms

使用指数退避:

第一次失败

等待 1s

第二次失败

等待 2s

第三次失败

等待 4s

即:

1s → 2s → 4s

同时设置最大退避时间:

backoff <= 30s

避免网络异常时产生高频请求。


十八、Fire and Forget

埋点系统通常不应该影响核心业务逻辑。

例如:

await pulsar.track(...)

如果业务必须等待埋点完成:

用户点击

等待埋点

网络请求

业务继续

那么埋点就可能影响页面交互。

因此 SDK 更适合采用:

业务代码

track()

进入内部队列

立即返回

SDK 后台处理

即:

Fire and Forget

业务只负责产生事件,SDK 自己负责后续传输。


十九、配置设计

SDK 对外暴露的核心配置可以保持简单:

interface MonitorConfig {
  endpoint: string

  secret: string

  getPreParams: () =>
    Promise<Record<string, string>>

  eventPrefix?: string
}

例如:

const pulsar = new Pulsar({
  endpoint: 'https://example.com/collect',

  secret: '******',

  getPreParams: async () => ({
    app_version: '1.0.0',
    channel: 'web',
  }),

  eventPrefix: 'web_',
})

其中:

  • endpoint:数据接收地址
  • secret:数据协议所需的密钥材料
  • getPreParams:获取动态公共参数
  • eventPrefix:统一事件名前缀

二十、完整数据链路

综合以上设计,一次事件完整生命周期如下:

                Business Code

                     │ track()

              ┌─────────────┐
              │   Pulsar    │
              └──────┬──────┘

              Event Assembly

          ┌──────────┼──────────┐
          ↓          ↓          ↓
      device_id  session_id  page info
          │          │          │
          └──────────┼──────────┘

                 Event Queue

              ┌──────┴──────┐
              ↓             ↓
        size >= 200      timer 3s
              │             │
              └──────┬──────┘

                  flush()

              JSON.stringify

                AES-256-GCM

                HMAC-SHA256

          ┌──────────┴──────────┐
          ↓                     ↓
      sendBeacon              fetch
                              keepalive
          │                     │
          └──────────┬──────────┘

                   Server

               Verify Signature

                 Decrypt

                 BI Storage

二十一、关键默认参数

SDK 默认参数可以集中管理:

const DEFAULT_CONFIG = {
  maxBatchSize: 200,

  flushInterval: 3000,

  maxRetries: 3,

  retryBackoffBase: 1000,

  maxBackoff: 30000,

  fetchTimeout: 10000,
}

这些参数并不是固定标准,而是需要根据实际业务流量、事件大小、网络环境进行调整。


二十二、可靠性与已知限制

任何浏览器端埋点 SDK 都无法保证 100% 数据不丢失。

当前方案仍然存在以下边界。

22.1 页面关闭期间正在重试的数据

重试队列目前保存在内存中。

因此:

请求失败

进入 retry queue

页面立即关闭

内存被释放

这一批数据可能无法继续重试。

对于分析类数据,这通常可以接受。

如果业务对数据完整性要求更高,可以进一步增加:

Memory Queue

localStorage / IndexedDB

Persistent Queue

从而实现跨页面生命周期的持久化重试。


22.2 FingerprintJS 失败

如果设备指纹服务失败或超时:

FingerprintJS

失败

uuidv4()

事件仍然可以继续上报,但跨 Session 的设备关联能力下降。


22.3 Web Crypto API 环境限制

Web Crypto API 中的部分能力要求安全上下文。

生产环境应该使用:

HTTPS

本地开发通常可以使用:

localhost

不应该依赖普通 HTTP 生产环境运行加密逻辑。


22.4 sendBeacon Payload 限制

sendBeacon() 更适合小型数据发送。

因此不能无限增大 Batch。

实际 Batch Size 应同时考虑:

事件数量
+
单事件大小
+
最终 JSON 大小
+
加密后的 Payload 大小

而不是仅仅根据事件条数判断。


二十三、测试策略

SDK 的测试重点应该围绕“初始化、队列、传输、生命周期、安全、兼容性”展开。

初始化

FingerprintJS 正常返回
FingerprintJS 超时
FingerprintJS 异常

验证:

正常 → deviceId
失败 → uuidv4

Ready 状态

preParams === undefined
preParams === {}

验证:

undefined → not ready
{}        → ready

Track

验证:

初始化完成前 track
初始化完成后 track
连续 track

确保初始化期间产生的事件不会直接丢失。


Session

连续调用:

track('event_a')
track('event_b')
track('event_c')

验证:

session_id(event_a)
==
session_id(event_b)
==
session_id(event_c)

Batch

验证:

200 条 → 立即 flush

<200 条 + 3 秒 → flush

页面 hidden → flush

Transport

验证:

sendBeacon() === true

以及:

sendBeacon() === false

fetch(keepalive)

Retry

验证:

1s

2s

4s

以及:

最多 3 次

SSR

模拟:

typeof window === 'undefined'

确保 SDK:

不抛异常

并且浏览器环境字段安全降级。


二十四、工程化发布

SDK 可以作为独立 npm Package 发布。

典型构建产物:

dist/
├── index.mjs
├── index.cjs
└── index.d.ts

分别提供:

ESM
CJS
TypeScript Declaration

业务项目只需要安装 SDK:

pnpm add @xxx/kit-pulsar

然后初始化:

import { Pulsar } from '@xxx/kit-pulsar'

const pulsar = new Pulsar({
  endpoint: 'https://example.com/collect',
  secret: '******',
  getPreParams: async () => ({
    app_version: '1.0.0',
  }),
})

二十五、为什么选择“SDK + 服务端接收层”

前端埋点不应该直接绑定最终的数据存储。

更合理的架构是:

Web Application


   Pulsar SDK


Collection API


Data Processing


     BI / OLAP

这样可以让前端 SDK 与后端数据系统解耦。

未来即使:

BI Storage

从一种数据库迁移到另一种数据库,前端 SDK 也不需要跟着改变。


二十六、后续演进方向

当前设计主要解决基础埋点能力,后续可以继续扩展。

1. 持久化队列

从:

Memory Queue

升级到:

IndexedDB

提高页面关闭、刷新以及异常场景下的数据可靠性。


2. Offline Queue

监听:

online
offline

网络断开时:

Queue → IndexedDB

网络恢复:

IndexedDB → Upload

3. 动态采样

对于高频事件,可以支持:

sampleRate: 0.1

表示仅采集约 10% 的事件。

这样可以降低:

  • 网络流量
  • 服务端写入压力
  • 数据存储成本

4. Schema Version

事件协议可以增加:

{
  "schema_version": 1
}

未来协议升级:

v1

v2

v3

服务端可以根据版本执行不同解析逻辑。


5. Transport 抽象

当前:

sendBeacon

fetch

未来可以进一步抽象:

interface Transport {
  send(events: Event[]): Promise<void>
}

从而支持:

BeaconTransport
FetchTransport
CustomTransport

二十七、总结

一个完整的前端埋点 SDK,真正复杂的部分并不是:

fetch('/track')

而是围绕这个简单 API 建立一套完整的数据传输基础设施。

核心设计可以总结为:

稳定设备标识
      +
Session 生命周期
      +
统一事件模型
      +
内存批量队列
      +
定时 / 容量双触发
      +
页面关闭 Flush
      +
sendBeacon
      +
fetch keepalive
      +
指数退避重试
      +
AES-GCM 加密
      +
HMAC 完整性校验
      +
SSR 安全
      +
可扩展 Transport

最终业务侧只需要关注:

pulsar.track('event_name', properties)

而 SDK 负责处理:

“这个事件是谁产生的”
“属于哪个 Session”
“应该什么时候发送”
“页面关闭了怎么办”
“网络失败怎么办”
“数据如何传输”
“如何保证协议完整性”
“SSR 怎么运行”

这也是前端埋点 SDK 从一个简单的“埋点工具”逐步演进为“数据采集基础设施”的核心过程。

本页目录