fix(sdk): 超预算组丢最旧闭环+4xx丢弃计数(I1/M1)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 14:27:26 +08:00
parent 6432435c93
commit 08495bc365
2 changed files with 94 additions and 27 deletions
+50 -24
View File
@@ -18,10 +18,12 @@ interface Group {
// (visitor,session,page) 拆子批,组间顺序发送、组内独立重试(组A 耗尽不清扫
// 组B 的重试资格);无归属字段的事件(track 宿主对象——公开 API 不能静默丢)
// 归入兜底组,信封走 identity 旧路径。beacon 载荷超 60KB 预算降级 fetch
// keepalive;4xx(除 429)不重试;指数退避+抖动重试,耗尽按组内事件数计
// dropped;queueMax 溢出丢最旧。响应计数(T3 契约)accepted/duplicated/
// rejected 累计 + lastServerTime,仅内存诊断(阶段B sdk_diag 消费),解析
// 失败绝不影响送达判定(不重试不丢弃)。
// keepalive,fetch 前另有 64KiB 硬限守卫(spec §1.5):超限组丢最旧事件
// (逐条计 dropped)直至预算内,组空即整组丢弃完毕——确定性失败不空转重试。
// 4xx(除 429)不重试但计 dropped(四桶对账无幽灵丢失);指数退避+抖动重试,
// 耗尽按组内事件数计 dropped;queueMax 溢出丢最旧。响应计数(T3 契约)
// accepted/duplicated/rejected 累计 + lastServerTime,仅内存诊断(阶段B
// sdk_diag 消费),解析失败绝不影响送达判定(不重试不丢弃)。
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象——
// stringify 抛错(如循环引用)时已换出的组按事件数计 dropped。
@@ -112,37 +114,60 @@ export class Transport {
for (const g of groups) this.dispatch(g, beacon);
}
/** 单组构 v2 信封并发送;本组任何异常按组内事件数计 dropped,不波及其他组。 */
/** 单组构 v2 信封并发送;本组任何异常按组内剩余事件数计 dropped,不波及其他组。 */
private dispatch(g: Group, beacon: boolean) {
const n = g.events.length;
try {
const body = JSON.stringify({
batch_id: uuid(),
site_id: this.cfg.siteId,
schema_version: SCHEMA_VERSION,
sdk_version: SDK_VERSION,
build_id: BUILD_ID,
visitor_id: g.visitor_id,
session_id: g.session_id,
sent_at: new Date().toISOString(),
// 由 index.ts(Task 15)注入;未注入时 undefined——JSON 序列化自然缺键,照发
context: (globalThis as { __ctk_ctx?: unknown }).__ctk_ctx,
events: g.events,
});
let body = this.envelope(g);
if (beacon && navigator.sendBeacon) {
const blob = new Blob([body], { type: "text/plain" });
// 60KB 发送预算(为浏览器信标 64KB 硬限留余量):超限降级 fetch keepalive
if (blob.size <= 60_000) {
// 返回 false=浏览器拒收(队列满/载荷超限)→ 整组丢,须计 dropped
if (!navigator.sendBeacon(this.cfg.endpoint, blob)) this.dropped += n;
if (!navigator.sendBeacon(this.cfg.endpoint, blob)) this.dropped += g.events.length;
return;
}
}
this.send(body, n);
// fetch keepalive 64KiB 硬限守卫(spec §1.5 送达闭环):超限组是确定性失败,
// 不空转重试——丢最旧事件(shift)计 dropped,重构造直至预算内;组空=
// 整组丢弃完毕,不再发送。只作用于 fetch 路径(beacon 分支已按 60KB 自守)。
while (g.events.length && this.sizeOf(body) > 64_000) {
g.events.shift();
this.dropped++;
body = this.envelope(g);
}
if (!g.events.length) return;
this.send(body, g.events.length);
} catch {
// 定时器/事件回调内的任何异常不得冒泡到宿主页;
// stringify 抛错时本组已换出(宿主可控对象),按事件数计丢弃
this.dropped += n;
// stringify 抛错时按组内剩余事件数计丢弃(已 shift 裁掉的在裁时已计,不重复)
this.dropped += g.events.length;
}
}
/** 组 → v2 信封序列化(64KB 裁剪循环内重构造共用)。 */
private envelope(g: Group): string {
return JSON.stringify({
batch_id: uuid(),
site_id: this.cfg.siteId,
schema_version: SCHEMA_VERSION,
sdk_version: SDK_VERSION,
build_id: BUILD_ID,
visitor_id: g.visitor_id,
session_id: g.session_id,
sent_at: new Date().toISOString(),
// 由 index.ts(Task 15)注入;未注入时 undefined——JSON 序列化自然缺键,照发
context: (globalThis as { __ctk_ctx?: unknown }).__ctk_ctx,
events: g.events,
});
}
/** 序列化字节数(Blob 计 UTF-8 字节,多字节标题/utm 精确;无 Blob 环境降级
* 码元长——ASCII 时等值,宁可低估不抛异常)。 */
private sizeOf(body: string): number {
try {
return new Blob([body]).size;
} catch {
return body.length;
}
}
@@ -158,7 +183,8 @@ export class Transport {
body,
});
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
return; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误)
this.dropped += count; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误)——但事件须落桶(四桶对账,M1)
return;
}
if (!res.ok) throw new Error(String(res.status));
await this.tally(res); // 计数解析自包异常——失败不触发重试(批已送达)