Files
fangke/sdk/src/stay.ts
T
2026-09-18 13:45:36 +08:00

148 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Transport } from "./transport";
import type { PageSession } from "./page";
import { pageContext } from "./context";
import { uuid } from "./util";
// 停留区间制(设计 §1.4 / spec §1.1 归属):一条 page_stay = 一段连续有效活动的
// 不重叠区间。区间长度只用单调时钟 performance.now 计算——系统墙钟回拨/漂移不改
// active_ms;active_from/active_to 是展示用 epoch ms:cut 时取一次墙钟读数 wall,
// 按 wall - performance.now() 偏移把两个 perf 端点换算到同一墙基(to-from 恒等于
// active_ms,墙钟中途回拨不会产生倒置区间被服务端重算丢弃)。
// 活动判定沿用 events.ts 冻结语义(可见 + Date.now() 距最近交互 <30s)。
// 服务端(ingest._recompute_session_stay)按 page_id 对区间做并集拼回总时长,
// 本模块只须保证:一条区间 = 一段连续活动;相邻区间不重叠——重开时的回溯
// (nowPerf-1s)被 lastCutEnd 钳制:1s tick 与 stayIntervalMs 周期定时器在同一
// 瞬间触发时回调顺序不定,两种顺序都不得让新区间起点越过上一切割点。
// 红线:所有回调/定时器异常 try/catch 静默,绝不冒泡宿主页(spec §4.5)。
const TICK_MS = 1_000;
const ACTIVITY_WINDOW_MS = 30_000;
export interface StayOptions {
/** 周期切分长度(默认 15s):连续活动每满一刀发一条 flush_reason=periodic 区间 */
stayIntervalMs?: number;
}
export function startStayTracking(t: Transport, ps: PageSession, opts: StayOptions = {}): void {
// 边界守卫:0/负数等有限小值钳制下限 1s(防 setInterval 高频 cut 灌入事件);
// NaN/Infinity 非有限值——NaN 绕过 ?? 的 nullish 判定直传,须显式回落默认 15s。
const stayIntervalMs = Math.max(1_000, Number.isFinite(opts.stayIntervalMs) ? opts.stayIntervalMs! : 15_000);
let intervalStart: number | null = null; // performance.now 基准;null=无 open 区间
let lastCutEnd: number | null = null; // 最近一次 cut 的 perf 端点——重开回溯的钳制上界
let lastAct = Date.now(); // 墙钟活动戳(沿 events.ts 冻结语义;页面加载即活动)
// 结算当前区间(幂等:无 open 区间则 no-op——hidden 后 pagehide 不重发)。
// attribution()/pageContext() 在 closed 段上仍可调(尾部区间随段归属冻结)。
const cut = (reason: string) => {
try {
if (intervalStart === null) return;
const startPerf = intervalStart;
const endPerf = performance.now();
intervalStart = null; // 先收状态再 emit:任何异常不留悬挂区间(不重发)
lastCutEnd = endPerf;
if (endPerf <= startPerf) return; // perf 非单调的异常环境:丢弃零/负区间
const wall = Date.now(); // from/to 同基换算——展示墙钟,不参与长度计算
const a = ps.attribution();
const pc = pageContext();
t.push({
event_id: uuid(),
event_name: "page_stay",
occurred_at: new Date(wall).toISOString(),
path: pc.path,
title: pc.title,
properties: {
active_ms: endPerf - startPerf,
path: pc.path, // 顶层 path 同源(服务端"page_stay 合并进相邻 page_view")
active_from: wall - endPerf + startPerf,
active_to: wall,
flush_reason: reason,
measurement_version: "2", // 区间制口径标记(v1 为 active_ms 累加制)
},
site_id: a.site_id,
visitor_id: a.visitor_id,
session_id: a.session_id,
page_id: a.page_id,
seq: a.seq,
// V1.1 预留字段从快照展开(非字面量)——将来 attribution() 填真实值时自动携带
purpose: a.purpose,
epoch: a.epoch,
grant_ref: a.grant_ref,
});
} catch {
/* 静默(spec §4.5) */
}
};
// 1s tick:活动判定 + 开区间(回溯本 tick——tick 触发时活动已持续至多 1s)。
// 失活/隐藏即 cut("periodic") 悬置:恢复后另开新区间(起点=活动恢复点),
// 空闲/隐藏跨度不计入任何区间——区间语义纯净:连续活动段。
setInterval(() => {
try {
const active = !document.hidden && Date.now() - lastAct < ACTIVITY_WINDOW_MS;
if (!active) {
cut("periodic"); // 无 open 区间时 no-op
return;
}
if (intervalStart === null) {
const backdate = performance.now() - TICK_MS;
intervalStart = lastCutEnd !== null && backdate < lastCutEnd ? lastCutEnd : backdate;
}
} catch {
/* 静默(spec §4.5) */
}
}, TICK_MS);
// 周期切一刀:连续活动每满 stayIntervalMs 发一条(服务端并集拼回总时长)。
setInterval(() => {
try {
cut("periodic");
} catch {
/* 静默(spec §4.5) */
}
}, stayIntervalMs);
// 活动监听:本模块独立记录停留判定用的活动戳(与 page.ts 的 touch 并存期——
// page.ts 管会话空闲检查点,这里管停留活动窗;T10 接线时再评估合并)。
const act = () => {
try {
lastAct = Date.now();
} catch {
/* 静默(spec §4.5:监听器异常不得冒泡到宿主页) */
}
};
["pointerdown", "keydown", "wheel", "touchstart"].forEach((name) =>
document.addEventListener(name, act, { passive: true, capture: true })
);
// hidden / pagehide:结算 + flush(true)(沿 v1 卸载 beacon 路径,尾部数据不驻队列)
document.addEventListener("visibilitychange", () => {
try {
if (document.hidden) {
cut("hidden");
t.flush(true);
}
} catch {
/* 静默 */
}
});
window.addEventListener("pagehide", () => {
try {
cut("pagehide");
t.flush(true);
} catch {
/* 静默 */
}
});
// SPA 路由切段(PageSession.close → onClose,晚注册已被 T6 修复为立即补触发):
// 尾部区间随段结算。pagehide 也会先关段(page.ts 的 pagehide 监听注册更早),
// 按 document.hidden 区分——卸载路径归 "pagehide"(上面自己的监听已结算则此处
// 幂等 no-op),SPA 切段归 "route_change"。切段不 flush:页面仍在台前,周期照送。
ps.onClose(() => {
try {
cut(document.hidden ? "pagehide" : "route_change");
} catch {
/* 静默 */
}
});
}