feat(sdk): 传输快照信封+混合拆批+beacon预算+响应计数
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+126
-27
@@ -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) */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user