Tracking Master:多供应商埋点统一跟踪层与Provider抽象 简介Tracking Master 是一款面向 Bludit 扁平文件 CMS 用户与插件开发者的开源网络分析跟踪插件重点解决轻量站点在不依赖数据库的前提下统计访问量、页面浏览与用户来源的问题并提供多供应商分析服务的接入思路。压缩包共 12 个文件约 17KB以 JSON 配置与元数据、PHP 插件入口、CSS 样式、JPG 图片、Markdown 说明及 LICENSE 授权文本为主其中 PHP 承载插件逻辑JSON 负责配置组织CSS 与图片用于界面呈现说明和授权文件则帮助理解使用边界。已有 481 人学习下载适合关注网站统计、隐私控制与开源实践的中初级站长及插件开发者参考。读者可从中了解 Bludit 插件的基本结构、多供应商跟踪的配置方式以及开源项目的授权与版本管理特征为自定义统计需求、替换分析服务或排查集成问题提供可复用的样本。1. 从一次归因数据对不上说起Tracking Master 到底解决什么投放后台显示转化 1200分析后台显示 860两边都号称统计的是同一批落地页。排查到凌晨才发现两套埋点各写各的事件名一个用signup_success一个用signUpSuccess失败重试一边三次一边不重试去重键一边用设备号一边用会话号。这种对不上的账在同时接了两家以上网络分析供应商的项目里几乎是常态。Tracking Master 就是一个冲这个场景做的开源软件一个多供应商网络分析跟踪器把「事件长什么样、什么时候发、发失败怎么办、发给谁」从业务代码里剥离出来做成一层统一跟踪层底下挂多个供应商适配器。业务侧只调一次track()底层可以并行上报多家也可以按配置灰度切换、降级跳过。它适合手上有中大型前端或客户端项目、已经接了不止一家分析或归因平台、又不愿意让埋点调用散落在几十个业务文件里的团队如果你只有一个供应商、事件量也很小直接用它反而多一层抽象。2. 架构拆解Provider 抽象层怎么把多家供应商塞进一套接口多供应商项目最容易走歪的地方是把「切换供应商」做成 if-else。今天接 A 家写一个if (vendor a)明天接 B 家再加一个分支三个月后这段代码没人敢动。Tracking Master 的骨架是反过来设计的上层只认一套事件模型和一套发送契约任何供应商都必须先被包装成符合契约的 Provider才允许挂进运行时。这一章拆的就是这层契约长什么样、仓库里怎么分包、每个包该放什么。2.1 三层职责划分Core、Provider、Transport整个运行时被切成三层边界很清楚改任何一层都不会牵动另外两层。Core 层是调度中枢负责事件构造、字段归一化、幂等键生成、队列管理与重试策略。它不知道任何供应商的存在只认Provider接口。换句话说Core 是可以脱离具体供应商单独跑测试的这一点在做单元测试时非常省事。Provider 层是适配器一家供应商一个包。它干三件事把统一事件翻译成该供应商的请求体、处理这家特有的鉴权和签名、把响应里的错误码翻译成统一的SendResult。凡是「只有这家才有的怪癖」全部关在这个包里不许外溢。Transport 层是最底下的发送通道抽出来是因为浏览器、Node、小程序、客户端 WebView 的下发方式差别很大。浏览器要优先用navigator.sendBeacon处理页面卸载Node 侧走http/https客户端要走原生桥。常见做法是给 Transport 定义一个极窄的接口收到 URL、headers、body返回状态码或异常别的一概不管。三层之间只允许自上而下调用Provider 不能反向去读 Core 的内部状态。这条约束听着死板但它保证了新增供应商时你只需要看一个包不用满仓库找耦合点。2.2 仓库目录六个包各自负责什么monorepo 结构用 pnpm workspace 管构建工具常见选择是 tsup 或 rollup输出 ESM CJS 双格式方便浏览器和 Node 同时消费。落到目录上大概是这个样子tracking-master/ ├── pnpm-workspace.yaml ├── packages/ │ ├── core/ # 事件模型、队列、重试、采样、生命周期 │ │ ├── src/ │ │ │ ├── tracker.ts # 对外入口track/identify/flush │ │ │ ├── queue.ts # 批量队列与 flush 触发 │ │ │ ├── event.ts # 事件构造与字段归一化 │ │ │ └── retry.ts # 退避策略 │ │ └── package.json │ ├── provider-base/ # Provider 接口定义 抽象基类 │ ├── provider-alpha/ # 供应商 A 适配器 │ ├── provider-beta/ # 供应商 B 适配器 │ ├── provider-gamma/ # 供应商 C 适配器 │ └── transport/ # fetch / sendBeacon / node-http 适配 ├── examples/ │ ├── web-demo/ # 浏览器最小可跑示例 │ └── node-demo/ # 服务端上报示例 └── scripts/ └── mock-server.ts # 本地验收用的假接收端provider-base单独成包是个关键决定。如果把它塞进core那所有 Provider 包都会依赖corecore又要在运行时去 import 各家 Provider很容易绕成循环依赖。抽出来之后依赖方向变成单向core → provider-base ← provider-alpha干净很多。examples目录别跳过多供应商项目最怕的就是「文档说支持实际跑不起来」。仓库里带一个能一键启动的 web-demo比写十页 README 管用。2.3 Provider 接口约定与生命周期供应商适配器要实现的接口非常薄薄到你可以半小时包一家新的// packages/provider-base/src/types.ts export interface ProviderConfig { enabled: boolean; // 灰度开关false 时整条链路直接跳过 endpoint: string; // 该供应商的接收地址 apiKey?: string; batchSize?: number; // 这家允许的单批上限各家不一样 timeoutMs?: number; } export interface SendResult { ok: boolean; retryable: boolean; // 关键区分「网络抖动」和「参数错了」 status?: number; message?: string; } export interface Provider { readonly name: string; init(cfg: ProviderConfig): Promisevoid; send(events: TrackEvent[]): PromiseSendResult; flush?(): Promisevoid; // 有本地缓冲的供应商才需要实现 }retryable这个字段是整个设计里最值得说的一处。HTTP 500、超时、连接重置属于可重试400、401、422 属于不可重试——参数写错了你重试一百次也是错的只会把队列堵死。把判断权交给 Provider是因为只有它知道自家错误码的含义Core 层不该去背别人家的错误码表。生命周期有严格顺序init()全部成功之后才允许send()。实践中常见做法是给init加一个超时保护比如 3 秒没完成就标记为「就绪但降级」让其它供应商先跑别让一家的初始化把整条链路卡住。这个细节在第 5 章会作为一个具体的坑展开。flush()是可选方法只对带本地缓冲的供应商标记有意义。有些供应商的 SDK 自己会攒批那适配器就得在页面卸载前把它的缓冲冲一遍否则最后一批数据就留在内存里跟着页面一起没了。3. 跑起来本地环境、配置和第一条上报事件架构看完接下来是让它真的动起来。这一章按「装依赖 → 写配置 → 发第一个事件 → 本地验收」的顺序走一遍中间会解释每个参数改了会发生什么以及失败时该去哪里看。整套流程在一台普通开发机上十分钟内能走完。3.1 环境准备与依赖安装Node 版本建议 18 以上原因是 Transport 层在 Node 侧会用到全局fetch低版本还得额外装 polyfill平白多一层坑。包管理器跟着仓库走用 pnpm别用 npm 装——workspace 里的workspace:*协议 npm 处理起来会报错。# 克隆后先确认 pnpm 版本仓库通常在 package.json 的 packageManager 字段里锁了 corepack enable pnpm install # 全量构建产出 dist 供 examples 引用 pnpm -r build # 跑测试先确认基线是绿的再动手改 pnpm -r testpnpm -r是递归执行会按拓扑顺序跑每个包。构建顺序很重要provider-base必须先于core构建完否则core编译时会找不到类型声明。如果你的构建报「Cannot find module tracking/provider-base」八成是跳过了这一步直接进某个子包跑 build。测试用的是 vitest跑起来很快。基线不绿就先别改代码先看是不是 Node 版本问题——这类环境差异导致的红灯排查起来最费时间。3.2 配置文件一份 providers 数组怎么读初始化配置决定了整个运行时的行为写清楚每个字段比背 API 有用得多// examples/web-demo/src/tracker.ts import { createTracker } from tracking/core; import { createAlphaProvider } from tracking/provider-alpha; import { createBetaProvider } from tracking/provider-beta; export const tracker createTracker({ appId: demo-app, // 全局公共属性会合并进每一条事件的 context context: { platform: web, release: 1.0.0, }, // 采样1 表示全量0.1 表示只上报 10%调试期建议先设 1 sampleRate: 1, queue: { maxBatch: 20, // 单批最多几条取所有供应商 batchSize 的最小值 flushIntervalMs: 5000, // 定时冲刷间隔 maxQueueSize: 500, // 队列上限超了丢最旧的防止内存涨爆 }, providers: [ createAlphaProvider({ enabled: true, endpoint: https://alpha.example-analytics.test/collect, apiKey: process.env.ALPHA_KEY, batchSize: 20, }), createBetaProvider({ enabled: false, // 灰度阶段先关掉验证完再打开 endpoint: https://beta.example-analytics.test/v2/events, apiKey: process.env.BETA_KEY, batchSize: 50, }), ], });queue.maxBatch的取值有个讲究它必须小于等于所有启用供应商里最小的batchSize。A 家允许 20 条一批、B 家只允许 10 条你设成 20 就会让 B 家那边整批被拒。手工对齐容易忘常见做法是在createTracker里做个校验取最小值并打一条 warn 日志。sampleRate是全局采样采样发生在入队之前所以被采样掉的事件不会占用队列空间。调试阶段先设 1等上线前再按量级往下调。这里有个容易忽略的点采样和去重的关系在第 5 章会单独讲它会直接影响转化数据。enabled: false不是把 Provider 从数组里删掉而是保留实例但跳过发送。这个区别在灰度切换时很实用——你可以通过远端配置热改这个布尔值不用发版。3.3 第一个事件从 track 调用到网络请求发出配置就绪发一条事件看看// 业务侧只认这一个调用不关心底下有几家供应商 tracker.track(signup_success, { plan: pro, amount: 199, currency: CNY, }); // 主动冲刷本地调试时用线上靠定时器和页面卸载触发 await tracker.flush(manual);调用进到 Core 之后实际发生的事是先补全eventId、ts、anonId这些公共字段再合并context和本次props然后入队最后按flushIntervalMs或队列长度触发冲刷。冲刷时 Core 会把同一批事件分发给所有enabled为 true 的 Provider各自翻译成自己的请求体并发发出。track()是同步返回的不阻塞业务代码这一点很重要——埋点绝不能在主线程上 await 网络请求。真正需要等结果的地方比如单测、脚本化验收才用flush()拿到 Promise。建议在本地先把flushIntervalMs调到 1000这样你能在 Network 面板里快速看到请求。等逻辑验证完再调回 5000线上频繁小批量上报对服务端不友好。如果想验证字段是否正确可以在 Provider 的send里临时打一行日志把翻译后的请求体打出来。这比在浏览器里翻 payload 快得多尤其是嵌套结构。3.4 用本地 mock 服务验收上报内容真的往供应商地址发请求在开发阶段既慢又不方便断言。仓库的scripts/mock-server.ts就是干这个的起一个本地接收端把收到的 body 落盘# 起本地接收端默认监听 8787 pnpm tsx scripts/mock-server.ts --port 8787 --out ./tmp/payloads # 另开一个终端跑 demo把 endpoint 指过来 cd examples/web-demo pnpm dev// scripts/mock-server.ts 的核心逻辑收什么存什么 import { createServer } from node:http; import { writeFileSync, mkdirSync } from node:fs; const outDir process.argv.includes(--out) ? ./tmp/payloads : ./tmp; mkdirSync(outDir, { recursive: true }); createServer((req, res) { let raw ; req.on(data, (c) (raw c)); req.on(end, () { // 按供应商名分文件存方便对比同一批事件被翻译成了什么 const vendor new URL(req.url ?? /, http://x).searchParams.get(v) ?? unknown; const name ${vendor}-${Date.now()}.json; writeFileSync(${outDir}/${name}, raw || {}); res.writeHead(204).end(); }); }).listen(8787, () console.log(mock listening on 8787));验收的重点不是「请求发出去了」而是「不同供应商收到的字段语义一致」。打开tmp/payloads里的两个文件对比A 家的event_name和 B 家的eventName是不是同一个值时间戳单位是不是都是毫秒金额有没有一边是元一边是分。这类差异一旦漏到线上就是开头说的那种「两边报表对不上」。--port和--out两个参数按需改就行端口冲突时换一个输出目录建议放tmp并加进.gitignore别把 payload 提交上去。4. 核心模块代码事件模型、批量队列与归因回传跑通之后再回头看内部会发现真正决定这套东西能不能长期用的是三块代码事件模型有没有把字段统一住、队列有没有把失败处理好、归因参数有没有正确地从落地页带到后续所有事件。这一章逐个拆代码都是可以照着抄的骨架。4.1 统一事件模型字段归一化与幂等键事件模型是整个系统的地基。字段不一致上层做得再好都白搭// packages/core/src/event.ts export interface TrackEvent { eventId: string; // 幂等键去重靠它 name: string; // 归一化事件名统一小写下划线 ts: number; // 客户端毫秒时间戳 anonId: string; // 匿名设备/浏览器标识 userId?: string; // 登录后绑定 props: Recordstring, unknown; // 业务自定义属性 context: Recordstring, unknown; // 页面、设备、版本等公共属性 } // 事件名归一化把 signUpSuccess、sign-up-success 全部收敛成 sign_up_success export function normalizeName(raw: string): string { return raw .replace(/([a-z0-9])([A-Z])/g, $1_$2) // 驼峰转下划线 .replace(/[-\s]/g, _) .toLowerCase(); } // 幂等键同一用户的同一事件在同一毫秒只允许一条 export function buildEventId(input: { anonId: string; name: string; ts: number }): string { return ${input.anonId}:${input.name}:${input.ts}; }normalizeName是必须的。各家供应商对事件名的大小写和分隔符要求不一样A 家建议下划线、B 家允许驼峰如果不在 Core 层统一同一个业务动作在不同后台会变成两个完全不同的指标中间再想合并就麻烦了。buildEventId用anonId name ts三元组目的是挡住重复上报。同一毫秒内同一个用户触发同一个事件在真实场景里基本只有一种可能重复调用。用这个键在 Provider 侧或服务端做一次去重能挡掉相当一部分脏数据。context和props分开放是因为它们的生命周期不同。context是初始化时给的公共属性可能中途更新比如用户切换了语言但整体变化少props是一次性的每条事件都不一样。分成两个字段Provider 翻译时也好处理——有的供应商要求公共属性放在请求头或者 query 上有的要求平铺进 body。4.2 批量队列与重试flush 的四个触发条件队列这块代码不长但要处理清楚的边界情况不少// packages/core/src/queue.ts type FlushReason interval | full | manual | unload; export class EventQueue { private buffer: TrackEvent[] []; private inFlight false; constructor( private providers: Provider[], private maxBatch: number, private maxQueueSize: number, private retry: RetryPolicy, ) {} push(evt: TrackEvent) { // 队列满时丢最旧的埋点数据可以丢但不能让内存涨爆 if (this.buffer.length this.maxQueueSize) this.buffer.shift(); this.buffer.push(evt); } async flush(reason: FlushReason): Promisevoid { // 单飞同一时刻只允许一个冲刷在跑否则会重复上报 if (this.inFlight) return; if (!this.buffer.length) return; const batch this.buffer.splice(0, this.maxBatch); this.inFlight true; try { const results await Promise.allSettled( this.providers .filter((p) p.isEnabled()) .map((p) p.send(batch)), ); // 只把「可重试」的失败塞回队首不可重试的直接丢避免堵死队列 const needRetry results.some( (r) r.status rejected || (r.value.ok false r.value.retryable), ); if (needRetry this.retry.canRetry()) { this.buffer.unshift(...batch); await this.retry.wait(); } } finally { this.inFlight false; } } }inFlight这个锁是血泪经验换来的。没有它的时候定时刷新和手动flush()很容易撞在一起同一批事件被发两次后台数据直接翻倍而且还很难复现——因为它依赖时序。加上这把锁之后并发调用会直接返回简单粗暴但有效。flush有四个触发点缺一不可定时器到点interval、队列达到批量上限full、手动调用manual、页面卸载unload。第四个最容易被忘而它恰恰是最后一批数据能不能落地的关键。卸载场景一般走sendBeacon因为普通fetch在页面销毁时会被浏览器掐断。重试策略用指数退避常见配置是首次 1 秒、每次翻倍、最多 3 到 5 次、单次上限 30 秒。别配无限重试一个坏掉的端点会把队列塞满最后正常的供应商也被拖累。4.3 归因参数回传首触与末触的持久化选择网络分析里最影响结论的是归因参数有没有被正确保存下来。从落地页 URL 上的utm_*、click_id这类参数抓起存下来之后每条事件都带上// packages/core/src/attribution.ts const STORAGE_KEY tm_attribution; interface Attribution { firstTouch?: Recordstring, string; // 首次进入时的参数 lastTouch?: Recordstring, string; // 最近一次进入时的参数 capturedAt: number; } export function captureAttribution( search: string, store: PickStorage, getItem | setItem, keys [utm_source, utm_medium, utm_campaign, click_id], ): Attribution | null { const params new URLSearchParams(search); const hit: Recordstring, string {}; for (const k of keys) { const v params.get(k); if (v) hit[k] v; } if (!Object.keys(hit).length) { // 本次没带参数返回已存的保证后续事件仍能挂上历史归因 const raw store.getItem(STORAGE_KEY); return raw ? (JSON.parse(raw) as Attribution) : null; } const prev store.getItem(STORAGE_KEY); const old: Attribution prev ? JSON.parse(prev) : { capturedAt: Date.now() }; const next: Attribution { firstTouch: old.firstTouch ?? hit, // 首触一旦定下就不再覆盖 lastTouch: hit, // 末触每次都更新 capturedAt: Date.now(), }; store.setItem(STORAGE_KEY, JSON.stringify(next)); return next; }这段的关键决策是「首触不覆盖、末触每次更新」。做渠道效果评估通常看首触做再营销效果看末触两个都存下来后面按需取比只存一个再回头补要省事得多。存储介质要挑Web 端用localStorage但要注意它有容量上限且可能被用户清掉客户端一般落本地数据库或 key-value如果涉及跨端打通还得考虑服务端回传兜底。存储失败隐私模式、配额满必须有 try-catch不能让归因失败把主流程带崩。keys参数做成可配置是因为不同业务的渠道参数名不一样硬编码在代码里等于给自己上锁。上线前把这份 key 列表跟投放侧对一遍别一边叫utm_campaign一边叫campaign_id这种不对齐到了月底对账时最要命。5. 避坑与排查多供应商接入最容易翻车的五个位置这套东西跑通不难难的是跑稳。下面五条都是在多供应商场景里真实会撞上的问题按「现象 → 原因 → 解决」写遇到类似症状可以直接对照。5.1 事件量莫名翻倍初始化被执行了两次现象后台看到的事件数是预期的接近两倍且两条记录的时间戳只差几毫秒eventId却不同。原因通常有两个一是模块被重复引入SPA 里路由切换、微前端子应用各自初始化了一遍 tracker二是热更新HMR在开发环境反复执行模块顶层代码把旧的实例留着没销毁。解决把 tracker 实例挂到全局单例上初始化前先判断是否存在同时导出destroy()清掉定时器和事件监听。开发环境的 HMR 问题在import.meta.hot.dispose里调一次destroy()就能解决大半。另外buildEventId用anonId name ts而不是随机 UUID就是为了在这种场景下还能靠服务端去重兜一层。5.2 同一个动作在两家后台变成两个指标现象A 家后台能看到signup_successB 家后台只有一条signUpSuccess两边漏斗对不上。原因是没有在 Core 层做事件名归一化或者做了一半——Provider 翻译时又自己转了一次大小写转出两套规则。解决归一化只在 Core 的normalizeName里做一次Provider 层禁止再动事件名只负责字段映射。同时维护一张「业务事件名 → 各供应商事件名」的映射表映射表集中在一个文件里改的时候一眼能看到全貌。上线前用 mock server 抓两份 payload 做逐字段 diff是最直接的验证手段。5.3 页面关闭时最后一批数据丢了现象用户点支付完成后立刻关页面支付成功事件在后台查不到但日志里明明有track()调用。原因是走的是普通fetch页面卸载时请求被浏览器中断或者队列里还有数据没到刷新间隔。解决监听visibilitychange在hidden状态触发一次flush(unload)这条路径上强制走sendBeacon。注意sendBeacon有 64KB 的体积限制超了会直接返回 false 且不报错所以卸载时的批量要单独控制得小一些比如 10 条以内。pagehide比beforeunload在移动端更可靠两个都监听更保险。5.4 批量太大被供应商静默丢弃现象单条事件发得出去批量发就没了服务端返回 200 但后台查无数据。原因是超过该供应商的单批上限部分实现会返回成功但只处理前 N 条形成黑匣子。解决maxBatch取所有启用供应商batchSize的最小值并在createTracker里做断言校验。同时对每个 Provider 的send结果做长度校验——如果发出去 20 条、接口只认了 10 条得能从返回体里读出来。接入新供应商时第一件事就是把它的批量上限和限流阈值摸清楚写进配置注释里。5.5 采样开到 0.1 之后转化数少了一截现象采样率从 1 调到 0.1曝光数按比例降了但转化数降得更多。原因是采样发生在入队前而转化事件往往依赖前序曝光事件做归因匹配前序被采样掉转化就成了无源之水。解决转化类关键事件不参与采样或者用「按用户维度采样」而不是「按事件维度采样」——同一个anonId要么全采要么全不采保证链路完整。实现上把采样判断放在anonId生成之后、eventId计算之前并对标记为critical的事件类型直接跳过采样逻辑。改了采样策略一定要重新对一次两边后台的转化数这是唯一靠谱的验证。6. 二次开发新增一家供应商与本地验证的完整路径接第四家供应商是这套架构最常被考验的地方。如果按下面四步走还觉得疼说明抽象层有问题该回头改provider-base而不是在业务里打补丁。第一步建包。复制provider-beta整个目录改成provider-delta改package.json的 name 和依赖pnpm install让 workspace 链接生效。第二步实现send重点是把统一事件翻译成目标请求体鉴权放在 headers 里别混进 body。第三步把错误码映射到retryable超时和 5xx 给 true4xx 给 false限流 429 单独处理——给 true 但要读Retry-After。第四步写单测至少要覆盖「正常一批」「部分字段缺失」「服务端 500」「服务端 400」四种情况。// packages/provider-delta/src/index.ts export function createDeltaProvider(cfg: ProviderConfig): Provider { return { name: delta, isEnabled: () cfg.enabled, async init() { /* 校验 apiKey 是否为空空则直接抛 */ }, async send(events) { const body { app: cfg.apiKey, // delta 要求时间戳是秒这里必须转不能直接透传毫秒 data: events.map((e) ({ eid: e.eventId, en: e.name, t: Math.floor(e.ts / 1000), uid: e.userId ?? e.anonId, kv: { ...e.context, ...e.props }, })), }; const res await transport.post(cfg.endpoint, body, { timeoutMs: cfg.timeoutMs }); // 429 是可重试的但要走它自己的退避节奏 return { ok: res.status 300, retryable: res.status 500 || res.status 429, status: res.status }; }, }; }单位换算是这类适配里最容易翻车的一处Math.floor(e.ts / 1000)少了就会在后台看到 1970 年的数据。字段命名映射eventId → eid建议在包内维护一张常量表别散在 map 里。验证分三层单测跑逻辑mock server 跑 payload diff最后用真实地址跑一个灰度环境enabled先设 false用远端配置单独打开给 1% 用户。观察一天比对这 1% 用户在新旧两个后台的事件量差异超过 2% 就回滚查原因。这个流程我一般会写在包的 README 里谁来接手都照着走。说个教训。早些年接第三家供应商时我图快直接在业务代码里加了个判断绕过了队列结果那家一限流就把主线程卡住页面白屏。从那以后我每次新增 Provider都强制走一遍「单测 → mock diff → 1% 灰度 → 差异比对」这四步一步不省。这套流程慢是慢了点但比线上回滚快得多。希望帮到你。本文还有配套的精品资源点击获取