feat(sdk): 页面浏览/有效停留/事件入口,打包≤15KB
- events.ts:page_view/page_stay(有效停留=可见+30s 内有活动), close 双触发(hidden+pagehide)靠 activeMs 清零不重发 - index.ts:IIFE 入口,队列重放前先替换 g.ctk=dispatch; 首触归因——__ctk_ctx 除设备环境外带 referrer_domain/utm/path - transport.ts:stringify 抛错(track 宿主可控对象)按已换出 批次事件数计 dropped(Task 14 审查加固) - demo 双页互跳;gzip 2209B ≤ 15360B;测试 35→44 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { Transport } from "./transport";
|
||||
import { pageContext } from "./context";
|
||||
import { uuid } from "./util";
|
||||
|
||||
// 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs;
|
||||
// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。
|
||||
// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。
|
||||
// 红线:任何回调异常不得冒泡到宿主页(spec §4.5)。
|
||||
export function startPageTracking(t: Transport) {
|
||||
let activeMs = 0;
|
||||
let lastAct = Date.now();
|
||||
const act = () => {
|
||||
lastAct = Date.now();
|
||||
};
|
||||
["pointerdown", "keydown", "wheel", "touchstart"].forEach((e) =>
|
||||
document.addEventListener(e, act, { passive: true, capture: true })
|
||||
);
|
||||
setInterval(() => {
|
||||
try {
|
||||
if (!document.hidden && Date.now() - lastAct < 30_000) activeMs += 1000;
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}, 1000);
|
||||
// emit 内 pageContext() 只取一次(pc)复用于 path/title——避免多次读取间
|
||||
// location 瞬变导致同一事件内字段不一致;page_view 另将 pc 传入复用
|
||||
const emit = (
|
||||
event_name: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
pc = pageContext()
|
||||
) => {
|
||||
t.push({
|
||||
event_id: uuid(),
|
||||
event_name,
|
||||
occurred_at: new Date().toISOString(),
|
||||
path: pc.path,
|
||||
title: pc.title,
|
||||
properties,
|
||||
});
|
||||
};
|
||||
const pc = pageContext();
|
||||
emit(
|
||||
"page_view",
|
||||
{ referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm },
|
||||
pc
|
||||
);
|
||||
const close = () => {
|
||||
try {
|
||||
// path 必须与 page_view 同源同清洗(pageContext().path 含 query),
|
||||
// 否则带 UTM 进落地页时服务端"page_stay 合并进相邻 page_view"匹配失败
|
||||
if (activeMs > 0) {
|
||||
const path = pageContext().path;
|
||||
emit("page_stay", { active_ms: activeMs, path });
|
||||
}
|
||||
activeMs = 0;
|
||||
t.flush(true);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) close();
|
||||
});
|
||||
window.addEventListener("pagehide", close);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { uuid } from "./util";
|
||||
import { mergeConfig } from "./config";
|
||||
import { Transport } from "./transport";
|
||||
import { startPageTracking } from "./events";
|
||||
import { buildEnvContext, pageContext } from "./context";
|
||||
import type { CtkConfig } from "./config";
|
||||
|
||||
// IIFE 打包入口(build.mjs):模块加载即接管全局 ctk 命令队列并重放。
|
||||
// 红线:dispatch 内任何异常静默,绝不冒泡到宿主页(spec §4.5)。
|
||||
type Cmd = [string, unknown?];
|
||||
type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
|
||||
const g = globalThis as unknown as {
|
||||
ctk?: CtkFn;
|
||||
__ctk_ctx?: unknown;
|
||||
};
|
||||
const q: Cmd[] = (g.ctk?.q as Cmd[] | undefined) || [];
|
||||
let transport: Transport | null = null;
|
||||
|
||||
function dispatch(cmd: string, arg?: unknown) {
|
||||
try {
|
||||
if (cmd === "init" && !transport) {
|
||||
const cfg = mergeConfig((arg ?? {}) as CtkConfig);
|
||||
// 首触归因:批次 context 除设备环境外必须带 referrer_domain/utm/path——
|
||||
// 服务端只从 context 读首触,缺了则首触/会话来源恒空
|
||||
const pc = pageContext();
|
||||
g.__ctk_ctx = {
|
||||
...buildEnvContext(),
|
||||
referrer_domain: pc.referrer_domain,
|
||||
utm: pc.utm,
|
||||
path: pc.path,
|
||||
};
|
||||
transport = new Transport(cfg);
|
||||
transport.push({
|
||||
event_id: uuid(),
|
||||
event_name: "sdk_loaded",
|
||||
occurred_at: new Date().toISOString(),
|
||||
path: pc.path,
|
||||
title: pc.title,
|
||||
properties: { sdk_version: "0.1.0" },
|
||||
});
|
||||
startPageTracking(transport);
|
||||
} else if (cmd === "track" && transport) {
|
||||
transport.push(arg as object);
|
||||
} else if (cmd === "flush" && transport) {
|
||||
transport.flush();
|
||||
}
|
||||
} catch {
|
||||
/* 任何异常静默,绝不冒泡到宿主页(spec §4.5) */
|
||||
}
|
||||
}
|
||||
|
||||
// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队
|
||||
g.ctk = dispatch;
|
||||
q.forEach(([c, a]) => dispatch(c, a));
|
||||
@@ -7,8 +7,8 @@ export type TransportConfig = Required<CtkConfig>;
|
||||
// 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试;
|
||||
// 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。
|
||||
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
|
||||
// 审查留档:events 目前均为 SDK 自产可序列化对象;若未来引入受控外的 events
|
||||
// 来源,需处理 stringify 抛错时已换出批次的计数(当前静默丢弃不计 dropped)。
|
||||
// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象——
|
||||
// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。
|
||||
export class Transport {
|
||||
private q: object[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -33,9 +33,11 @@ export class Transport {
|
||||
this.timer = null;
|
||||
}
|
||||
if (!this.q.length) return;
|
||||
let n = 0;
|
||||
try {
|
||||
const events = this.q;
|
||||
this.q = [];
|
||||
n = events.length; // 换出后先记数:stringify 抛错时本批已丢,须计 dropped
|
||||
const body = JSON.stringify({
|
||||
batch_id: uuid(),
|
||||
site_id: this.cfg.siteId,
|
||||
@@ -55,7 +57,9 @@ export class Transport {
|
||||
}
|
||||
this.send(body, events.length);
|
||||
} catch {
|
||||
// 定时器/事件回调内的任何异常不得冒泡到宿主页
|
||||
// 定时器/事件回调内的任何异常不得冒泡到宿主页;
|
||||
// stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃
|
||||
this.dropped += n;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user