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); // 计数解析自包异常——失败不触发重试(批已送达)
+44 -3
View File
@@ -110,20 +110,22 @@ describe("transport", () => {
expect(fetchMock.mock.calls[1][1].body).toBe(fetchMock.mock.calls[0][1].body);
});
it("400 不重试(参数错,重试无意义)", async () => {
it("400 不重试(参数错,重试无意义),事件落 dropped 桶(四桶对账无幽灵丢失)", async () => {
fetchMock.mockResolvedValueOnce(res(400));
const t = new Transport(mkCfg({ batchSize: 1 }));
t.push({ event_id: "e1" });
await vi.advanceTimersByTimeAsync(10000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(t.dropped).toBe(1); // M1:4xx 不重试但必须计丢弃
});
it("403 不重试(权限错,重试只会掩盖配置问题)", async () => {
it("403 不重试(权限错,重试只会掩盖配置问题),事件落 dropped 桶", async () => {
fetchMock.mockResolvedValueOnce(res(403));
const t = new Transport(mkCfg({ batchSize: 1 }));
t.push({ event_id: "e1" });
await vi.advanceTimersByTimeAsync(10000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(t.dropped).toBe(1);
});
it("429 要重试(限流是暂态)", async () => {
@@ -313,7 +315,8 @@ describe("transport", () => {
visitor_id: "v1",
session_id: "s1",
page_id: "p1",
properties: { blob: "x".repeat(65_000) }, // 信封+事件 > 60KB
// 须落在 (60_000, 64_000] 窗口:超信标预算触发降级,又不触发 fetch 64KB 裁剪守卫
properties: { blob: "x".repeat(62_000) },
});
t.flush(true);
expect(beacon).not.toHaveBeenCalled(); // 超预算不走信标
@@ -405,6 +408,44 @@ describe("transport", () => {
{ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" },
]);
});
// ---- I1/M1:超预算组送达闭环 + 4xx 丢弃计数 ----
it("超 64KB 组丢最旧闭环:裁剪至预算内送达,最旧事件计 dropped", () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
const big = (id: string, n: number) => ({
event_id: id,
visitor_id: "v1",
session_id: "s1",
page_id: "p1",
properties: { blob: "x".repeat(n) },
});
t.push(big("e1", 33_000));
t.push(big("e2", 33_000));
t.push(big("e3", 2_000));
t.flush();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.events.map((e: { event_id: string }) => e.event_id)).toEqual(["e2", "e3"]); // e1(最旧)被裁
expect(t.dropped).toBe(1); // 被裁的最旧事件逐条计 dropped
});
it("单事件超 64KB:整组裁空不发送不空转重试,全量计 dropped", async () => {
fetchMock.mockResolvedValue(res(500)); // 若误发送会进入重试空转——用调用数守卫
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 3 }));
t.push({
event_id: "huge",
visitor_id: "v1",
session_id: "s1",
page_id: "p1",
properties: { blob: "x".repeat(70_000) },
});
t.flush();
expect(fetchMock).not.toHaveBeenCalled(); // keepalive 64KiB 硬限确定性失败——不空转
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchMock).not.toHaveBeenCalled();
expect(t.dropped).toBe(1); // 整组丢弃完毕
});
});
describe("mergeConfig", () => {