25e37f56dc
Co-Authored-By: Claude Code <noreply@anthropic.com>
148 lines
6.3 KiB
TypeScript
148 lines
6.3 KiB
TypeScript
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 {
|
||
/* 静默 */
|
||
}
|
||
});
|
||
}
|