feat(sdk): 0.2.1——track补归属+business接线
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ctk-sdk",
|
"name": "ctk-sdk",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+12
-2
@@ -1,8 +1,9 @@
|
|||||||
import { uuid } from "./util";
|
import { uuid } from "./util";
|
||||||
import { mergeConfig } from "./config";
|
import { mergeConfig } from "./config";
|
||||||
import { Transport } from "./transport";
|
import { Transport } from "./transport";
|
||||||
import { startPageSession } from "./page";
|
import { startPageSession, getPageSession } from "./page";
|
||||||
import { startStayTracking } from "./stay";
|
import { startStayTracking } from "./stay";
|
||||||
|
import { startBusinessTracking } from "./business";
|
||||||
import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context";
|
import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context";
|
||||||
import { SDK_VERSION, BUILD_ID, SCHEMA_VERSION, CONFIG_VERSION } from "./types";
|
import { SDK_VERSION, BUILD_ID, SCHEMA_VERSION, CONFIG_VERSION } from "./types";
|
||||||
import type { CtkConfig } from "./config";
|
import type { CtkConfig } from "./config";
|
||||||
@@ -65,8 +66,17 @@ function dispatch(cmd: string, arg?: unknown) {
|
|||||||
...ps.attribution(),
|
...ps.attribution(),
|
||||||
});
|
});
|
||||||
startStayTracking(transport, ps);
|
startStayTracking(transport, ps);
|
||||||
|
// 0.2.1 接线(T5):业务事件自动监听(contact_click / form_* / product_view)。
|
||||||
|
// 必须在 startPageSession 之后——emit 经 getPageSession() 取归属,无会话时
|
||||||
|
// 守卫静默丢弃(business.test.ts 首例覆盖异常顺序)。幂等性依赖调用方守卫:
|
||||||
|
// init 的 !transport 条件已保证只调一次。product_view 的 init 判定读挂载时
|
||||||
|
// 同步的 location.pathname——置于 startStayTracking 之后无时序敏感(首帧判定)。
|
||||||
|
startBusinessTracking(transport);
|
||||||
} else if (cmd === "track" && transport) {
|
} else if (cmd === "track" && transport) {
|
||||||
transport.push(arg as object);
|
// track 补当前段归属(T5):宿主字段展开在前、归属在后——归属键权威(宿主
|
||||||
|
// 伪造的 site_id 等被覆盖),其余宿主字段原样保留。PageSession 未启动时
|
||||||
|
// attribution() 为 undefined,展开为无操作——保持原样走兜底组路径。
|
||||||
|
transport.push({ ...(arg as object), ...getPageSession()?.attribution() });
|
||||||
} else if (cmd === "flush" && transport) {
|
} else if (cmd === "flush" && transport) {
|
||||||
transport.flush();
|
transport.flush();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ export interface Attribution {
|
|||||||
epoch?: null; // V1.1 预留
|
epoch?: null; // V1.1 预留
|
||||||
grant_ref?: null; // V1.1 预留
|
grant_ref?: null; // V1.1 预留
|
||||||
}
|
}
|
||||||
export const SDK_VERSION = "0.2.0"; // 与 package.json 同步手维护
|
export const SDK_VERSION = "0.2.1"; // 与 package.json 同步手维护
|
||||||
export const SCHEMA_VERSION = 2;
|
export const SCHEMA_VERSION = 2;
|
||||||
export const CONFIG_VERSION = "1";
|
export const CONFIG_VERSION = "1";
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,19 @@ describe("business / startBusinessTracking", () => {
|
|||||||
expect(ids).toEqual(["cf7-156", "cf7-42", "cf7-0"]);
|
expect(ids).toEqual(["cf7-156", "cf7-42", "cf7-0"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("form_id 回归(T4 审查遗留):11 位 ID 不截断匹配 → cf7-0(服务端 cf7-N 上限 10 位)", () => {
|
||||||
|
setUrl("/fid11");
|
||||||
|
const t = mkTransport("biz-fid11");
|
||||||
|
startPageSession(t);
|
||||||
|
startBusinessTracking(t);
|
||||||
|
const form = mkForm({ action: "#wpcf7-f12345678901" });
|
||||||
|
trusted("focusin", form.querySelector("input")!);
|
||||||
|
t.flush();
|
||||||
|
const fss = fetchedEvents("biz-fid11").filter((e) => e.event_name === "form_start");
|
||||||
|
expect(fss).toHaveLength(1);
|
||||||
|
expect(fss[0].properties.form_id).toBe("cf7-0"); // (?!\d) 拒绝截断——按解析失败归 cf7-0
|
||||||
|
});
|
||||||
|
|
||||||
// ---- product_view(spec §1.3:非锚定段匹配 + onSegmentChange 切段) ----
|
// ---- product_view(spec §1.3:非锚定段匹配 + onSegmentChange 切段) ----
|
||||||
|
|
||||||
it("product_view:init 时 /product/slug/ 判定 → inferred=true rule_version=v1 + 顶层归属", () => {
|
it("product_view:init 时 /product/slug/ 判定 → inferred=true rule_version=v1 + 顶层归属", () => {
|
||||||
|
|||||||
+105
-11
@@ -2,9 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
|
|
||||||
// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
|
// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
|
||||||
// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
|
// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
|
||||||
// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留,
|
// startPageSession/startStayTracking/startBusinessTracking 注册的 document/window
|
||||||
// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession
|
// 监听跨用例存留,用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由
|
||||||
// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。
|
// startPageSession 发(seq=1),sdk_loaded 随后入队(seq=2)。0.2.1 接线(T5):
|
||||||
|
// startBusinessTracking 挂业务监听(产品页路径下 init 队列追加 product_view)、
|
||||||
|
// track 补当前段归属(不再走兜底组)。
|
||||||
|
|
||||||
type Cmd = [string, unknown?];
|
type Cmd = [string, unknown?];
|
||||||
type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
|
type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
|
||||||
@@ -33,6 +35,26 @@ function bodiesOf(siteId: string) {
|
|||||||
.filter((b: { site_id: string }) => b.site_id === siteId);
|
.filter((b: { site_id: string }) => b.site_id === siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL 重置:走原型方法绕开历史用例的 page.ts pushState 包装——不触发旧模块
|
||||||
|
* 切段杂事件(同 business.test.ts setUrl 模式) */
|
||||||
|
function setUrl(url: string) {
|
||||||
|
const proto = Object.getPrototypeOf(window.history) as {
|
||||||
|
replaceState: (data: unknown, unused: string, url?: string | URL | null) => void;
|
||||||
|
};
|
||||||
|
proto.replaceState.call(window.history, {}, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** beacon 路径(contact_click 的 flush(true))批次汇总(按信封 site_id 过滤) */
|
||||||
|
async function beaconBodiesOf(siteId: string) {
|
||||||
|
const out: Record<string, any>[] = [];
|
||||||
|
const beacon = (navigator as unknown as { sendBeacon: ReturnType<typeof vi.fn> }).sendBeacon;
|
||||||
|
for (const [, blob] of beacon.mock.calls as unknown as [string, Blob][]) {
|
||||||
|
const body = JSON.parse(await blob.text());
|
||||||
|
if (body.site_id === siteId) out.push(body);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
describe("index 入口", () => {
|
describe("index 入口", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -64,7 +86,7 @@ describe("index 入口", () => {
|
|||||||
expect(bodies).toHaveLength(1);
|
expect(bodies).toHaveLength(1);
|
||||||
const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded");
|
const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded");
|
||||||
expect(loaded).toBeTruthy();
|
expect(loaded).toBeTruthy();
|
||||||
expect(loaded.properties.sdk_version).toBe("0.2.0");
|
expect(loaded.properties.sdk_version).toBe("0.2.1");
|
||||||
// build_id 由 vitest.config define 注入 "vitest";无 define 环境兜底 "unknown"
|
// build_id 由 vitest.config define 注入 "vitest";无 define 环境兜底 "unknown"
|
||||||
expect(loaded.properties.build_id).toBe("vitest");
|
expect(loaded.properties.build_id).toBe("vitest");
|
||||||
expect(loaded.properties.schema_version).toBe(2);
|
expect(loaded.properties.schema_version).toBe(2);
|
||||||
@@ -95,6 +117,76 @@ describe("index 入口", () => {
|
|||||||
expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属
|
expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("track 补归属(T5):init 后 track 事件信封带顶层归属(四元组+seq+V1.1 预留位),不再走兜底组", async () => {
|
||||||
|
await loadSdk([["init", { siteId: "site-track", endpoint: "/e", flushMs: 60000 }]]);
|
||||||
|
g.ctk!("track", {
|
||||||
|
event_id: "e-track-1",
|
||||||
|
event_name: "cta_click",
|
||||||
|
occurred_at: new Date().toISOString(),
|
||||||
|
path: "/anywhere",
|
||||||
|
title: "t",
|
||||||
|
properties: { k: 1 },
|
||||||
|
});
|
||||||
|
g.ctk!("flush");
|
||||||
|
|
||||||
|
const bodies = bodiesOf("site-track");
|
||||||
|
// track 补当前段归属 → 与 page_view/sdk_loaded 同四元组,单组子批
|
||||||
|
// (兜底组路径只剩 PageSession 未启动的异常接线场景)
|
||||||
|
expect(bodies).toHaveLength(1);
|
||||||
|
const evs = bodies[0].events;
|
||||||
|
expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([
|
||||||
|
"page_view",
|
||||||
|
"sdk_loaded",
|
||||||
|
"cta_click",
|
||||||
|
]);
|
||||||
|
const ev = evs[2] as Record<string, unknown>;
|
||||||
|
expect(ev.event_id).toBe("e-track-1"); // 宿主字段原样保留(归属展开在后只补/覆盖归属键)
|
||||||
|
expect(ev.properties).toEqual({ k: 1 });
|
||||||
|
expect(ev.site_id).toBe("site-track");
|
||||||
|
expect(ev.visitor_id).toMatch(UUID_RE);
|
||||||
|
expect(ev.session_id).toMatch(UUID_RE);
|
||||||
|
expect(ev.page_id).toBe(evs[0].page_id); // 与初始 page_view 同段
|
||||||
|
expect(ev.seq).toBe(3); // page_view=1、sdk_loaded=2 之后
|
||||||
|
expect(ev.purpose).toBe(null); // V1.1 预留位从快照展开
|
||||||
|
expect(ev.epoch).toBe(null);
|
||||||
|
expect(ev.grant_ref).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init 接线 business(T5):产品页路径下队列 [page_view,sdk_loaded,product_view];wa.me 点击 → contact_click beacon 冒烟", async () => {
|
||||||
|
setUrl("/product/hydraulic-press-100t/");
|
||||||
|
await loadSdk([["init", { siteId: "site-biz", endpoint: "/e", flushMs: 60000 }]]);
|
||||||
|
g.ctk!("flush");
|
||||||
|
|
||||||
|
const bodies = bodiesOf("site-biz");
|
||||||
|
expect(bodies).toHaveLength(1); // 三事件同四元组 → 单组子批
|
||||||
|
const evs = bodies[0].events;
|
||||||
|
expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([
|
||||||
|
"page_view",
|
||||||
|
"sdk_loaded",
|
||||||
|
"product_view",
|
||||||
|
]);
|
||||||
|
const pv = evs[2] as Record<string, unknown>;
|
||||||
|
expect(pv.properties).toEqual({ product_id: "hydraulic-press-100t", inferred: true, rule_version: "v1" });
|
||||||
|
expect(pv.page_id).toBe(evs[0].page_id); // init 同步判定归当前段
|
||||||
|
|
||||||
|
// business 监听已装:点击 wa.me 链接 → contact_click 入队并 flush(true) beacon 送达
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "https://wa.me/8613800138000";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
try {
|
||||||
|
a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||||
|
} finally {
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
const beacons = await beaconBodiesOf("site-biz");
|
||||||
|
expect(beacons).toHaveLength(1);
|
||||||
|
const cc = beacons[0].events[0];
|
||||||
|
expect(cc.event_name).toBe("contact_click");
|
||||||
|
expect(cc.properties).toEqual({ channel: "whatsapp" });
|
||||||
|
expect(cc.page_id).toBe(pv.page_id); // 归属同段
|
||||||
|
expect(cc.site_id).toBe("site-biz");
|
||||||
|
});
|
||||||
|
|
||||||
it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => {
|
it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => {
|
||||||
Object.defineProperty(document, "referrer", {
|
Object.defineProperty(document, "referrer", {
|
||||||
value: "https://news.example/a",
|
value: "https://news.example/a",
|
||||||
@@ -224,21 +316,23 @@ describe("index 入口", () => {
|
|||||||
expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换
|
expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换
|
||||||
g.ctk!("flush");
|
g.ctk!("flush");
|
||||||
let bodies = bodiesOf("site-8");
|
let bodies = bodiesOf("site-8");
|
||||||
// T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主
|
// T5 起 track 补当前段归属:cta_click 与 page_view/sdk_loaded 同四元组 →
|
||||||
// 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批
|
// 单组子批(兜底组只剩 PageSession 未启动的异常接线场景)
|
||||||
expect(bodies).toHaveLength(2);
|
expect(bodies).toHaveLength(1);
|
||||||
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
|
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
|
||||||
"page_view",
|
"page_view",
|
||||||
"sdk_loaded",
|
"sdk_loaded",
|
||||||
|
"cta_click",
|
||||||
]);
|
]);
|
||||||
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]);
|
expect(bodies[0].events[2].properties).toEqual({ k: 1 });
|
||||||
expect(bodies[1].events[0].properties).toEqual({ k: 1 });
|
expect(bodies[0].events[2].event_id).toBe("e-custom"); // 宿主字段原样保留
|
||||||
|
|
||||||
// 重放结束后宿主新调用直接进 dispatch(不再排队丢失)
|
// 重放结束后宿主新调用直接进 dispatch(不再排队丢失)
|
||||||
g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" });
|
g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" });
|
||||||
g.ctk!("flush");
|
g.ctk!("flush");
|
||||||
bodies = bodiesOf("site-8");
|
bodies = bodiesOf("site-8");
|
||||||
expect(bodies).toHaveLength(3);
|
expect(bodies).toHaveLength(2);
|
||||||
expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
|
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
|
||||||
|
expect(bodies[1].events[0].site_id).toBe("site-8"); // dispatch 直调同补归属
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user