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
+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", () => {