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 的两个 SDKWeb 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——这是关键设计保证每条事件的结构一致consteventTimetoRFC3339();constmergedPropsmerge(this.superProperties,properties);// 公共属性 本次属性constpageUrlgetPageUrl();if(pageUrl!mergedProps.pageUrl)mergedProps.pageUrlpageUrl;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).length0?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.tsenqueue(event:ReportEvent):void{this.queue.push(event);if(this.queue.lengththis.opts.batchSize){// 默认 batchSize20voidthis.flush();}}触发 2定时到了——setInterval每flushInterval默认 5000ms调一次flush()。在 Node 环境下调.unref()避免 timer 卡住进程退出。flush()的核心是并发守卫this.flushingtrue;consteventsthis.queue.splice(0,this.queue.length);// 原子清空try{constbodythis.buildBody(events);constresultawaitsendWithRetry(this.opts.url,body,{...});// ...}finally{this.flushingfalse;}同一时间只有一个 in-flight 的 send。并发的flush()调用会短路返回。这个设计保证了不会因为重试风暴把服务端打爆。凭证在 body不在 HeaderbuildBody// body { appId, appSecret, events, clientInfo }这是整条链路的契约起点。后面会看到正是这个选择让 sendBeacon 兜底成为可能。2.3 重试与降级精确区分状态码sendWithRetry的重试策略值得细看retry.tsfunctionisNoRetryStatus(status:number):boolean{returnstatus400status500status!429;}// 循环 maxRetries1 次for(letattempt0;attemptcfg.maxRetries;attempt){resultawaitsendOnce(url,body,cfg);if(result.ok)returnresult;// 2xx成功if(isNoRetryStatus(result.status))returnresult;// 4xx除 429不重试// 否则退避重试if(attemptcfg.maxRetries){constdelaycfg.baseDelay*Math.pow(2,attempt);// 指数退避awaitsleep(delay);}}三个关键决策401/400 不重试——鉴权失败或参数错误重试也是错浪费请求。直接放弃。429 要重试——服务端限流过会儿可能就好了。5xx / 网络错误重试——指数退避baseDelay * 2^attempt默认 baseDelay1000ms。重试耗尽后怎么办丢掉不回填队列。这是有意的——避免无限堆积导致内存爆炸。源码注释明说宁可丢一批也不能让 SDK 内存无限增长。if(!result.success){droppedevents.length;// 直接计入 dropped}sendOnce用AbortController控超时默认 8000ms发credentials: omit不带 cookie避免干扰 app-secret 鉴权。2.4 页面卸载兜底sendBeacon这是 Web SDK 最有 browser 特色的部分。浏览器关闭页面时普通 fetch 会被取消唯有navigator.sendBeacon能在卸载时可靠发出去但它不能设 Header、不能等响应。SDK 注册了双事件兜底移动端 桌面端覆盖privatebindUnload():void{consthandler()this.batcher.flushBeacon();window.addEventListener(pagehide,handler);// 移动端友好window.addEventListener(beforeunload,handler);// 桌面端}flushBeacon是尽力而为batcher.tsflushBeacon():void{if(!this.opts.enableBeacon||this.queue.length0)return;consteventsthis.queue.splice(0,this.queue.length);constbodythis.buildBody(events);constoksendBeacon(this.opts.url,body);// 用 Blob 包装异步发出if(!ok){this.opts.log(warn,sendBeacon failed, events lost on unload);}}注意几个取舍sendBeacon 发的就是 fetch 同样的 body含 appId/appSecret——正是因为凭证在 bodysendBeacon 才能用。如果把凭证放 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 清掉同样有内存兜底。platformUA 嗅探返回web/ios/android/mini_program/node小程序靠window.wx.getSystemInfo存在性识别。热力图用的点击坐标和XPath也在这里算坐标加上 scroll offset 换算到文档坐标XPath 用于元素定位。自动埋点的 click 监听绑在捕获阶段addEventListener(click, handler, true)保证在业务逻辑之前触发。2.6 默认值一览记一下默认值跟 C# SDK 是镜像的后文验证参数默认值batchSize20flushInterval5000msmaxRetries3timeout8000msretryBaseDelay1000msenableBeacontrueautoTracktrue三、C# SDK零依赖核心 可注入传输C# SDK 在sdk/csharp/src/两个项目Uba.Core.NET Standard 2.0覆盖 Unity / Godot和Uba.UnityUnity 适配。API 跟 TS 版几乎同构Track/TrackBehavior/TrackRisk/Identify/SetSuperProperties/FlushAsync默认值也完全一样——这是有意为之方便团队跨端接入时心智一致。但有一个架构性差异C# 版的传输层和上下文都是可注入的。3.1 IHttpTransport为 Unity WebGL 留的口子为什么要抽象传输层因为 Unity WebGL 是个特殊环境。接口注释写得很清楚Transport.cs/// HTTP 传输抽象。核心库提供 HttpClientTransportUnity 侧可用 UnityWebRequestTransport 覆盖。/// 抽象出此接口是为了让 Unity WebGLHttpClient 不可用能替换实现。publicinterfaceIHttpTransport{TaskFetchResultSendAsync(stringurl,stringbody,inttimeoutMs,CancellationTokenctdefault);}Unity WebGL 里 HttpClient 会抛PlatformNotSupportedException。原因是 WebGL 没有原生 socket浏览器只开放了 fetch 风格的UnityWebRequestAPI。所以必须替换传输实现。UbaClient构造函数接受可选的 transport 和 contextpublicUbaClient(UbaConfigconfig,IHttpTransport?transportnull,IContextProvider?contextnull){Validate(config);_configconfig;_config.Endpoint(_config.Endpoint??).TrimEnd(/);_contextcontext??newDefaultContextProvider();_batchernewBatcher(_config,transport??newHttpClientTransport(),()_context.GetClientInfo(),Log);}默认走HttpClientTransport.NET Core / Mono / Unity 原生平台都能用Unity WebGL 注入UnityWebRequestTransport。这就是依赖注入解决真实问题的范例——不是为了解耦而解耦是为了对付平台差异。HttpClientTransport用静态共享 HttpClientsocket 复用CancellationTokenSource控超时把无外部取消的 OperationCanceledException判为超时。它的ParseResult是public static专门让 Unity 适配器复用响应解析。3.2 UnityWebRequestTransport把协程桥接到 TaskUnity 的UnityWebRequest必须在主线程跑而且是协程风格。怎么跟Task接口对接用TaskCompletionSourceUnityWebRequestTransport.cspublicTaskFetchResultSendAsync(stringurl,stringbody,inttimeoutMs,CancellationTokenctdefault){vartcsnewTaskCompletionSourceFetchResult(TaskCreationOptions.RunContinuationsAsynchronously);_host.StartCoroutine(SendCoroutine(url,body,timeoutMs,tcs,ct));// 在 MonoBehaviour host 上启协程returntcs.Task;}协程里每帧轮询op.isDone超时手动 abort完成后 resolve TCS。还用#if UNITY_2020_2_OR_NEWER区分 Unity 版本的错误 APIreq.resultvsreq.isHttpError/isNetworkError。响应解码复用HttpClientTransport.ParseResult不重复造轮子。3.3 Batcher线程安全版C# 是多线程环境不像 JS 单线程所以 Batcher 用了lockInterlocked做并发控制Batcher.csif(Interlocked.CompareExchange(ref_flushing,1,0)!0)returnnewFlushResult{Successtrue};// 已有 flush 在跑短路ListReportEventevents;lock(_lock){if(_queue.Count0){_flushing0;returnnewFlushResult{Successtrue};}eventsnewListReportEvent(_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.Json或Newtonsoft.Json——让 DLL 干净到能直接丢进 Unity 的Assets/Plugins/。为什么这么执着于零依赖因为 Unity 项目对依赖极度敏感引入一个 JSON 库可能跟 Unity 自带的冲突或者 IL2CPP 编译时出问题。零依赖 部署零摩擦。这是一个为游戏开发者量身定制的取舍也是游戏专项不只是后端分析模型、连 SDK 都照顾到的体现。四、Collector把采集数据接住Collector 在 第 1 篇 已概览过这里聚焦采集链路特有的细节。4.1 鉴权加固实现AppAuthenticatorapp_auth.go有几个安全细节每一个都解决一类真实攻击① Redis 只存哈希不存明文密钥typecachedAppstruct{AppIDstringjson:app_idSecretHashstringjson:secret_hash// sha256(app_secret) 的十六进制串Status ubaV1.Application_Status TenantIDuint32json:tenant_id}Redis 被脱库 ≠ 密钥泄露。② constant-time 比较防时序攻击inHash:sha256Hex(appSecret)ifsubtle.ConstantTimeCompare([]byte(inHash),[]byte(app.SecretHash))!1{returnnil,collectorV1.ErrorIncorrectAppSecret(...)}普通字符串比较会在第一个不匹配字节提前返回攻击者可据此逐字节爆破。subtle.ConstantTimeCompare消耗固定时间。③ 负缓存防穿透不存在的 appId 也写进 RedisTTL 1 分钟。攻击者拿假 appId 暴力试探第一次会打穿到 DB但之后 1 分钟内都被 Redis 挡住。正常 appId 缓存 10 分钟。④ 可用性 ≠ 鉴权失败gRPC 查应用失败时返回InternalServerError不返回Unauthorized。一次网络抖动不该让客户端误以为密钥错了触发改密钥的误操作。⑤ 状态检查只有Application_ON的应用能通过禁用的应用哪怕密钥对也拒。4.2 字段回填的微妙之处handleBehavior有个值得讲的设计业务扩展字段的回填策略。它优先取 behavior oneof 里的值只有对有存在性语义的字段空串 / nil / len0 才算未设才回退到顶层ReportEvent。但数值标量不回退SessionSeq/DurationMs/Quantity/Score。源码注释解释proto3 区分不了未设和显式 0——如果回退会把合法的Score0用户真打了 0 分错改成顶层默认值。这是 proto3 的经典坑作者识别到了并主动回避。4.3 两个 TopicCollector 发布到两个 Kafka topicbackend/pkg/topic/kafka.goUbaEventRawuba_events_raw// 原始行为事件UbaEventRiskuba_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 SDKC# SDKCollectorbatchSize2020—flushInterval5000ms5000ms—maxRetries33—timeout8000ms8000ms服务端 10s退避base * 2^nbase * 2^n—4xx 处理除 429 不重试除 429 不重试—凭证位置bodybody从 body 读卸载兜底sendBeacon无不需要—两个细节值得强调客户端 timeout 8s 服务端 10s——避免客户端先超时放弃、服务端还在处理造成幽灵请求。两个 SDK 的 Config 文档都标注了这点。凭证在 body是三端共同契约。这是 sendBeacon 能 work 的前提前面讲过也让 CORS 简单不需要预检自定义 Header。六、小结采集层的工程美学回到开头的问题——采集层做了多少看不见的工程答案是可靠性批量、指数退避重试、状态码精确区分、并发守卫。环境适配Web 的 sendBeacon 兜底、Unity WebGL 的传输注入、隐私模式的内存兜底。安全哈希存储、constant-time 比较、负缓存、tenantId 权威覆盖。契约对称TS / C# / 服务端三端默认值和策略镜像降低跨端心智成本。克制C# 手写 JSON 序列化器追求零依赖SDK 重试耗宁丢不堆。这些每一项都不复杂但组合起来才是一个生产级采集层。很多人低估了埋点 SDK 的难度——它不难在算法难在在不可靠的客户端环境里可靠地、克制地把数据送出去。GoWind UBA 在这件事上做得相当扎实。本文代码出自 go-wind-ubaWeb SDKfrontend/sdk/web/uba/src/、C# SDKsdk/csharp/src/、Collectorbackend/app/collector/service/internal/service/。