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 { /* 静默 */ } }); }