feat(sdk): 传输快照信封+混合拆批+beacon预算+响应计数

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 14:07:45 +08:00
parent b0a8afcc19
commit 6432435c93
2 changed files with 334 additions and 32 deletions
+126 -27
View File
@@ -1,20 +1,40 @@
import { uuid } from "./util";
import { getVisitorId, getSessionId } from "./identity";
import { getPageSession } from "./page";
import { SDK_VERSION, SCHEMA_VERSION, BUILD_ID } from "./types";
import type { CtkConfig } from "./config";
export type TransportConfig = Required<CtkConfig>;
// 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试;
// 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。
/** 子批组:同 (visitor_id, session_id, page_id) 快照归属的事件聚合(组内 FIFO) */
interface Group {
visitor_id: string;
session_id: string;
events: object[];
}
// 批量传输(schema v2):满 batchSize 或 flushMs 到期即发;信封归属从事件顶层
// 快照字段读取(flush 不再重读身份——D02 的传输侧配套);混合归属队列按
// (visitor,session,page) 拆子批,组间顺序发送、组内独立重试(组A 耗尽不清扫
// 组B 的重试资格);无归属字段的事件(track 宿主对象——公开 API 不能静默丢)
// 归入兜底组,信封走 identity 旧路径。beacon 载荷超 60KB 预算降级 fetch
// keepalive;4xx(除 429)不重试;指数退避+抖动重试,耗尽按组内事件数计
// dropped;queueMax 溢出丢最旧。响应计数(T3 契约)accepted/duplicated/
// rejected 累计 + lastServerTime,仅内存诊断(阶段B sdk_diag 消费),解析
// 失败绝不影响送达判定(不重试不丢弃)。
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象——
// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。
// stringify 抛错(如循环引用)时已换出的组按事件数计 dropped。
export class Transport {
private q: object[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private retry = 0;
dropped = 0;
/** 响应计数(内存诊断,阶段B sdk_diag 消费;不入队不上报) */
accepted = 0;
duplicated = 0;
rejected = 0;
/** 最近一次服务端墙钟(ISO 串,供 offset 估计) */
lastServerTime: string | null = null;
constructor(public cfg: TransportConfig) {}
push(ev: object) {
@@ -43,39 +63,93 @@ export class Transport {
this.timer = null;
}
if (!this.q.length) return;
let n = 0;
const events = this.q;
this.q = []; // 换出即刻:后续 push 进新队;stringify 抛错时本组已丢,须计 dropped
// 按快照归属分组(key=visitor:session:page,取事件顶层字段;组内保持 FIFO,
// 组序=首现顺序)。归属字段校验 typeof string:track 宿主对象字段不可信,
// 非串值(含缺失)一律视为无归属走兜底组。
const groups: Group[] = [];
const indexOf = new Map<string, number>();
let fallback: object[] | null = null;
for (const ev of events) {
let v: unknown, s: unknown, p: unknown;
try {
v = (ev as { visitor_id?: unknown }).visitor_id;
s = (ev as { session_id?: unknown }).session_id;
p = (ev as { page_id?: unknown }).page_id;
} catch {
// 宿主 getter 抛错(track 可控对象红线):视为无归属走兜底组——
// stringify 时同 getter 再抛 → 兜底组按事件数落丢,归属组不受波及
(fallback ??= []).push(ev);
continue;
}
if (typeof v === "string" && typeof s === "string" && typeof p === "string") {
const key = `${v}:${s}:${p}`;
const existing = indexOf.get(key);
if (existing === undefined) {
indexOf.set(key, groups.length);
groups.push({ visitor_id: v, session_id: s, events: [ev] });
} else {
groups[existing].events.push(ev);
}
} else {
(fallback ??= []).push(ev);
}
}
if (fallback) {
// 兜底组(track 兼容):信封走 identity 旧路径——公开 API 不能静默丢。
// 惰性构造:全队列带归属字段时零 identity 调用(快照信封语义)。
try {
groups.push({
visitor_id: getVisitorId(),
session_id: getSessionId(),
events: fallback,
});
} catch {
this.dropped += fallback.length; // identity 已内部降级,此处仅极端环境兜底
}
}
for (const g of groups) this.dispatch(g, beacon);
}
/** 单组构 v2 信封并发送;本组任何异常按组内事件数计 dropped,不波及其他组。 */
private dispatch(g: Group, beacon: boolean) {
const n = g.events.length;
try {
const events = this.q;
this.q = [];
n = events.length; // 换出后先记数:stringify 抛错时本批已丢,须计 dropped
const body = JSON.stringify({
batch_id: uuid(),
site_id: this.cfg.siteId,
visitor_id: getVisitorId(),
session_id: getSessionId(),
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,
events: g.events,
});
if (beacon && navigator.sendBeacon) {
// 返回 false=浏览器拒收(队列满/载荷超 64KB 等)→ 整批丢,须计 dropped
const ok = navigator.sendBeacon(
this.cfg.endpoint,
new Blob([body], { type: "text/plain" })
);
if (!ok) this.dropped += n;
return;
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;
return;
}
}
this.send(body, events.length);
this.send(body, n);
} catch {
// 定时器/事件回调内的任何异常不得冒泡到宿主页;
// stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃
// stringify 抛错时本组已换出(宿主可控对象),按事件数计丢弃
this.dropped += n;
}
}
private async send(body: string, count: number) {
/** 组内独立重试(retry 参数传递——组A 耗尽不清扫组B 的资格):
* 4xx(非 429)不重试;退避重放同 body(同 event_id,服务端幂等去重);
* 耗尽按本组事件数计 dropped。 */
private async send(body: string, count: number, retry = 0) {
try {
const res = await fetch(this.cfg.endpoint, {
method: "POST",
@@ -84,21 +158,46 @@ export class Transport {
body,
});
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
this.retry = 0;
return; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误)
}
if (!res.ok) throw new Error(String(res.status));
this.retry = 0;
await this.tally(res); // 计数解析自包异常——失败不触发重试(批已送达)
} catch {
if (this.retry++ < this.cfg.maxRetry) {
const delay = 500 * 2 ** this.retry * (0.8 + Math.random() * 0.4);
if (retry < this.cfg.maxRetry) {
const delay = 500 * 2 ** (retry + 1) * (0.8 + Math.random() * 0.4);
setTimeout(() => {
this.send(body, count).catch(() => {});
this.send(body, count, retry + 1).catch(() => {});
}, delay); // 重放同一 body 字符串——同 event_id 重放,服务端幂等去重
} else {
this.retry = 0;
this.dropped += count; // 按事件数计丢弃
}
}
}
/** 响应计数(T3 契约 {code, data:{accepted,duplicated,rejected,request_id,
* server_time}, message}):全程自包异常——计数是诊断附庸,绝不反过来
* 影响发送语义(不重试不丢弃)。旧测试桩/异常环境无 json 方法时静默跳过。 */
private async tally(res: Response) {
try {
const j = (res as { json?: () => Promise<unknown> }).json;
if (typeof j !== "function") return;
const payload = (await j.call(res).catch(() => null)) as {
data?: {
accepted?: unknown;
duplicated?: unknown;
rejected?: unknown;
server_time?: unknown;
};
} | null;
const d = payload && typeof payload === "object" ? payload.data : null;
if (!d || typeof d !== "object") return;
if (typeof d.accepted === "number") this.accepted += d.accepted;
if (typeof d.duplicated === "number") this.duplicated += d.duplicated;
if (Array.isArray(d.rejected)) this.rejected += d.rejected.length;
if (typeof d.server_time === "string" && d.server_time)
this.lastServerTime = d.server_time;
} catch {
/* 静默(spec §4.5) */
}
}
}
+208 -5
View File
@@ -1,11 +1,26 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Transport } from "../src/transport";
import { mergeConfig } from "../src/config";
import { SDK_VERSION, SCHEMA_VERSION, BUILD_ID } from "../src/types";
import { getVisitorId, getSessionId } from "../src/identity";
// 快照信封验证(T8):identity 包一层 spy(委托真实实现,其余用例行为不变),
// 断言"全队列带归属字段的 flush 不再读身份"(信封归属=事件顶层快照)。
vi.mock("../src/identity", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/identity")>();
return {
...actual,
getVisitorId: vi.fn(actual.getVisitorId),
getSessionId: vi.fn(actual.getSessionId),
};
});
// uuid v4 格式(crypto.randomUUID 或 Math.random 兜底均为该形状)
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const res = (status: number) => ({ ok: status < 400, status }) as unknown as Response;
/** 可选 data:成功响应的 T3 契约体 {accepted,duplicated,rejected,request_id,server_time} */
const res = (status: number, data?: unknown) =>
({ ok: status < 400, status, json: () => Promise.resolve(data ?? null) }) as unknown as Response;
// 与 mergeConfig 默认值无关的测试专用 cfg(各用例按需覆盖)
function mkCfg(over: Record<string, unknown> = {}) {
@@ -28,6 +43,8 @@ describe("transport", () => {
vi.useFakeTimers(); // 默认连 Date 一起 fake
fetchMock = vi.fn().mockResolvedValue(res(200));
vi.stubGlobal("fetch", fetchMock);
vi.mocked(getVisitorId).mockClear(); // identity spy 调用记录按用例隔离
vi.mocked(getSessionId).mockClear();
});
afterEach(() => {
vi.unstubAllGlobals(); // 还原 fetch / navigator 桩
@@ -81,7 +98,7 @@ describe("transport", () => {
expect(body.context).toEqual({ device_type: "PC" }); // 引用透传而非拷贝拼接
});
it("500 失败退避重试后成功:retry 归零,body 逐字节不变(同 event_id 重放)", async () => {
it("500 失败退避重试后成功:body 逐字节不变(同 event_id 重放)", async () => {
fetchMock.mockResolvedValueOnce(res(500)).mockResolvedValueOnce(res(200));
const t = new Transport(mkCfg({ batchSize: 2 }));
t.push({ event_id: "e1" });
@@ -91,7 +108,6 @@ describe("transport", () => {
await vi.advanceTimersByTimeAsync(2000); // 第 1 次退避 500*2^1*jitter ∈ [800,1200]
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1].body).toBe(fetchMock.mock.calls[0][1].body);
expect((t as unknown as { retry: number }).retry).toBe(0);
});
it("400 不重试(参数错,重试无意义)", async () => {
@@ -100,7 +116,6 @@ describe("transport", () => {
t.push({ event_id: "e1" });
await vi.advanceTimersByTimeAsync(10000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((t as unknown as { retry: number }).retry).toBe(0);
});
it("403 不重试(权限错,重试只会掩盖配置问题)", async () => {
@@ -130,7 +145,6 @@ describe("transport", () => {
await vi.advanceTimersByTimeAsync(2000); // 第 1 次重试(∈[800,1200])后再失败
expect(fetchMock).toHaveBeenCalledTimes(2); // 初发 1 + 重试 1,共 2 次
expect(t.dropped).toBe(2); // 按入批事件数计,而非按批计
expect((t as unknown as { retry: number }).retry).toBe(0); // 归零供下批复用
});
it("queueMax 溢出丢最旧", () => {
@@ -202,6 +216,195 @@ describe("transport", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]);
});
// ---- T8:快照信封 / 混合拆批 / beacon 预算 / 响应计数 ----
it("flush 从队首事件快照取归属:信封 visitor/session=事件顶层字段,identity 零调用", () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
t.push({ event_id: "e1", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" });
t.push({ event_id: "e2", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" });
t.flush();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(getSessionId).not.toHaveBeenCalled(); // 快照归属——flush 不再读身份(消灭 D02 传输侧)
expect(getVisitorId).not.toHaveBeenCalled();
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.visitor_id).toBe("v-snap");
expect(body.session_id).toBe("s-snap");
});
it("混合归属队列拆子批:两组 (visitor,session,page) → 两次独立请求各含正确信封(组内 FIFO)", () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.flush();
expect(fetchMock).toHaveBeenCalledTimes(2);
const b1 = JSON.parse(fetchMock.mock.calls[0][1].body);
const b2 = JSON.parse(fetchMock.mock.calls[1][1].body);
expect(b1.visitor_id).toBe("v1"); // 信封归属=组 key(组首事件快照)
expect(b1.session_id).toBe("s1");
expect(b1.events.map((e: { event_id: string }) => e.event_id)).toEqual(["a1", "a2"]); // 组内 FIFO
expect(b2.visitor_id).toBe("v2");
expect(b2.session_id).toBe("s2");
expect(b2.events.map((e: { event_id: string }) => e.event_id)).toEqual(["b1"]);
expect(b1.batch_id).not.toBe(b2.batch_id); // 每子批独立 batch_id
});
it("无归属字段的兼容事件(track 宿主对象)走兜底组:信封从 identity 读,不静默丢", () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
t.push({ event_id: "t1" }); // track 命令原样对象——无归属字段
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.flush();
expect(fetchMock).toHaveBeenCalledTimes(2); // 归属组 + 兜底组
const b1 = JSON.parse(fetchMock.mock.calls[0][1].body);
const b2 = JSON.parse(fetchMock.mock.calls[1][1].body); // 兜底组排后
expect(b1.visitor_id).toBe("v1");
expect(b1.events).toEqual([{ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }]);
expect(b2.visitor_id).toMatch(UUID_RE); // getVisitorId() 旧路径构造信封
expect(b2.session_id).toMatch(UUID_RE); // getSessionId() 旧路径
expect(b2.events).toEqual([{ event_id: "t1" }]); // 公开 API 不能静默丢
expect(getSessionId).toHaveBeenCalledTimes(1); // 惰性求值:仅兜底组读一次
});
it("子批独立重试:组A 500 失败重试,组B 200 不重试", async () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
fetchMock
.mockResolvedValueOnce(res(500)) // 组A 初发
.mockResolvedValueOnce(res(200)) // 组B(成功不重试)
.mockResolvedValueOnce(res(200)); // 组A 重试
t.flush();
expect(fetchMock).toHaveBeenCalledTimes(2); // 组间顺序:初发各一次
await vi.advanceTimersByTimeAsync(2000); // 组A 退避 ∈[800,1200]
expect(fetchMock).toHaveBeenCalledTimes(3); // 仅组A 重试
expect(fetchMock.mock.calls[2][1].body).toBe(fetchMock.mock.calls[0][1].body); // 同 body 重放
expect(t.dropped).toBe(0);
});
it("子批独立重试资格:组A 耗尽不清扫组B 的重试计数(组B 仍可自救)", async () => {
// 抖动钳制:两组退避同为 1000ms——fake timer 同刻按调度序(A 先)触发,
// 否则 Math.random 会让 A/B 重试顺序随机翻转(响应序列错绑到另一组)
const rnd = vi.spyOn(Math, "random").mockReturnValue(0.5);
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 1 }));
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
fetchMock
.mockResolvedValueOnce(res(500)) // A 初发
.mockResolvedValueOnce(res(500)) // B 初发
.mockResolvedValueOnce(res(500)) // A 重试 → 耗尽(dropped+=2)
.mockResolvedValueOnce(res(200)); // B 重试 → 成功
t.flush();
await vi.advanceTimersByTimeAsync(2000); // 两组退避均已到期
expect(fetchMock).toHaveBeenCalledTimes(4); // 共享计数器下 B 会错失自己的重试(仅 3 次)
expect(t.dropped).toBe(2); // 仅组A 的 2 条落丢
expect(JSON.parse(fetchMock.mock.calls[3][1].body).visitor_id).toBe("v2"); // B 的重试成功送达
rnd.mockRestore();
});
it("beacon 载荷超 60KB 预算 → 降级 fetch keepalive;失败耗尽按事件数计 dropped", async () => {
const beacon = vi.fn().mockReturnValue(true);
vi.stubGlobal("navigator", { sendBeacon: beacon });
fetchMock.mockResolvedValue(res(500)); // 降级后恒失败
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 0 }));
t.push({
event_id: "big",
visitor_id: "v1",
session_id: "s1",
page_id: "p1",
properties: { blob: "x".repeat(65_000) }, // 信封+事件 > 60KB
});
t.flush(true);
expect(beacon).not.toHaveBeenCalled(); // 超预算不走信标
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0][1];
expect(init.keepalive).toBe(true);
await vi.advanceTimersByTimeAsync(100);
expect(t.dropped).toBe(1); // fetch 亦失败(maxRetry=0 耗尽)按事件数计
});
it("响应解析:accepted/duplicated/rejected 累计进 counts,server_time 记录(T3 契约)", async () => {
const t = new Transport(mkCfg({ batchSize: 1 }));
fetchMock.mockResolvedValueOnce(
res(200, {
code: 200,
data: {
accepted: 2,
duplicated: 1,
rejected: [{ index: 0, event_id: "e1", reason: "invalid_field" }],
request_id: "r1",
server_time: "2026-09-18T10:00:00+00:00",
},
message: "ok",
})
);
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
await vi.advanceTimersByTimeAsync(0); // 微任务冲刷(json promise)
expect(t.accepted).toBe(2);
expect(t.duplicated).toBe(1);
expect(t.rejected).toBe(1); // rejected 明细数组长度累计
expect(t.lastServerTime).toBe("2026-09-18T10:00:00+00:00"); // 供 offset 估计
fetchMock.mockResolvedValueOnce(
res(200, {
code: 200,
data: { accepted: 1, duplicated: 0, rejected: [], request_id: "r2", server_time: "2026-09-18T10:00:05+00:00" },
message: "ok",
})
);
t.push({ event_id: "e2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
await vi.advanceTimersByTimeAsync(0);
expect(t.accepted).toBe(3); // 跨批累计
expect(t.rejected).toBe(1);
expect(t.lastServerTime).toBe("2026-09-18T10:00:05+00:00"); // 最新值
});
it("响应体非 JSON 不影响送达判定:不重试不丢弃,计数不动", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.reject(new Error("not json")),
} as unknown as Response);
const t = new Transport(mkCfg({ batchSize: 1 }));
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledTimes(1); // 计数解析失败不触发重试(批已送达)
expect(t.dropped).toBe(0);
expect(t.accepted).toBe(0);
});
it("v2 信封形态:恒 schema_version=2 + sdk_version + build_id(sent_at 保留)", () => {
const t = new Transport(mkCfg({ batchSize: 1 }));
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.schema_version).toBe(SCHEMA_VERSION); // types.ts 单一来源
expect(body.schema_version).toBe(2); // v1 兼容形态不再产生
expect(body.sdk_version).toBe(SDK_VERSION);
expect(body.build_id).toBe(BUILD_ID); // vitest define 注入 "vitest"
expect(body.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); // sent_at 已有保留
});
it("宿主对象归属字段 getter 抛错:不冒泡,兜底组落丢,归属组照发", () => {
const t = new Transport(mkCfg({ batchSize: 10 }));
const evil = { event_id: "evil" } as { event_id: string; visitor_id?: string };
Object.defineProperty(evil, "visitor_id", {
// 分组读取/stringify 都会触发——宿主可控对象红线(spec §4.5)
get() {
throw new Error("getter boom");
},
enumerable: true,
});
t.push(evil);
t.push({ event_id: "ok" }); // 与 evil 同落兜底组(无归属字段)
t.push({ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" });
expect(() => t.flush()).not.toThrow(); // 红线:异常不得冒泡
expect(t.dropped).toBe(2); // 兜底组(evil+ok)stringify 抛错按组内事件数计
expect(fetchMock).toHaveBeenCalledTimes(1); // 归属组不受宿主坏对象波及
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([
{ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" },
]);
});
});
describe("mergeConfig", () => {