前端埋点 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
↓ ↓
└──────────┬──────────┘
↓
服务端设计目标主要包括:
- 业务无感
- 初始化非阻塞
- 批量传输
- 页面关闭尽可能不丢数据
- 网络异常自动重试
- 浏览器端数据保护
- SSR 安全运行
- 可扩展的事件模型
二、整体架构
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
↓
deviceId3.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_path | window.location.pathname |
url | window.location.href |
device | ua-parser-js |
browser_name | ua-parser-js |
browser_major | ua-parser-js |
browser_version | ua-parser-js |
os_name | ua-parser-js |
os_version | ua-parser-js |
language | navigator.language |
timezone | Intl.DateTimeFormat |
screen | screen.width / height |
memory | navigator.deviceMemory |
cpu | navigator.hardwareConcurrency |
ua | navigator.userAgent |
七、SSR 兼容
浏览器埋点 SDK 最大的运行环境差异之一,就是 SSR。
以下对象在 Node.js 环境中不存在:
window
navigator
screen
document如果模块加载阶段直接执行:
window.location.hrefSSR 就可能直接抛出异常。
因此所有 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: true11.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 对失败请求进行有限次数重试。
默认参数:
| 参数 | 默认值 |
|---|---|
maxRetries | 3 |
retryBackoffBase | 1000ms |
maxBackoff | 30000ms |
fetchTimeout | 10000ms |
使用指数退避:
第一次失败
↓
等待 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
失败 → uuidv4Ready 状态
preParams === undefined
preParams === {}验证:
undefined → not ready
{} → readyTrack
验证:
初始化完成前 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 → flushTransport
验证:
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 → Upload3. 动态采样
对于高频事件,可以支持:
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 从一个简单的“埋点工具”逐步演进为“数据采集基础设施”的核心过程。
前端性能监控服务设计:基于 Cloudflare Workers Cron 与 R2 构建 FCP 指标采集系统
基于 Cloudflare Workers Cron+R2 搭建轻量前端 FCP 性能监控服务。固定每分钟 Cron 触发,业务动态控制采样间隔;查询原始事件后过滤异常值、分级聚合,以 index 实现 R2 幂等写入日维度指标快照;做好边界与异常处理,将原始事件转为可直接用于看板的聚合指标。
基于 Cloudflare Workers 构建后台管理 BFF:认证、权限、R2 与配置中心设计
本文介绍基于 Cloudflare Workers+R2 搭建管理 BFF,作为管理 SPA 统一控制层。实现登录会话、资源‑动作权限校验,R2 存储配置、审计、用户数据,支持配置热更新、数据同步、文件上传,采用加密签名与时间戳做请求防护;指出 R2 并发写入等局限,规划架构演进,适配低频内部后台场景。