fix(sdk): §4.5 红线补全(act/重放包裹)+ beacon-false 丢批计数 + 空闲负向测试 + es2020

- events.ts:act 回调与 visibilitychange 监听体包 try/catch(字面红线)
- transport.ts:sendBeacon 返回 false(64KB 超限等)按批事件数计 dropped
  ——与 stringify 丢包计数对称的缺口
- index.ts:顶层队列重放双层包裹,单个坏条目只跳过自身不吞后续 init/track
- 测试:30s 空闲负向(active_ms=29000 后停止)、beacon-false 丢批;44→46
- build.mjs target es2018→es2020(代码依赖 globalThis,目标诚实化)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 16:40:35 +08:00
parent dc13ae6e0e
commit 21c3cb2423
6 changed files with 54 additions and 6 deletions
+1 -1
View File
@@ -2,5 +2,5 @@ import { build } from "esbuild";
await build({
entryPoints: ["src/index.ts"],
bundle: true, minify: true, format: "iife",
outfile: "dist/v1.js", target: ["es2018"],
outfile: "dist/v1.js", target: ["es2020"], // 代码依赖 globalThis(es2018 目标名不副实)
});
+10 -2
View File
@@ -10,7 +10,11 @@ export function startPageTracking(t: Transport) {
let activeMs = 0;
let lastAct = Date.now();
const act = () => {
lastAct = Date.now();
try {
lastAct = Date.now();
} catch {
/* 静默(spec §4.5 红线:监听器异常不得冒泡到宿主页) */
}
};
["pointerdown", "keydown", "wheel", "touchstart"].forEach((e) =>
document.addEventListener(e, act, { passive: true, capture: true })
@@ -59,7 +63,11 @@ export function startPageTracking(t: Transport) {
}
};
document.addEventListener("visibilitychange", () => {
if (document.hidden) close();
try {
if (document.hidden) close();
} catch {
/* 静默:document.hidden 读取本身也不得冒泡异常(close 内部已自包) */
}
});
window.addEventListener("pagehide", close);
}
+15 -2
View File
@@ -49,6 +49,19 @@ function dispatch(cmd: string, arg?: unknown) {
}
}
// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队
// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队。
// 宿主队列数据不可信(非可迭代项等)——模块顶层也不得抛(spec §4.5 唯一未保护路径);
// 单个坏条目只跳过自身,不吞掉后续排队的 init/track
g.ctk = dispatch;
q.forEach(([c, a]) => dispatch(c, a));
try {
q.forEach((entry) => {
try {
const [c, a] = entry as Cmd;
dispatch(c, a);
} catch {
/* 静默:坏条目跳过,继续重放 */
}
});
} catch {
/* 静默:q 本身不可迭代/无 forEach——放弃重放,入口已接管 */
}
+3 -1
View File
@@ -49,10 +49,12 @@ export class Transport {
events,
});
if (beacon && navigator.sendBeacon) {
navigator.sendBeacon(
// 返回 false=浏览器拒收(队列满/载荷超 64KB 等)→ 整批丢,须计 dropped
const ok = navigator.sendBeacon(
this.cfg.endpoint,
new Blob([body], { type: "text/plain" })
);
if (!ok) this.dropped += n;
return;
}
this.send(body, events.length);
+15
View File
@@ -118,6 +118,21 @@ describe("events / startPageTracking", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("30s 无活动不累计:单次活动后空闲 35s → active_ms 恰为 29000 后停止", async () => {
window.history.pushState({}, "", "/pidle");
const t = mkTransport("site-idle");
startPageTracking(t);
document.dispatchEvent(new Event("pointerdown")); // 仅此一次活动
await vi.advanceTimersByTimeAsync(35_000);
window.dispatchEvent(new Event("pagehide"));
const bodies = await beaconBodies("site-idle");
expect(bodies).toHaveLength(1);
const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
// tick 于 +1s..+29s 时 Date.now()-lastAct<30000 成立(+30s 起等于 30000 不成立)
expect(stay.properties.active_ms).toBe(29000);
});
it("隐藏→恢复→再隐藏:两段 stay 各自正确(第二段从 0 重新累计)", async () => {
window.history.pushState({}, "", "/p6");
const t = mkTransport("site-t6");
+10
View File
@@ -177,6 +177,16 @@ describe("transport", () => {
expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃
});
it("sendBeacon 返回 false(队列满/载荷超限)按本批事件数计 dropped", () => {
vi.stubGlobal("navigator", { sendBeacon: vi.fn().mockReturnValue(false) });
const t = new Transport(mkCfg({ batchSize: 10 }));
t.push({ event_id: "e1" });
t.push({ event_id: "e2" });
t.flush(true);
expect(t.dropped).toBe(2); // 信标被浏览器拒收(64KB 上限等)→ 整批丢
expect(fetchMock).not.toHaveBeenCalled();
});
it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => {
const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 }));
const bad: { event_id: string; self?: unknown } = { event_id: "bad" };