Go Wind UBA 拆解系列 - SDK 与采集层:从浏览器到 Kafka

Go Wind UBA 拆解系列 - SDK 与采集层:从浏览器到 Kafka

本文回答一个问题:一个埋点事件,从用户在浏览器里点了一下,到最终被 Kafka 接住,中间的 SDK 和 Collector 做了多少你看不见的工程? 答案是:比你想象的多得多。

一、为什么"采集"这件事很难

很多人以为埋点 SDK 就是 fetch('/report', { body: event })。这在 demo 里成立,在生产里不成立。真实的采集层要回答这些问题:

  • 页面随时可能关闭------用户点完就关 tab,残留事件怎么办?
  • 网络随时可能抖动------一次失败就丢数据?还是重试?重试几次?退避多久?
  • 服务端可能限流------429 要重试,但 401(鉴权失败)不该重试,怎么区分?
  • 批量还是单条------单条上报开销大,批量要攒多久、攒多少?
  • 客户端环境千差万别------浏览器的 sendBeacon、Unity WebGL 不能用 HttpClient、小程序没有 localStorage......
  • 凭证放哪------放 Header 还是 body?前者标准但不支持 sendBeacon。

GoWind UBA 的两个 SDK(Web TS / C# .NET)和 Collector 服务,把这些都处理了。本文逐个拆。

二、Web SDK:一个克制的批量上报器

Web SDK 在 frontend/sdk/web/uba/src/,6 个模块。架构很干净:UbaClient(高层 API + 事件构造)委托给 Batcher(缓冲 + 触发),Batcher 委托给 retry.ts(网络 + 重试)。上下文(设备/会话/平台)独立在 context.ts

2.1 UbaClient:单例 + 事件构造

UbaClient 是个单例 ,挂在 globalThis.__uba_client__ 上。init() 会先 tear down 旧实例再重建,避免重复初始化。

所有事件都走一个唯一的构造漏斗 buildEvent------这是关键设计,保证每条事件的结构一致:

ts 复制代码
const eventTime = toRFC3339();
const mergedProps = merge(this.superProperties, properties);  // 公共属性 + 本次属性
const pageUrl = getPageUrl();
if (pageUrl && !mergedProps.pageUrl) mergedProps.pageUrl = pageUrl;
return {
  eventType, eventId: uuid(), eventName, eventTime,
  userId: options?.userId ?? this.userId,
  deviceId: options?.deviceId ?? getDeviceId(),
  sessionId: options?.sessionId ?? getSessionId(),
  platform: options?.platform ?? this.platform,
  properties: Object.keys(mergedProps).length > 0 ? mergedProps : undefined,
};

setSuperProperties({ platform: 'web', version: '1.0.0' }) 设的公共属性,会自动合并进后续每一条事件trackBehavior / trackRisk 都是调 buildEvent 后再挂一个 oneof 载荷event.behavior / event.risk)------这跟后端 proto 的 oneof 契约对齐。

2.2 Batcher:双触发 + 并发守卫

这是 SDK 的心脏。Batcher 持一个内存队列 queue、一个 setInterval 定时器、一个 flushing 布尔守卫。两个触发条件:

触发 1:攒够了batcher.ts

ts 复制代码
enqueue(event: ReportEvent): void {
  this.queue.push(event);
  if (this.queue.length >= this.opts.batchSize) {   // 默认 batchSize=20
    void this.flush();
  }
}

触发 2:定时到了 ------setIntervalflushInterval(默认 5000ms)调一次 flush()。在 Node 环境下调 .unref(),避免 timer 卡住进程退出。

flush() 的核心是并发守卫

ts 复制代码
this.flushing = true;
const events = this.queue.splice(0, this.queue.length);   // 原子清空
try {
  const body = this.buildBody(events);
  const result = await sendWithRetry(this.opts.url, body, {...});
  // ...
} finally {
  this.flushing = false;
}

同一时间只有一个 in-flight 的 send。 并发的 flush() 调用会短路返回。这个设计保证了:不会因为重试风暴把服务端打爆。

凭证在 body,不在 HeaderbuildBody):

ts 复制代码
// body = { appId, appSecret, events, clientInfo }

这是整条链路的契约起点。后面会看到,正是这个选择让 sendBeacon 兜底成为可能。

2.3 重试与降级:精确区分状态码

sendWithRetry 的重试策略值得细看(retry.ts):

ts 复制代码
function isNoRetryStatus(status: number): boolean {
  return status >= 400 && status < 500 && status !== 429;
}

// 循环 maxRetries+1 次
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
  result = await sendOnce(url, body, cfg);
  if (result.ok) return result;                        // 2xx:成功
  if (isNoRetryStatus(result.status)) return result;   // 4xx(除 429):不重试
  // 否则退避重试
  if (attempt < cfg.maxRetries) {
    const delay = cfg.baseDelay * Math.pow(2, attempt);  // 指数退避
    await sleep(delay);
  }
}

三个关键决策:

  1. 401/400 不重试------鉴权失败或参数错误,重试也是错,浪费请求。直接放弃。
  2. 429 要重试------服务端限流,过会儿可能就好了。
  3. 5xx / 网络错误重试 ------指数退避(baseDelay * 2^attempt,默认 baseDelay=1000ms)。

重试耗尽后怎么办?丢掉,不回填队列。 这是有意的------避免无限堆积导致内存爆炸。源码注释明说:宁可丢一批,也不能让 SDK 内存无限增长。

ts 复制代码
if (!result.success) {
  dropped = events.length;   // 直接计入 dropped
}

sendOnceAbortController 控超时(默认 8000ms),发 credentials: 'omit'(不带 cookie,避免干扰 app-secret 鉴权)。

2.4 页面卸载兜底:sendBeacon

这是 Web SDK 最有 browser 特色的部分。浏览器关闭页面时,普通 fetch 会被取消;唯有 navigator.sendBeacon 能在卸载时可靠发出去(但它不能设 Header、不能等响应)。

SDK 注册了双事件兜底(移动端 + 桌面端覆盖):

ts 复制代码
private bindUnload(): void {
  const handler = () => this.batcher.flushBeacon();
  window.addEventListener('pagehide', handler);     // 移动端友好
  window.addEventListener('beforeunload', handler); // 桌面端
}

flushBeacon尽力而为batcher.ts):

ts 复制代码
flushBeacon(): void {
  if (!this.opts.enableBeacon || this.queue.length === 0) return;
  const events = this.queue.splice(0, this.queue.length);
  const body = this.buildBody(events);
  const ok = sendBeacon(this.opts.url, body);     // 用 Blob 包装,异步发出
  if (!ok) {
    this.opts.log('warn', 'sendBeacon failed, events lost on unload');
  }
}

注意几个取舍:

  • sendBeacon 发的就是 fetch 同样的 body (含 appId/appSecret)------正是因为凭证在 body,sendBeacon 才能用。如果把凭证放 Header,这一层兜底就彻底废了。 这就是为什么鉴权在 body 是深思熟虑的选择。
  • 失败就丢------sendBeacon 没法重试(页面都没了),SDK 只能 log warn。这是 browser 环境的硬限制,没有银弹。

2.5 上下文:设备 / 会话 / 平台

context.ts 负责生成稳定标识:

  • deviceId :存 localStorage__uba_device_id__),跨会话稳定;无 localStorage 时(隐私模式 / Node)退回内存变量。
  • sessionId :存 sessionStorage__uba_session_id__),tab 级隔离(关 tab 清掉);同样有内存兜底。
  • platform :UA 嗅探,返回 web/ios/android/mini_program/node;小程序靠 window.wx.getSystemInfo 存在性识别。

热力图用的点击坐标XPath 也在这里算:坐标加上 scroll offset 换算到文档坐标,XPath 用于元素定位。自动埋点的 click 监听绑在捕获阶段addEventListener('click', handler, true)),保证在业务逻辑之前触发。

2.6 默认值一览

记一下默认值,跟 C# SDK 是镜像的(后文验证):

参数 默认值
batchSize 20
flushInterval 5000ms
maxRetries 3
timeout 8000ms
retryBaseDelay 1000ms
enableBeacon true
autoTrack true

三、C# SDK:零依赖核心 + 可注入传输

C# SDK 在 sdk/csharp/src/,两个项目:Uba.Core(.NET Standard 2.0,覆盖 Unity / Godot)和 Uba.Unity(Unity 适配)。API 跟 TS 版几乎同构(Track / TrackBehavior / TrackRisk / Identify / SetSuperProperties / FlushAsync),默认值也完全一样------这是有意为之,方便团队跨端接入时心智一致。

但有一个架构性差异 :C# 版的传输层和上下文都是可注入的

3.1 IHttpTransport:为 Unity WebGL 留的口子

为什么要抽象传输层?因为 Unity WebGL 是个特殊环境。接口注释写得很清楚(Transport.cs):

csharp 复制代码
/// HTTP 传输抽象。核心库提供 HttpClientTransport;Unity 侧可用 UnityWebRequestTransport 覆盖。
/// 抽象出此接口是为了让 Unity WebGL(HttpClient 不可用)能替换实现。
public interface IHttpTransport
{
    Task<FetchResult> SendAsync(string url, string body, int timeoutMs, CancellationToken ct = default);
}

Unity WebGL 里 HttpClient 会抛 PlatformNotSupportedException 原因是 WebGL 没有原生 socket,浏览器只开放了 fetch 风格的 UnityWebRequest API。所以必须替换传输实现。

UbaClient 构造函数接受可选的 transport 和 context:

csharp 复制代码
public UbaClient(UbaConfig config, IHttpTransport? transport = null, IContextProvider? context = null)
{
    Validate(config);
    _config = config;
    _config.Endpoint = (_config.Endpoint ?? "").TrimEnd('/');
    _context = context ?? new DefaultContextProvider();
    _batcher = new Batcher(_config, transport ?? new HttpClientTransport(),
                           () => _context.GetClientInfo(), Log);
}

默认走 HttpClientTransport(.NET Core / Mono / Unity 原生平台都能用);Unity WebGL 注入 UnityWebRequestTransport这就是依赖注入解决真实问题的范例------不是为了解耦而解耦,是为了对付平台差异。

HttpClientTransport静态共享 HttpClient (socket 复用),CancellationTokenSource 控超时,把"无外部取消的 OperationCanceledException"判为超时。它的 ParseResultpublic static,专门让 Unity 适配器复用响应解析。

3.2 UnityWebRequestTransport:把协程桥接到 Task

Unity 的 UnityWebRequest 必须在主线程 跑,而且是协程 风格。怎么跟 Task 接口对接?用 TaskCompletionSourceUnityWebRequestTransport.cs):

csharp 复制代码
public Task<FetchResult> SendAsync(string url, string body, int timeoutMs, CancellationToken ct = default)
{
    var tcs = new TaskCompletionSource<FetchResult>(TaskCreationOptions.RunContinuationsAsynchronously);
    _host.StartCoroutine(SendCoroutine(url, body, timeoutMs, tcs, ct));   // 在 MonoBehaviour host 上启协程
    return tcs.Task;
}

协程里每帧轮询 op.isDone,超时手动 abort,完成后 resolve TCS。还用 #if UNITY_2020_2_OR_NEWER 区分 Unity 版本的错误 API(req.result vs req.isHttpError/isNetworkError)。响应解码复用 HttpClientTransport.ParseResult,不重复造轮子。

3.3 Batcher:线程安全版

C# 是多线程环境(不像 JS 单线程),所以 Batcher 用了 lock + Interlocked 做并发控制(Batcher.cs):

csharp 复制代码
if (Interlocked.CompareExchange(ref _flushing, 1, 0) != 0)
    return new FlushResult { Success = true };        // 已有 flush 在跑,短路
List<ReportEvent> events;
lock (_lock) {
    if (_queue.Count == 0) { _flushing = 0; return new FlushResult { Success = true }; }
    events = new List<ReportEvent>(_queue);
    _queue.Clear();
}

重试逻辑跟 TS 完全一致(指数退避、4xx 除 429 不重试、耗尽即丢),只是内联在 batcher 里(TS 是独立模块)。C# 版没有 sendBeacon 等价物------因为 Unity / Godot 这些运行时不存在"tab 关闭丢数据"问题,这是 TS 独有的 browser 痛点。

3.4 零依赖:手写 JSON 序列化器

这是 C# SDK 最"较真"的地方。Uba.Core.csproj 零 PackageReference ------确认过,没有任何 NuGet 包。目标 netstandard2.0

代价是手写了一个 JSON 序列化器Json.cs)。它的注释说得很直白:

仅服务于本库的请求序列化...不实现通用 JSON,保持极简、可审计。

它发 camelCase key、跳过 null、手写一个 JsonRead"key" 子串扫描找标量值,不构建完整 DOM 。这一切只为了不引入 System.Text.JsonNewtonsoft.Json ------让 DLL 干净到能直接丢进 Unity 的 Assets/Plugins/

为什么这么执着于零依赖?因为 Unity 项目对依赖极度敏感:引入一个 JSON 库可能跟 Unity 自带的冲突,或者 IL2CPP 编译时出问题。零依赖 = 部署零摩擦。这是一个为游戏开发者量身定制的取舍,也是"游戏专项"不只是后端分析模型、连 SDK 都照顾到的体现。

四、Collector:把采集数据接住

Collector 在 第 1 篇 已概览过,这里聚焦采集链路特有的细节。

4.1 鉴权:加固实现

AppAuthenticatorapp_auth.go)有几个安全细节,每一个都解决一类真实攻击:

① Redis 只存哈希,不存明文密钥:

go 复制代码
type cachedApp struct {
    AppID      string `json:"app_id"`
    SecretHash string `json:"secret_hash"`  // sha256(app_secret) 的十六进制串
    Status     ubaV1.Application_Status
    TenantID   uint32 `json:"tenant_id"`
}

Redis 被脱库 ≠ 密钥泄露。

② constant-time 比较,防时序攻击:

go 复制代码
inHash := sha256Hex(appSecret)
if subtle.ConstantTimeCompare([]byte(inHash), []byte(app.SecretHash)) != 1 {
    return nil, collectorV1.ErrorIncorrectAppSecret(...)
}

普通字符串比较会在第一个不匹配字节提前返回,攻击者可据此逐字节爆破。subtle.ConstantTimeCompare 消耗固定时间。

③ 负缓存防穿透: 不存在的 appId 也写进 Redis(TTL 1 分钟)。攻击者拿假 appId 暴力试探,第一次会打穿到 DB,但之后 1 分钟内都被 Redis 挡住。正常 appId 缓存 10 分钟。

④ 可用性 ≠ 鉴权失败: gRPC 查应用失败时返回 InternalServerError,不返回 Unauthorized。一次网络抖动不该让客户端误以为密钥错了,触发改密钥的误操作。

⑤ 状态检查: 只有 Application_ON 的应用能通过,禁用的应用哪怕密钥对也拒。

4.2 字段回填的微妙之处

handleBehavior 有个值得讲的设计:业务扩展字段的回填策略。它优先取 behavior oneof 里的值 ,只有对"有存在性语义"的字段(空串 / nil / len==0 才算未设)才回退到顶层 ReportEvent

数值标量不回退SessionSeq / DurationMs / Quantity / Score)。源码注释解释:proto3 区分不了"未设"和"显式 0"------如果回退,会把合法的 Score=0(用户真打了 0 分)错改成顶层默认值。这是 proto3 的经典坑,作者识别到了并主动回避。

4.3 两个 Topic

Collector 发布到两个 Kafka topic(backend/pkg/topic/kafka.go):

go 复制代码
UbaEventRaw  = "uba_events_raw"    // 原始行为事件
UbaEventRisk = "uba_risk_events"   // 风险事件

行为事件和风险事件分 topic,便于下游独立消费 / 独立扩容。文件里还定义了 uba_events_enriched / uba_path_events / sync / alert / audit / DLQ topic 和消费组名------这些属于下游消费者,不在 collector 的发布路径。

第 1 篇 所述,Core 内消费这两个 topic 入库的 subscriber 尚未实现 。Collector 这端是通的,Core 也具备 BatchCreate 入库能力,连接它们的管线是生产化前要补的一环。

五、跨端契约的一致性

把三个端(TS SDK / C# SDK / Collector)放在一起看,会发现契约是有意保持对称的:

维度 TS SDK C# SDK Collector
batchSize 20 20 ---
flushInterval 5000ms 5000ms ---
maxRetries 3 3 ---
timeout 8000ms 8000ms 服务端 10s
退避 base * 2^n base * 2^n ---
4xx 处理 除 429 不重试 除 429 不重试 ---
凭证位置 body body 从 body 读
卸载兜底 sendBeacon 无(不需要) ---

两个细节值得强调:

  1. 客户端 timeout 8s < 服务端 10s------避免客户端先超时放弃、服务端还在处理造成"幽灵请求"。两个 SDK 的 Config 文档都标注了这点。
  2. 凭证在 body 是三端共同契约。这是 sendBeacon 能 work 的前提(前面讲过),也让 CORS 简单(不需要预检自定义 Header)。

六、小结:采集层的工程美学

回到开头的问题------采集层做了多少看不见的工程?答案是:

  • 可靠性:批量、指数退避重试、状态码精确区分、并发守卫。
  • 环境适配:Web 的 sendBeacon 兜底、Unity WebGL 的传输注入、隐私模式的内存兜底。
  • 安全:哈希存储、constant-time 比较、负缓存、tenantId 权威覆盖。
  • 契约对称:TS / C# / 服务端三端默认值和策略镜像,降低跨端心智成本。
  • 克制:C# 手写 JSON 序列化器追求零依赖,SDK 重试耗宁丢不堆。

这些每一项都不复杂,但组合起来才是一个生产级采集层。很多人低估了埋点 SDK 的难度------它不难在算法,难在"在不可靠的客户端环境里可靠地、克制地把数据送出去"。GoWind UBA 在这件事上做得相当扎实。


本文代码出自 go-wind-uba: Web SDK frontend/sdk/web/uba/src/、C# SDK sdk/csharp/src/、Collector backend/app/collector/service/internal/service/

相关推荐
Databend2 小时前
从湖仓升级为 Agent 时代的数据控制面,Snowflake 和 Databricks 有哪些布局
大数据·数据库·agent
非洲农业不发达3 小时前
windows终端体验大升级,让你拥有macos级别的美化
前端·后端
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十七):登录接口完善,登录页接口整合,解决跨域
前端·后端·ai编程
SamDeepThinking3 小时前
从源码到代码:MyBatis-Flex 与 MyBatis-Plus 的逐项对比
java·后端·程序员
shepherd1113 小时前
一文带你掌握 LLM、Token、Context、Prompt、RAG、MCP、Skill、Agent 等 AI 核心概念
人工智能·后端·ai编程
狂炫冰美式4 小时前
人均配了AI, 为什么公司还是没变快? 🤔 本质还是分布式系统问题
前端·后端·架构
她的男孩6 小时前
Spring Boot 接 Flowable 工作流:用 3 个注解搭一个请假审批流程
java·后端·架构
爱读源码的大都督6 小时前
Claude Code源码分析(三):为什么系统提示词中需要有tools呢?
前端·人工智能·后端
爱勇宝6 小时前
Claude Code 被曝暗藏“隐形检测”代码:封代理不是最可怕的,可怕的是你根本不知道它在干什么
前端·后端·程序员
ITOM运维行者7 小时前
从零搭建企业级服务器监控体系:踩坑实录与架构设计
前端·后端