diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts index e91e98b..df27d5c 100644 --- a/sdk/src/transport.ts +++ b/sdk/src/transport.ts @@ -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); // 计数解析自包异常——失败不触发重试(批已送达) diff --git a/sdk/test/transport.test.ts b/sdk/test/transport.test.ts index c3c58bb..beea7c7 100644 --- a/sdk/test/transport.test.ts +++ b/sdk/test/transport.test.ts @@ -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", () => {