From f24060de871c64fcaa10851a25c0fed484e842ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 14:56:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=200.2.0=E6=8E=A5=E7=BA=BF=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=BD=92=E5=B1=9E=E5=BF=AB=E7=85=A7/=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=E5=81=9C=E7=95=99/=E6=B8=85=E6=B4=97=E5=AF=B9?= =?UTF-8?q?=E9=BD=90/=E5=93=8D=E5=BA=94=E5=A5=91=E7=BA=A6=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- sdk/src/events.ts | 13 ++++++------ sdk/src/index.ts | 16 +++++++++++++-- sdk/test/index.test.ts | 45 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/sdk/src/events.ts b/sdk/src/events.ts index 541304b..d91eea2 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -2,11 +2,11 @@ import type { Transport } from "./transport"; import { pageContext } from "./context"; import { uuid } from "./util"; -// 来源信号(Navigation Timing L2):startPageTracking 仅 init 时调用一次, -// 读一次 Navigation Entry 即冻结进 page_view——nav_type ∈ navigate/reload/ +// 来源信号(Navigation Timing L2):page_view 构造时读取, +// 读一次 Navigation Entry 即冻结——nav_type ∈ navigate/reload/ // back_forward(浏览器不给则空串);activationStart>0 = 页面由预渲染激活 // (AI 搜索/聚合器预加载的强指纹)。任何环境异常静默(spec §4.5 红线)。 -// T6 起导出供 page.ts 的 page_view 复用(T7 拆分后保留于此)。 +// page.ts 的 page_view 复用本函数(T6 起导出)。 export function navSignals(): { nav_type: string; prerendering: boolean } { try { const nav = performance.getEntriesByType("navigation")[0] as @@ -19,9 +19,10 @@ export function navSignals(): { nav_type: string; prerendering: boolean } { // T7 拆分:有效停留(区间制重做)移至 stay.ts(startStayTracking 与 PageSession // 协作:单调时钟区间 / 15s 周期切分 / hidden-pagehide 共享结算幂等)。 -// 本函数保留 v1 接线兼容(index.ts 过渡期仍调用,T10 改接 page.ts+stay.ts): -// 只发 page_view,并在 hidden/pagehide 时 flush(true)(沿 v1 卸载 beacon 路径, -// 防首屏事件随页面关闭丢失),不再产生 page_stay。 +// T10 起 index.ts 改接 page.ts+stay.ts,本函数不再接线——保留是因为 +// events.test.ts 仍守 v1 行为基线(page_view 形态与卸载 flush 路径)。 +// 行为:只发 page_view,并在 hidden/pagehide 时 flush(true)(沿 v1 卸载 +// beacon 路径,防首屏事件随页面关闭丢失),不产生 page_stay。 // 红线:任何回调异常不得冒泡到宿主页(spec §4.5)。 export function startPageTracking(t: Transport) { // emit 内 pageContext() 只取一次(pc)复用于 path/title——避免多次读取间 diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 8e5c22a..23f72d4 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,7 +1,8 @@ import { uuid } from "./util"; import { mergeConfig } from "./config"; import { Transport } from "./transport"; -import { startPageTracking } from "./events"; +import { startPageSession } from "./page"; +import { startStayTracking } from "./stay"; import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context"; import { SDK_VERSION, BUILD_ID, SCHEMA_VERSION, CONFIG_VERSION } from "./types"; import type { CtkConfig } from "./config"; @@ -44,6 +45,14 @@ function dispatch(cmd: string, arg?: unknown) { /* 静默:绝不冒泡到宿主页(spec §4.5) */ }); transport = new Transport(cfg); + // 0.2.0 接线(T10):PageSession 发初始 page_view(携带归属快照)并安装 + // SPA 切段/bfcache/交互 hook;stay.ts 区间停留(onClose 已挂钩 + // route_change/pagehide 结算)。交互监听双份属过渡期设计(page.ts 用于 + // 会话恢复判定、stay.ts 用于活动窗口判定)——8 个 passive capture 监听 + // 性能可忽略,不合并(合并会耦合两个模块的测试,收益低)。 + const ps = startPageSession(transport); + // sdk_loaded 先于 startStayTracking 入队:即使 stay 极端环境注册抛错, + // init 心跳(版本组诊断)也不丢失(dispatch 外层 catch 只兜异常不补事件) transport.push({ event_id: uuid(), event_name: "sdk_loaded", @@ -51,8 +60,11 @@ function dispatch(cmd: string, arg?: unknown) { path: pc.path, title: pc.title, properties: { sdk_version: SDK_VERSION, build_id: BUILD_ID, schema_version: SCHEMA_VERSION, config_version: CONFIG_VERSION }, + // 顶层归属快照展开(...a 含 V1.1 预留 null 位——attribution() 将来填 + // 实值时事件自动携带,不会静默漏);seq 在初始 page_view 之后自增 + ...ps.attribution(), }); - startPageTracking(transport); + startStayTracking(transport, ps); } else if (cmd === "track" && transport) { transport.push(arg as object); } else if (cmd === "flush" && transport) { diff --git a/sdk/test/index.test.ts b/sdk/test/index.test.ts index ccc7479..d70f84a 100644 --- a/sdk/test/index.test.ts +++ b/sdk/test/index.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。 // 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。 -// startPageTracking 注册的 document/window 监听跨用例存留,用独立 siteId 过滤批次。 +// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留, +// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession +// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。 type Cmd = [string, unknown?]; type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] }; @@ -70,6 +72,29 @@ describe("index 入口", () => { expect(loaded.event_id).toMatch(UUID_RE); }); + it("接线冒烟(T10):init 后 page_view+sdk_loaded 两事件均带顶层归属快照(四元组+seq+V1.1 预留位)", async () => { + await loadSdk([["init", { siteId: "site-wire", endpoint: "/e", flushMs: 60000 }]]); + g.ctk!("flush"); + + const bodies = bodiesOf("site-wire"); + expect(bodies).toHaveLength(1); // 两事件同归属四元组 → 单组子批 + const evs = bodies[0].events; + expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual(["page_view", "sdk_loaded"]); + for (const e of evs as Record[]) { + expect(e.site_id).toBe("site-wire"); + expect(e.visitor_id).toMatch(UUID_RE); + expect(e.session_id).toMatch(UUID_RE); + expect(e.page_id).toMatch(UUID_RE); + expect(typeof e.seq).toBe("number"); + expect(e.purpose).toBe(null); // V1.1 预留位从快照展开(非字面量) + expect(e.epoch).toBe(null); + expect(e.grant_ref).toBe(null); + } + expect(evs[0].seq).toBe(1); // 初始 page_view:段内首快照(startPageSession 内发) + expect(evs[1].seq).toBe(2); // sdk_loaded:归属取自同一 PageSession + expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属 + }); + it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => { Object.defineProperty(document, "referrer", { value: "https://news.example/a", @@ -139,9 +164,11 @@ describe("index 入口", () => { expect(bodies).toHaveLength(1); expect(bodies[0].visitor_id).toMatch(UUID_RE); // 内存降级身份仍可用 expect(bodies[0].session_id).toMatch(UUID_RE); + // page_view 由 startPageSession 先发(seq=1),sdk_loaded 随后(seq=2); + // 15s 的 stay 周期切分在同一 fake 时刻触发,但 flush 定时器注册更早(先发) expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ - "sdk_loaded", "page_view", + "sdk_loaded", ]); }); @@ -197,19 +224,21 @@ describe("index 入口", () => { expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换 g.ctk!("flush"); let bodies = bodiesOf("site-8"); - expect(bodies).toHaveLength(1); + // T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主 + // 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批 + expect(bodies).toHaveLength(2); expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ - "sdk_loaded", "page_view", - "cta_click", + "sdk_loaded", ]); - expect(bodies[0].events[2].properties).toEqual({ k: 1 }); + expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]); + expect(bodies[1].events[0].properties).toEqual({ k: 1 }); // 重放结束后宿主新调用直接进 dispatch(不再排队丢失) g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" }); g.ctk!("flush"); bodies = bodiesOf("site-8"); - expect(bodies).toHaveLength(2); - expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); + expect(bodies).toHaveLength(3); + expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); }); });