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) */
}
}
}