H5-contact/node_modules/@dcloudio/uni-stat/dist/uni-stat-public.cjs.js

7095 lines
276 KiB
JavaScript
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.

'use strict';
/**
* 事件类型与会话创建类型常量。
*
* 与私有版 / 文档 `uni统计上报参数.md` 的兼容关系:
* - 上行参数文档明确:`lt` 仅取 `1 / 3 / 11 / 21 / 31 / 41`**没有** `lt=0`。
* - 历史架构文档03-公有版架构设计.md §3.2)曾设计 `lt=0` 作为"客户端 session 边界"事件,
* 但与服务端入库口径不一致(会话日志 = lt=1已**整体移除**
* 新会话直接发一条 lt=1会话字段`sid / cst / fvts / lvts / tvc`)随 lt=1 上行。
* - 因此 `LT` 不再包含 `Session`;删除 lt=0 不影响老接收端。
*/
/**
* Log Type事件类型。统一在此声明禁止其他模块裸写字符串。
*
* 注:`lt=41`uni-app x 原生崩溃日志)暂未在公有版实现,详见 `docs/暂未实现字段说明.md`。
*/
const LT = {
Launch: '1',
Hide: '3',
Page: '11',
Event: '21',
Error: '31',
Push: '101',
};
/**
* Create Session Type / Session Create Type同义
*
* - `1` 冷启动:进程刚起,第一次创建会话。
* - `2` 后台超时:从后台返回前台,间隔 >= `backgroundTimeoutSec`(秒)。
* - `3` 前台无操作超时:在前台一段时间无任何 page/event 触达。
*
* 公有版预留 `0` 给"未触发新会话"的零值;不要用 0 覆写 storage仅作为内部哨兵。
*/
const CST = {
ColdLaunch: 1,
BackgroundTimeout: 2,
PageInactiveTimeout: 3,
};
/**
* 入口页标记。
*
* `iey` / `ppiey` 上行字段以 `0/1` 形式表达布尔,与私有版数字风格保持一致。
*/
const IEY = {
No: 0,
Yes: 1,
};
/**
* 把任意输入归一化为 `IEYValue`。
*
* 用于 `domain/entry` 在拼装字段时统一布尔→0/1。`true / 1 / '1'` 均视为 Yes。
*/
function toIey(input) {
if (input === true || input === 1 || input === '1')
return IEY.Yes;
return IEY.No;
}
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
/**
* 安全工具集序列化、try 包裹、指数退避重试。
*
* 修复缺陷:
* - #1 `_retry` 未初始化导致重试链路 NaN公有版直接以参数显式传 `times`)。
* - #7 取值反向(私有版 `if (data.length > MAX_LENGTH)` 误判)。
* - #8 循环引用导致 `JSON.stringify` 抛错(用 WeakSet replacer 兜底)。
*/
const DEFAULT_MAX_LENGTH = 4096;
const TRUNCATED_SUFFIX = '…[truncated]';
/**
* 序列化任意值为字符串:支持循环引用与最大长度截断。
*
* @param value 待序列化的值。`undefined` 返回 ''string 直接返回(仍参与截断)。
* @param max 字符串最大长度,默认 4096超长会截断并附 `…[truncated]`。
*/
function safeStringify(value, max = DEFAULT_MAX_LENGTH) {
var _a;
if (value === undefined)
return '';
let raw;
if (typeof value === 'string') {
raw = value;
}
else {
const seen = new WeakSet();
try {
raw =
(_a = JSON.stringify(value, (_key, val) => {
if (typeof val === 'object' && val !== null) {
if (seen.has(val))
return '[Circular]';
seen.add(val);
}
if (typeof val === 'bigint')
return val.toString();
if (typeof val === 'function')
return `[Function ${val.name || 'anonymous'}]`;
return val;
})) !== null && _a !== void 0 ? _a : '';
}
catch (e) {
raw = `[Unserializable: ${e.message}]`;
}
}
if (raw.length > max) {
return (raw.slice(0, Math.max(0, max - TRUNCATED_SUFFIX.length)) +
TRUNCATED_SUFFIX);
}
return raw;
}
/**
* 包裹同步函数,捕获任何抛出,返回 fallback。
*
* 不打印 console由调用方按需 `logger.warn`);保持纯函数风格便于热路径使用。
*/
function tryRun(fn, fallback) {
try {
return fn();
}
catch (_a) {
return fallback;
}
}
/**
* 指数退避重试:失败时按 `baseDelayMs * 2^(n-1)` 等待后重试,全部失败抛出最后一个错误。
*
* @example
* await withRetry(() => fetch(url), { times: 3, baseDelayMs: 200 })
* // 第 1 次失败 → wait 200ms第 2 次失败 → wait 400ms第 3 次失败 → throw
*/
function withRetry(fn, opts) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const total = Math.max(1, Math.floor(opts.times));
const sleep = (_a = opts.sleep) !== null && _a !== void 0 ? _a : defaultSleep;
let lastErr;
for (let attempt = 1; attempt <= total; attempt++) {
try {
return yield fn();
}
catch (e) {
lastErr = e;
if (attempt >= total)
break;
yield sleep(opts.baseDelayMs * Math.pow(2, (attempt - 1)));
}
}
throw lastErr;
});
}
function defaultSleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 解析 uni-app 运行时根对象 `uni`。
*
* - H5 / App常见为 `globalThis.uni`。
* - 微信小程序等:多为 Vite/rollup 向**当前模块**注入的标识符 `uni`
* **未必**同步挂到 `globalThis`;仅读 `globalThis.uni` 会导致
* `bindLifecycle` / `uni.request` / storage 等全部静默失败。
* - 支付宝等旧版小程序:**无 `globalThis` 标识符**,须用 `getGlobalObject()` 兜底。
* - H5 发行摇树:`pages.json.js` 会先把 `window.uni = {}` 占位;若仍按「有 object 即用」
* 会误把空桩当真 uni。须用 `isUsableUniRuntime` 过滤后再择源。
*
* 第二路依赖宿主构建对 `uni` 的注入(与业务页面同一套解析规则),
* 类型兜底见 `packages/uni-stat/src/uni-global.d.ts`。
*/
/**
* 判断候选 `uni` 是否具备统计 SDK 可用的最小 API 集合(排除 H5 摇树空桩 `{}`)。
*
* 任一核心 API 存在即视为可用;与具体平台无关,微信/QQ/抖音/支付宝/百度等
* 完整 runtime 均满足,仅「占位空对象」会被过滤。
*/
function isUsableUniRuntime(candidate) {
if (candidate == null || typeof candidate !== 'object')
return false;
const u = candidate;
return (typeof u.getStorageSync === 'function' ||
typeof u.onCreateVueApp === 'function' ||
typeof u.request === 'function' ||
typeof u.onAppShow === 'function');
}
/**
* 读取宿主向当前模块注入的 `uni`(小程序等);不可用时返回 `undefined`。
*/
function getModuleUniCandidate() {
if (typeof uni === 'undefined' || uni == null || typeof uni !== 'object') {
return undefined;
}
return uni;
}
/**
* H5 兜底:在 `globalThis` / `self` 不可用时尝试读取 `window`。
*
* 通过 `Function` 间接访问,避免 ESLint `no-restricted-globals` 对 `window` 标识符的限制;
* 小程序等环境执行失败时返回 `undefined`。
*/
function getWindowObject() {
try {
const w = Function('return typeof window !== "undefined" ? window : undefined')();
return w != null ? w : undefined;
}
catch (_a) {
return undefined;
}
}
/**
* 安全获取全局对象。
*
* 支付宝 / 部分旧版小程序运行时未提供 `globalThis`,直接写 `globalThis` 会
* `ReferenceError: globalThis is not defined`,导致 install 阶段整包崩溃。
*/
function getGlobalObject() {
if (typeof globalThis !== 'undefined' && globalThis != null) {
return globalThis;
}
if (typeof global !== 'undefined' && global != null) {
return global;
}
if (typeof self !== 'undefined' && self != null) {
return self;
}
const win = getWindowObject();
if (win)
return win;
return {};
}
/**
* 用字面量 `uni.方法` 引用拼出一个可用的 `uni` facade。
*
* uni-app 各端构建的 API 注入器仅识别字面量成员表达式 `uni.方法`H5 → `@dcloudio/uni-h5`
* 小程序 / App 同理),动态 `u.方法` 不会被注入。这里对所需 API 逐个写字面量 `uni.方法`
* 使其被注入后收敛为一个对象供下游适配器使用。
*
* 仅在 `globalThis.uni` 与模块 `uni` 均不可用时作为兜底典型H5 发行摇树后 `window.uni`
* 为 `{}` 空桩)。未经注入的环境下 `uni.方法` 读到空桩 / 未声明,拼不出方法时返回 undefined。
*/
function buildInjectedUniRuntime() {
try {
const out = {};
const pick = (name, fn) => {
if (typeof fn === 'function')
out[name] = fn;
};
// 必须逐个写字面量 `(uni as ...).方法`emit 后为 `uni.方法`),不可别名 / 循环,否则不会被注入。
pick('getStorageSync', uni.getStorageSync);
pick('setStorageSync', uni.setStorageSync);
pick('removeStorageSync', uni.removeStorageSync);
pick('getSystemInfoSync', uni.getSystemInfoSync);
pick('getDeviceInfo', uni.getDeviceInfo);
pick('getAppBaseInfo', uni.getAppBaseInfo);
pick('getWindowInfo', uni.getWindowInfo);
pick('getNetworkType', uni.getNetworkType);
pick('request', uni.request);
pick('onAppShow', uni.onAppShow);
pick('offAppShow', uni.offAppShow);
pick('onAppHide', uni.onAppHide);
pick('offAppHide', uni.offAppHide);
pick('onAppLaunch', uni.onAppLaunch);
pick('offAppLaunch', uni.offAppLaunch);
pick('getLaunchOptionsSync', uni.getLaunchOptionsSync);
pick('addInterceptor', uni.addInterceptor);
pick('removeInterceptor', uni.removeInterceptor);
pick('getPushClientId', uni.getPushClientId);
pick('getAccountInfoSync', uni.getAccountInfoSync);
pick('onCreateVueApp', uni.onCreateVueApp);
return Object.keys(out).length > 0 ? out : undefined;
}
catch (_e) {
// 未注入且 `uni` 未声明(单测 / 极端环境)→ ReferenceError兜底返回 undefined。
return undefined;
}
}
/**
* 探测 `uni` 解析路径(不改变 `resolveUniRuntime` 行为,仅用于 debug 诊断)。
*/
function probeUniRuntime() {
const globalThisAvailable = typeof globalThis !== 'undefined';
const g = getGlobalObject();
const globalUni = g.uni;
const globalThisHasUni = globalUni != null && typeof globalUni === 'object';
const globalThisUniStub = globalThisHasUni && !isUsableUniRuntime(globalUni);
const moduleUni = getModuleUniCandidate();
const moduleUniDefined = moduleUni != null;
if (isUsableUniRuntime(globalUni)) {
return {
resolved: true,
source: 'globalThis',
globalThisHasUni: true,
globalThisUniStub: false,
moduleUniDefined,
globalThisAvailable,
uni: globalUni,
};
}
if (isUsableUniRuntime(moduleUni)) {
return {
resolved: true,
source: 'module',
globalThisHasUni,
globalThisUniStub,
moduleUniDefined: true,
globalThisAvailable,
uni: moduleUni,
};
}
// globalThis / 模块 uni 均不可用(典型 H5 发行空桩)时,用注入 facade 兜底。
const injectedUni = buildInjectedUniRuntime();
if (isUsableUniRuntime(injectedUni)) {
return {
resolved: true,
source: 'injected',
globalThisHasUni,
globalThisUniStub,
moduleUniDefined,
globalThisAvailable,
uni: injectedUni,
};
}
return {
resolved: false,
source: 'none',
globalThisHasUni,
globalThisUniStub,
moduleUniDefined,
globalThisAvailable,
uni: undefined,
};
}
/**
* 返回与业务侧一致的 `uni` 运行时根对象;均不可用时返回 `undefined`。
*/
function resolveUniRuntime() {
const probe = probeUniRuntime();
return probe.resolved ? probe.uni : undefined;
}
/**
* 公有版统一日志出口。
*
* 修复的私有版缺陷:
* - #19 `!!process.env.UNI_STAT_DEBUG` 在构建时若被替换为字符串 `"false"` 仍是 truthy。
* 公有版严格使用 `=== 'true'` 判定,并允许在运行时通过 `setDebug()` 临时打开
* (供调试 / 灰度小流量验证)。
*
* 行为约定:
* - `debug` 受调试开关控制;其他 level 始终输出到对应的 `console.*`。
* - **Android / iOS 真机**`TAG` 与正文拼成单条字符串,避免桥接丢弃第二参起。
* - **其它平台**`console.*(TAG, ...args)`,对象保持原生传递。
*
* 兼容性:
* - 历史版本插件 define 误把 `process.env.UNI_STAT_DEBUG` 替换成布尔字面量
* (未 `JSON.stringify`),导致 dist 运行时该值为 `true`/`false` 而非 `'true'`/`'false'`。
* `isDebug()` 同时接受字符串 `'true'` 与布尔字面量 `true`,避免历史构建产物完全失效。
*/
const TAG = '[uni统计 2.0]';
let runtimeDebug;
/**
* 是否屏蔽 info / warn / error。
* `undefined`:自动——`NODE_ENV === 'test'` 时默认屏蔽,避免 Jest 用例预期失败路径刷屏。
*/
let muteNonDebug;
/**
* 是否将日志合并为单行Android / iOS 真机侧)。
*/
function preferSingleLineConsole() {
return isAndroidOrIosRuntime();
}
/**
* 是否为 App 或小程序运行在 **Android / iOS** 上(仅此类环境对对象参数做字符串化)。
*/
function isAndroidOrIosRuntime() {
var _a, _b, _c, _d, _e, _f, _g, _h;
const raw = (_a = process.env.UNI_PLATFORM) !== null && _a !== void 0 ? _a : '';
const g = getGlobalObject();
if (raw === 'app' || raw === 'app-plus' || raw === 'app-harmony') {
const n = (_d = (_c = (_b = g.plus) === null || _b === void 0 ? void 0 : _b.os) === null || _c === void 0 ? void 0 : _c.name) === null || _d === void 0 ? void 0 : _d.toLowerCase();
if (!n)
return false;
if (n.includes('android'))
return true;
if (n === 'ios' || n.includes('iphone'))
return true;
return false;
}
if (raw.startsWith('mp-')) {
try {
const p = (_h = (_g = (_f = (_e = g.uni) === null || _e === void 0 ? void 0 : _e.getSystemInfoSync) === null || _f === void 0 ? void 0 : _f.call(_e)) === null || _g === void 0 ? void 0 : _g.platform) === null || _h === void 0 ? void 0 : _h.toLowerCase();
return p === 'android' || p === 'ios';
}
catch (_j) {
return false;
}
}
return false;
}
/**
* 在 Android/iOS 上将「对象类」参数转为可打印字符串;其余类型原样返回。
*/
function stringifyObjectArgForNative(value) {
if (value === null || value === undefined)
return value;
if (typeof value !== 'object')
return value;
if (value instanceof Error)
return `${value.name}: ${value.message}`;
return safeStringify(value);
}
/**
* 将单段日志参数格式化为可拼进一行文本的片段Android/iOS 单参输出用)。
*/
function formatLogArgForNativeConsole(value) {
if (value === null)
return 'null';
if (value === undefined)
return 'undefined';
if (typeof value === 'string')
return value;
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (typeof value === 'bigint')
return String(value);
if (typeof value === 'symbol') {
try {
return value.toString();
}
catch (_a) {
return '?';
}
}
if (typeof value === 'function') {
const fn = value;
return `[Function ${fn.name || 'anonymous'}]`;
}
if (typeof value === 'object') {
if (value instanceof Error)
return `${value.name}: ${value.message}`;
return safeStringify(value);
}
return String(value);
}
/**
* 当前是否应屏蔽 info / warn / errordebug 仍由 `isDebug()` 单独控制)。
*/
function isNonDebugMuted() {
if (muteNonDebug !== undefined)
return muteNonDebug;
return process.env.NODE_ENV === 'test';
}
/**
* 测试 / CI 下临时恢复 warn 等输出(如断言 install 告警文案)。
*
* @param value `true` 屏蔽;`false` 允许;`undefined` 恢复为按 NODE_ENV 自动判定。
*/
function setMuteNonDebug(value) {
muteNonDebug = value;
}
/**
* 输出到 consoleAndroid/iOS 真机整行单参;其余平台 `TAG` + 多参。
*/
function emitConsole(method, args) {
if (method !== 'log' && isNonDebugMuted())
return;
const fn = console[method];
if (!preferSingleLineConsole()) {
fn.call(console, TAG, ...args);
return;
}
const mapped = isAndroidOrIosRuntime()
? args.map(stringifyObjectArgForNative)
: args;
if (mapped.length === 0) {
fn.call(console, TAG);
return;
}
const body = mapped.map(formatLogArgForNativeConsole).join(' ');
fn.call(console, `${TAG} ${body}`);
}
/**
* 当前是否启用 debug 输出。优先级:
* 1. `setDebug(value)` 显式设置过 → 直接返回。
* 2. `process.env.UNI_STAT_DEBUG === 'true'` 或被构建期替换为布尔字面量 `true`
* (历史插件兼容路径)。
*/
function isDebug() {
if (runtimeDebug !== undefined)
return runtimeDebug;
const v = process.env.UNI_STAT_DEBUG;
return v === 'true' || v === true;
}
/**
* 运行时切换 debug 开关;传 `undefined` 恢复为「按 process.env 判断」。
*/
function setDebug(value) {
runtimeDebug = value;
}
const logger = {
debug(...args) {
if (!isDebug())
return;
// eslint-disable-next-line no-console
emitConsole('log', args);
},
info(...args) {
// eslint-disable-next-line no-console
emitConsole('info', args);
},
warn(...args) {
// eslint-disable-next-line no-console
emitConsole('warn', args);
},
error(...args) {
// eslint-disable-next-line no-console
emitConsole('error', args);
},
setDebug,
isDebug,
setMuteNonDebug,
};
/**
* 公有版本地存储抽象(重写自私有版 `utils/db.js`)。
*
* 修复的私有版缺陷:
* - #14 `dbRemove` 第二段 `data = uni.getStorageSync(...)` 无 `|| {}` 兜底导致 NPE。
* - #18 每次 get/set/remove 都做 2~3 次 storage IOread-modify-write
* 公有版改为「按 key 独立存储 + 内存缓存」,每次操作只 1 次同步 IO。
*
* 关键能力(供 Phase 4 缺陷 #5 修复使用):
* - `safeRead`:读取失败时返回 `{ ok: false }` 而不是 `undefined`,调用方据此区分
* "key 不存在 / storage 异常",避免老用户被 lvts=0 误判为新用户。
*
* 命名空间:所有 key 自动加前缀 `UNI_STAT_DATA:<appid>:``<appid>` 取
* `process.env.UNI_APP_ID`,缺失时退化为 `default`。
*
* 注意:本模块依赖 `uni.{getStorageSync,setStorageSync,removeStorageSync}`
* 解析规则见 `infra/uniRuntime.ts`(含小程序注入路径)。
* 测试中通过 `helpers/mockUni` 注入。
*/
/**
* 公有版命名空间前缀,遵循公司内部统一规范 `UNI_STAT_DATA:<appid>:<key>`。
*
* 私有版(旧)使用 `$$STAT__DBDATA:<appid>` 作为单一聚合 key这里**不再**使用旧前缀,
* 仅在 Phase 4 `domain/migration` 中通过显式只读 API 读取一次老聚合数据并拆解到新前缀,
* 保证平滑迁移;除迁移路径外,新代码全部写入 `UNI_STAT_DATA:` 命名空间。
*/
const NAMESPACE_ROOT = 'UNI_STAT_DATA';
/** 仅供迁移层读取老数据用:私有版聚合 key 的前缀。 */
const LEGACY_NAMESPACE_ROOT = '$$STAT__DBDATA';
/**
* 内存缓存。值语义:
* - 命中且非 undefined → cache 中的真实值
* - 命中且 undefined → 已经主动 `remove` 或确认 storage 中不存在
* - 未命中 → 还没读过 storage
*/
const cache = new Map();
/** 已知存在过的全部完整 key用于 `clearNamespace`)。 */
const knownKeys = new Set();
/**
* 拼装命名空间下的完整 key。
*/
function fullKey(key) {
const appid = process.env.UNI_APP_ID || 'default';
return `${NAMESPACE_ROOT}:${appid}:${key}`;
}
/**
* 取真实 uni 对象。剥离到函数里,便于测试用 mockUni 替换后立即生效。
*/
function getUni$a() {
const raw = resolveUniRuntime();
const u = raw != null && typeof raw === 'object'
? raw
: undefined;
if (!u || typeof u.getStorageSync !== 'function') {
throw new Error('[uni统计 2.0] uni storage API is not available');
}
return u;
}
/**
* 获取一个 key 的值。
*
* @returns 命中返回值;未命中或 storage 异常返回 `undefined`(无法区分两种情况,
* 需要区分时请使用 `safeRead`)。
*/
function get(key) {
const fk = fullKey(key);
if (cache.has(fk))
return cache.get(fk);
try {
const raw = getUni$a().getStorageSync(fk);
// uni 规范:未命中返回空字符串
if (raw === '' || raw === null || raw === undefined) {
cache.set(fk, undefined);
return undefined;
}
cache.set(fk, raw);
knownKeys.add(fk);
return raw;
}
catch (_a) {
return undefined;
}
}
/**
* 安全读取:明确区分「未命中 / 读异常」。
*
* @returns
* - `{ ok: true, value }`成功读取value 可能为 undefined 表示 key 不存在)。
* - `{ ok: false, value: undefined }`storage 抛错;调用方应使用上次内存值兜底,
* **绝不**直接退化为 0 / null否则会复现缺陷 #5老用户被误判为新用户
*/
function safeRead(key) {
const fk = fullKey(key);
if (cache.has(fk))
return { ok: true, value: cache.get(fk) };
try {
const raw = getUni$a().getStorageSync(fk);
if (raw === '' || raw === null || raw === undefined) {
cache.set(fk, undefined);
return { ok: true, value: undefined };
}
cache.set(fk, raw);
knownKeys.add(fk);
return { ok: true, value: raw };
}
catch (_a) {
return { ok: false, value: undefined };
}
}
/**
* 写入一个 key。`undefined` 视为删除(与私有版语义对齐)。
*
* 失败策略:先更新缓存,再写 storagestorage 抛错时不回滚缓存,
* 由调用方决定是否补偿(视调用方场景而定,热路径不应阻塞)。
*/
function set(key, value) {
const fk = fullKey(key);
if (value === undefined) {
remove(key);
return;
}
cache.set(fk, value);
knownKeys.add(fk);
try {
getUni$a().setStorageSync(fk, value);
}
catch (_a) {
// 缓存已更新,吞掉异常;调用方如需感知请使用 try/catch 显式包裹。
}
}
/**
* 删除一个 key。
*/
function remove(key) {
const fk = fullKey(key);
cache.set(fk, undefined);
try {
getUni$a().removeStorageSync(fk);
}
catch (_a) {
// 同 set忽略 storage 异常,缓存已置空。
}
}
/**
* 批量读:返回 `Record<key, value>`,未命中 / 异常的 key 取值为 `undefined`。
*/
function batchGet(keys) {
const out = {};
for (const k of keys)
out[k] = get(k);
return out;
}
/**
* 批量写:逐个 `set`,等价于循环调用,便于调用侧聚合。
*/
function batchSet(entries) {
for (const k of Object.keys(entries))
set(k, entries[k]);
}
/**
* 清除当前 appid 命名空间下、自模块加载以来访问过的所有 key。
*
* 注意:受限于 uni storage 不一定支持 `getStorageInfoSync`,本函数只清理
* 「本模块写入或读取过的 key」未触达过的历史脏数据需要调用方显式处理。
*/
function clearNamespace() {
let uni;
try {
uni = getUni$a();
}
catch (_a) {
// uni 不可用:仅清缓存,无法清持久化
}
for (const fk of Array.from(knownKeys)) {
try {
uni === null || uni === void 0 ? void 0 : uni.removeStorageSync(fk);
}
catch (_b) {
// 单 key 失败不影响其他 key 的清理
}
cache.set(fk, undefined);
}
knownKeys.clear();
}
/**
* 仅供单测使用:清空内部缓存与 knownKeys 索引,让模块"像刚加载"一样。
*
* 单测必须在每个用例之间调用,否则会跨用例泄漏缓存状态。
*/
function __resetCache() {
cache.clear();
knownKeys.clear();
}
const storage = {
get,
set,
remove,
safeRead,
batchGet,
batchSet,
clearNamespace,
__resetCache,
};
/**
* 访问字段 `fvts / lvts / tvc` 状态机。**专项修复缺陷 #5lvts=0 老用户被误判为新用户。**
*
* 私有版(`utils/pageTime.js`)的副作用链:
* 1. `get_first_visit_time` 在写 fvts 时主动 `dbRemove(LAST_VISIT_TIME_KEY)`
* → 第 1 次启动结束 storage 中 lvts 为空。
* 2. `get_last_visit_time` 在读时立即 `dbSet(LAST_VISIT_TIME_KEY, get_time())`
* → 把 "上一次" 当成 "本次",下次启动读出来已是当前时间。
* 3. 写入早于上报,上报失败时无法回滚,下次启动状态错乱。
*
* 公有版严格契约:
* 1. 老用户三段拆分:`loadVisitSnapshot()` 纯读、`buildVisitFields()` 仅生成本次待写、
* `commitVisitOnAck()` 在上报 ack 后才落 storage仅推进 lvts永不主动清 lvts
* 2. **新用户首条 lt=1 例外(保证"一生只计一次新增"**:本条仍上报 `lvts=0`,但
* `buildVisitFields` 会**立即**把基线 `fvts/lvts/tvc=now/now/1` 落库(对齐私有版
* `get_last_visit_time` 的"读即写")。这样本进程后续续会话、以及下次冷启动都会读到
* `lvts=now`(非 0不再被重复计为新增首条即便上报失败也已由 retry 暂存重试,
* 不会丢失这唯一一次新增信号。**唯有卸载应用 / 清空缓存**清掉基线后才会重新计一次。
* 3. `loadVisitSnapshot` 区分 "key 不存在" 与 "storage 异常"
* - 不存在 → `lvts=0`按新用户路径走Yes new user
* - 异常 → 内存有上次 snapshot 时复用之;首次启动且异常 → fallback `lvts=0`
* 但**记录** `degraded=true`上层可决定是否仍上报Phase 5 collector 用)。
* 4. 同一进程内只允许一次 `buildVisitFields`;后续 cst=2/3 触发的新会话 lt=1
* 调用 `buildVisitFieldsForSessionRenewal`,复用 committed / lastBuilt 推进 tvc
* 并保证 lvts 仍随 lt=1 上行(缺失会被服务端误判为新用户)。
*
* 与 `pipeline/collector.ts` 的契约见 `05-公有版重构开发计划.md` §4.1.5。
*/
const KEY_FVTS = 'visit:fvts';
const KEY_LVTS = 'visit:lvts';
const KEY_TVC = 'visit:tvc';
const EMPTY_SNAPSHOT = {
fvts: 0,
lvts: 0,
tvc: 0,
isNewUser: true,
degraded: false,
};
/** 启动后通过 `loadVisitSnapshot` 写入;后续 build/commit 均基于此推进。 */
let loaded = null;
/** `buildVisitFields` 生成;`commitVisitOnAck` 落库后清空。 */
let pending = null;
/** cst=2/3 新会话 lt=1 生成;`commitVisitOnAck` 落库后清空。 */
let pendingRenewal = null;
/** `commitVisitOnAck` 落库后写入;同进程内 cst=2/3 后续事件复用此 snapshot。 */
let committed = null;
/**
* 本进程内最近一次 `buildVisitFields` 的产出。即使 pending 已被 commit / rollback 清空,
* 仍保留这份,用于:
* - cst=2/3 复用同一份字段(参见 T5/T6
* - 拦截同进程二次 `buildVisitFields` 误调warn 后返回此值,不再生成新值)。
*/
let lastBuilt = null;
/** 同进程内 `buildVisitFields` 只允许调用一次(缺陷 #5 修复点之一)。 */
let buildCalledInProcess = false;
/**
* 转 number兼容历史 storage 中 string 形式的时间戳)。
*
* 异常 / NaN / 负数一律视为 0
* 这里保守不抛错,因为读流程要保证不让"脏数据"中断采集链路。
*/
function toNum(v) {
if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
return v;
if (typeof v === 'string' && v.length > 0) {
const n = Number(v);
if (Number.isFinite(n) && n >= 0)
return n;
}
return 0;
}
/**
* snapshot 是否「确实是一台全新设备」:三字段全 0。
*
* 用于消费 `degraded`storage 读取异常时 `lvts` 会退化为 0 而误判 `isNewUser=true`。
* 若此时 `fvts/tvc` 仍读到非 0说明是老用户、只是 lvts 这一项读失败),就**不能**当新增,
* 也不能落库覆盖真实持久值;只有三字段都为 0 才是可信的全新设备。
*/
function isLikelyFreshDevice(snap) {
return snap.fvts === 0 && snap.lvts === 0 && snap.tvc === 0;
}
/**
* 是否为「可信的新用户」:非 degraded 直接信任 `isNewUser`degraded 时仅当三字段全 0
* `isLikelyFreshDevice`)才信任,否则视为「读失败的老用户」,走老用户兜底路径。
*/
function isTrustworthyNewUser(snap) {
if (!snap.isNewUser)
return false;
return !snap.degraded || isLikelyFreshDevice(snap);
}
/**
* 从 storage 读取 snapshot。**纯读,无副作用**spy `storage.set` 必须 not.toHaveBeenCalled
*
* 异常处理:
* - 三个 key 任意一个 `safeRead.ok=false` → degraded=true。
* - 后续读到值仍写 snapshot调用方应根据 `degraded` 决策是否上报。
*/
function loadVisitSnapshot() {
const fvtsR = storage.safeRead(KEY_FVTS);
const lvtsR = storage.safeRead(KEY_LVTS);
const tvcR = storage.safeRead(KEY_TVC);
const degraded = !fvtsR.ok || !lvtsR.ok || !tvcR.ok;
const fvts = toNum(fvtsR.value);
const lvts = toNum(lvtsR.value);
const tvc = toNum(tvcR.value);
const snapshot = {
fvts,
lvts,
tvc,
isNewUser: lvts === 0,
degraded,
};
if (degraded) {
const likelyFresh = fvts === 0 && lvts === 0 && tvc === 0 && snapshot.isNewUser;
if (!likelyFresh) {
logger.warn('[uni统计 2.0] visit snapshot degraded; some storage keys read failed');
}
}
loaded = snapshot;
return snapshot;
}
/**
* 取已加载的 snapshot未调用过 `loadVisitSnapshot` 时返回 EMPTY。
*
* 这里不主动调 `loadVisitSnapshot`,避免在错误时机产生隐式 IO
* collector 必须在启动时显式 load 一次。
*/
function ensureLoaded() {
if (!loaded)
loaded = EMPTY_SNAPSHOT;
return loaded;
}
/**
* 新用户首条 lt=1 的**乐观落库**:立即把基线 `fvts/lvts/tvc=now/now/1` 写入 storage
* 并把内存 `loaded`/`committed` 刷新为"非新用户"基线。
*
* 目的:保证一台设备一生只上报一次 `lvts=0`=只计一次新增)。对齐私有版
* `get_first_visit_time`/`get_last_visit_time` 的"读即写"语义。
*
* 与 ack-commit 的关系:
* - 首条 lt=1 仍按 `lvts=0` 上报(在 `buildVisitFields` 里单独构造 pending 返回);
* 即便该条上报失败,也已由 `pipeline/retry` 暂存重试,唯一一次新增信号不丢。
* - 本进程后续续会话(`buildVisitFieldsForSessionRenewal`)命中 `committed` → lvts=now非 0
* 下次冷启动 `loadVisitSnapshot` 读到 storage 里的 lvts=now → `isNewUser=false`。
* - 唯有卸载应用 / 清空缓存清掉基线后,才会重新计一次新增。
*/
function persistNewUserBaseline(now) {
storage.set(KEY_FVTS, now);
storage.set(KEY_LVTS, now);
storage.set(KEY_TVC, 1);
const baseline = {
fvts: now,
lvts: now,
tvc: 1,
isNewUser: false,
degraded: false,
};
loaded = baseline;
committed = baseline;
}
/**
* 生成本次启动要上报的 fvts/lvts/tvc 三元组。
*
* 推进规则:
* - 新用户loaded.isNewUser本次上报 fvts=now, lvts=00 表示新增tvc=1
* **同时立即落库基线**(见 `persistNewUserBaseline`),确保后续不再重复计新增。
* - 老用户:**不写 storage**fvts 维持 loaded.fvtslvts 上报 loaded.lvts"上一次"
* 不是 nowtvc=loaded.tvc+1真正落库由 `commitVisitOnAck` 在 ack 后推进。
*
* 注意:同一进程内只允许调用一次(参考 `domain/session` 设计cst=2/3 新会话应走
* `buildVisitFieldsForSessionRenewal`。这里通过 `buildCalledInProcess` 哨兵防止误用,
* 二次调用返回与首次相同的结果但发出 warn便于排查上层 collector bug。
*/
function buildVisitFields(now) {
const snap = ensureLoaded();
if (buildCalledInProcess && lastBuilt) {
logger.warn('[uni统计 2.0] buildVisitFields() called twice in same process; returning cached fields');
return Object.assign({}, lastBuilt);
}
buildCalledInProcess = true;
if (isTrustworthyNewUser(snap)) {
pending = { fvts: now, lvts: 0, tvc: 1, now };
persistNewUserBaseline(now);
}
else if (snap.isNewUser) {
// degraded 且非全新设备lvts 读失败被误当 0。按老用户兜底**不**上报 lvts=0、
// **不**落库基线storage 不可靠),避免新增虚高与覆盖真实持久值。
logger.warn('[uni统计 2.0] visit degraded: lvts 读取失败但检测到历史数据,按老用户处理以避免新增虚高');
const fvts = snap.fvts > 0 ? snap.fvts : now;
pending = { fvts, lvts: fvts, tvc: snap.tvc + 1, now };
}
else {
pending = {
fvts: snap.fvts,
lvts: snap.lvts,
tvc: snap.tvc + 1,
now,
};
}
lastBuilt = { fvts: pending.fvts, lvts: pending.lvts, tvc: pending.tvc };
return Object.assign({}, lastBuilt);
}
/**
* 为 cst=2/3 新会话 lt=1 生成本次要上报的 visit 字段(**不写 storage**)。
*
* 与私有版 `sendReportRequest` 对齐:后台/前台超时触发的新会话仍携带 fvts/lvts/tvc
* 避免 lvts 缺失被服务端按新用户入库。
*
* 推进规则:
* - 已有 committedfvts 不变lvts 上报 committed.lvtstvc=committed.tvc+1。
* - 冷启动 lt=1 尚未 ack复用 lastBuilt不重复递增 tvc。
* - 兜底读 loaded snapshot逻辑同 buildVisitFields 的老用户路径。
*/
function buildVisitFieldsForSessionRenewal(now) {
let fvts;
let lvts;
let tvc;
if (committed) {
fvts = committed.fvts;
lvts = committed.lvts;
tvc = committed.tvc + 1;
}
else if (lastBuilt) {
fvts = lastBuilt.fvts;
// 防御:新用户冷启首条 lt=1lvts=0尚未 ack 时,本进程后续续会话不能再上报 lvts=0
// 否则同一新设备被重复计新增。此时用"本次启动时间"(=fvts) 作为上一次访问时间。
// 正常路径下 buildVisitFields 已落库基线并置 committed会走上面的 committed 分支。
lvts = lastBuilt.lvts !== 0 ? lastBuilt.lvts : lastBuilt.fvts;
tvc = lastBuilt.tvc;
}
else {
const snap = ensureLoaded();
if (isTrustworthyNewUser(snap)) {
// 续会话成为本进程首条 lt=1 且命中新用户(罕见:未走过冷启 build本条仍按
// lvts=0 计一次新增,并立即落库基线,保证只计一次。
fvts = now;
lvts = 0;
tvc = 1;
persistNewUserBaseline(now);
}
else if (snap.isNewUser) {
// degraded 且非全新设备:按老用户兜底,不上报 lvts=0、不落库基线。
fvts = snap.fvts > 0 ? snap.fvts : now;
lvts = fvts;
tvc = snap.tvc + 1;
}
else {
fvts = snap.fvts;
lvts = snap.lvts;
tvc = snap.tvc + 1;
}
}
pendingRenewal = { fvts, lvts, tvc, now };
return { fvts, lvts, tvc };
}
/**
* 上报 ack 成功后落库。
*
* 实际写入:
* - 新用户:`fvts=now, lvts=now, tvc=1`(本次启动既是首装也是上一次)。
* - 老用户:`fvts` 不变,`lvts=now`(注意:不是 pending.lvts是 commit 时的 now`tvc=pending.tvc`。
*
* pending 为空 / commit 重复调用一律 noop保持幂等便于 collector 重试逻辑)。
*/
function commitVisitOnAck(now) {
if (pending) {
const snap = ensureLoaded();
const newFvts = snap.fvts === 0 ? now : snap.fvts;
const newLvts = now;
const newTvc = pending.tvc;
storage.set(KEY_FVTS, newFvts);
storage.set(KEY_LVTS, newLvts);
storage.set(KEY_TVC, newTvc);
committed = {
fvts: newFvts,
lvts: newLvts,
tvc: newTvc,
isNewUser: false,
degraded: false,
};
loaded = committed;
pending = null;
return;
}
if (!pendingRenewal)
return;
const newFvts = pendingRenewal.fvts;
const newLvts = now;
const newTvc = pendingRenewal.tvc;
storage.set(KEY_FVTS, newFvts);
storage.set(KEY_LVTS, newLvts);
storage.set(KEY_TVC, newTvc);
committed = {
fvts: newFvts,
lvts: newLvts,
tvc: newTvc,
isNewUser: false,
degraded: false,
};
loaded = committed;
pendingRenewal = null;
}
/**
* 上报失败回滚:清掉 pending下次再 build 仍基于 loaded snapshot 推进。
*
* **不**重置 `buildCalledInProcess`:同一进程内即使首批失败,也不允许"重新"再造一份
* fvts/lvts 上报,避免污染。失败的批次应由 `pipeline/retry` 负责持久化重试。
*/
function rollbackPendingVisit() {
pending = null;
pendingRenewal = null;
}
/**
* 入口页entry page记忆与 `iey / ppiey` 计算。
*
* 设计文档:`03-公有版架构设计.md` §4 与 `04-字段字典与平台获取矩阵.md`。
*
* 上行出口:
* - **仅 `lt=11` 携带 `iey` / `ppiey`0/1**`lt=1` / `lt=3` 等事件不含入口字段。
* 字段含义(`lt=11` 在**下一页 onShow** 采集,描述**刚离开的上一页**
* - `iey`:离开页是否为本会话**首次离开的入口页**(会话内仅第一次离开入口路由为 1
* - `ppiey``urlref` 指向页是否仍为**有效入口**(同上,循环回到入口后再离开不算)。
*
* 写入时机(`markEntryPage` 仅维护「本会话入口 path」供 `isEntry` 与 `lt=11` 使用):
* - 新会话:`clearEntry()` 后立刻 `markEntryPage(route)`launch / app_show / 首个 page_show
* 使首屏/恢复后当前页成为本会话登记入口。
* - 同一会话内仅首个 route 生效(一会话一 entry后续 `markEntryPage` noop。
*
* 模块**不持有** lastRouteppiey 由调用方传入"上一页",避免和 `adapter/route` 的
* 当前路由职责耦合。
*/
const KEY_ENTRY = 'session:entryRoute';
let cached$3;
/** 本会话是否已离开过登记入口(离开后循环回入口不再计 iey/ppiey。 */
let entryDeparted = false;
/**
* 标记当前页为入口页。
*
* 行为:
* - 已存在 entry 时直接 noop保证一会话一 entry
* - route 为空字符串 / undefined 时 noop不污染 storage
*/
function markEntryPage(route) {
if (!route)
return;
const existing = getEntryRoute();
if (existing)
return;
storage.set(KEY_ENTRY, route);
cached$3 = route;
}
/**
* 当前会话的入口路径;从内存优先取,未命中读 storage。
*/
function getEntryRoute() {
if (cached$3 !== undefined)
return cached$3 || undefined;
const r = storage.safeRead(KEY_ENTRY);
if (!r.ok)
return undefined;
if (typeof r.value === 'string' && r.value.length > 0) {
cached$3 = r.value;
return r.value;
}
// 标注已查过,避免下次再 IO
cached$3 = '';
return undefined;
}
/**
* 当前路径是否为入口页。
*
* route 为空时返回 false尚未 mark 时返回 false不会把"未知"误判为入口)。
*/
function isEntry(route) {
if (!route)
return false;
const entry = getEntryRoute();
return entry === route;
}
/**
* 当前路径是否仍按入口参与 `iey` / `ppiey` 计算。
*
* 与 `isEntry` 区别:用户首次离开登记入口后,即使再次导航回同一路由也不再视为入口。
*/
function isEntryForIey(route) {
if (entryDeparted)
return false;
return isEntry(route);
}
/**
* 标记本会话已离开登记入口;后续同路由访问不再产生 `iey=1` / `ppiey=1`。
*/
function markEntryDeparted() {
entryDeparted = true;
}
/**
* session 切换时调用:清掉 entry等待新会话第一次 pageShow 重新登记。
*/
function clearEntry() {
cached$3 = '';
entryDeparted = false;
storage.remove(KEY_ENTRY);
}
/**
* pages.json 导航栏标题解析ttpj 数据源)。
*
* 与私有版 `utils/pageInfo.js` 对齐,按 Vue 版本分两套构建期数据源:
* - VUE3`uni:stat` 插件注入 `process.env.UNI_STAT_TITLE_JSON`JSON 字符串);
* - VUE2`require('uni-pages?{"type":"style"}')` 在应用打包阶段解析 pages.json公有版 dist
* 拷贝进 Vue2 工程后无法依赖 define 注入,须与私有版同路径)。
*
* 运行时 `getPagesJsonNavigationTitle` 等价私有版 `get_page_name`。
*/
/** VUE3 懒加载缓存;`undefined` 表示尚未解析。 */
let titleMapCache;
/**
* VUE2 构建期:由 `uni-pages` 虚拟模块解析出的 path → title 表(模块加载时即确定)。
*/
// #ifndef VUE3
function buildVue2TitleMapFromUniPages() {
const titleMap = {};
try {
// eslint-disable-next-line no-restricted-globals
const pagesTitle = require('uni-pages?{"type":"style"}').default;
const pagesData = pagesTitle === null || pagesTitle === void 0 ? void 0 : pagesTitle.pages;
if (!pagesData || typeof pagesData !== 'object')
return titleMap;
for (const path in pagesData) {
const style = pagesData[path];
const navigationBar = style.navigationBar;
const titleText = style.navigationBarTitleText ||
style.defaultTitle ||
(navigationBar === null || navigationBar === void 0 ? void 0 : navigationBar.titleText) ||
'';
if (titleText) {
titleMap[path] = titleText;
}
}
}
catch (_a) {
// uni-pages 不可用时(单测、非 uni 打包上下文)保持空表
}
return titleMap;
}
const vue2TitleMap = buildVue2TitleMapFromUniPages();
// #endif
/**
* VUE3解析并缓存 `UNI_STAT_TITLE_JSON`;解析失败或缺失时得到空表,避免重复 JSON.parse。
*/
// #ifdef VUE3
function getVue3TitleMap() {
if (titleMapCache)
return titleMapCache;
titleMapCache = {};
try {
// 必须直接读 process.env.UNI_STAT_TITLE_JSON勿包 typeof process小程序无 process 时 define 内联字面量会被三元式丢弃,见 install#readManifestStatConfig
const raw = process.env.UNI_STAT_TITLE_JSON;
if (typeof raw !== 'string' || !raw)
return titleMapCache;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
titleMapCache = parsed;
}
}
catch (_a) {
titleMapCache = {};
}
return titleMapCache;
}
// #endif
/**
* 取当前编译目标下的标题映射表。
*/
function getTitleMap() {
let map = {};
// #ifndef VUE3
map = vue2TitleMap;
// #endif
// #ifdef VUE3
map = getVue3TitleMap();
// #endif
return map;
}
/**
* 按当前页路由取 pages.json 中的导航栏标题,供 `setConfigTitle` → 上行 `ttpj`。
*
* @param routePath `getCurrentRoute()` 的典型返回值(一般无前导 `/`);允许含 query。
* @returns 未配置或查找不到时返回空串(与私有版 `get_page_name` 一致)。
*/
function getPagesJsonNavigationTitle(routePath) {
if (!routePath || typeof routePath !== 'string')
return '';
const pathOnly = routePath.split('?')[0].trim();
if (!pathOnly)
return '';
const map = getTitleMap();
let result = '';
// #ifndef VUE3
const direct = map[pathOnly];
result = typeof direct === 'string' && direct.length > 0 ? direct : '';
// #endif
// #ifdef VUE3
const keys = [pathOnly];
if (pathOnly.startsWith('/')) {
keys.push(pathOnly.slice(1));
}
else {
keys.push(`/${pathOnly}`);
}
for (const k of keys) {
const v = map[k];
if (typeof v === 'string' && v.length > 0) {
result = v;
break;
}
}
// #endif
return result;
}
/**
* 导航栏标题内存状态。
*
* 私有版 `Stat._navigationBarTitle = { page, config, report }` 三段被分散维护:
* - `page`:拦截 `uni.setNavigationBarTitle` → `ttn`
* - `config`:构建期注入的 pages.json 映射,运行时由 `getPagesJsonNavigationTitle(route)`(等价私有版 `get_page_name`)在每次 `onShow` 写入 → `ttpj`
* - `report``uni.report('title', value)` / `StatApp.report('title')` → `ttc`。
*
* 公有版集中到 `domain/title.ts`,对外仅暴露 setter / getter / clearForRoute
* statData 拼装时通过 `getCurrentTitle()` 一次性读出,**不再**和拦截器/路由耦合。
*/
const state$2 = { page: '', config: '', report: '' };
/**
* 由拦截器在 `setNavigationBarTitle.invoke` 时调用。
*
* @param title 用户设置的标题;非字符串视为空串,避免污染上行 ttn。
*/
function setPageTitle(title) {
state$2.page = typeof title === 'string' ? title : '';
}
/**
* 由 collector / runtime 在 onPageShow 时写入 pages.json 配置标题。
*/
function setConfigTitle(title) {
state$2.config = typeof title === 'string' ? title : '';
}
/**
* 业务通过 `uni.report('title', value)` 写入;与私有版 `sendEvent('title')` 行为一致。
*/
function setReportTitle(title) {
state$2.report = typeof title === 'string' ? title : '';
}
/**
* 取当前 title 三元组的浅拷贝statData.builder 在拼装 ttn/ttpj/ttc 时调用。
*/
function getCurrentTitle() {
return { ttn: state$2.page, ttpj: state$2.config, ttc: state$2.report };
}
/**
* 切换页面时清掉 page 维度的 titleconfig / report 由各自 setter 控制)。
*/
function clearPageTitle() {
state$2.page = '';
}
/**
* 时间相关基础设施。
*
* 设计要点:
* 1. 单元统一:`Sec` 后缀代表秒级;`Ms` 后缀代表毫秒级。任何函数签名都禁止"参数与
* 返回值单元不一致",避免私有版长期存在的"秒/毫秒混用"问题。
* 2. 全部走 `Date.now()`,不走 `new Date().getTime()`,便于 jest fake timers / mock。
*/
/**
* 当前毫秒级时间戳。用于上报 jitter / 节流定时器等内部计算。
*/
function nowMs() {
return Date.now();
}
/**
* 当前秒级时间戳。用于 statData 上行字段(`t / fvts / lvts / sst` 等)。
*/
function nowSec() {
return Math.floor(Date.now() / 1000);
}
/**
* 将「离开页 / 后台前当前页」停留时长(秒)钳到与私有版 `get_residence_time` 一致:
* 差值小于 1 秒时按 1 秒上报(`residenceTime &lt; 1 ? 1 : residenceTime`)。
*
* @param deltaSec 非负停留秒数优先;传入负数时视为 0 再钳制。
*/
function clampUrlrefStaySec(deltaSec) {
const d = deltaSec > 0 ? deltaSec : 0;
return d < 1 ? 1 : d;
}
/**
* 平台标识适配。
*
* 私有版 `pageInfo.js#get_platform_name` 的能力等价物,但做了三点改进:
* 1. 类型化:返回受控 `Platform` 联合,禁止把陌生平台直接透传出去。
* 2. 拆出 `getRawPlatform()`:返回 `process.env.UNI_PLATFORM` 原值,便于 adapter 内部
* 做"小程序基础库分支"判断,而无需重复读 env。
* 3. `isApp / isMp / isH5 / isNvue` 一次实现,调用方不再四处 `if (platform === 'n')`。
*
* 上行字段约定:
* - `ut` = `getPlatform()`宿主类型wx / h5 / n …)。
* - `p` = 运行设备操作系统(与私有版 `report.js` 中 `sys.platform` 语义一致),由
* `normalizeStatOsP()` 从 `getSystemInfo` 合并结果解析,**不得**再用仅读 `plus` 的
* `getClientOs()` 填小程序(否则恒为 `unknown` → 空串)。
* - `getClientOs()`:保留为 App 端粗分字母 'a' / 'i' / 'h'(历史逻辑),与 `p` 无强绑定。
*
* 注意:本模块严禁缓存平台判定结果到模块级常量。`process.env.UNI_PLATFORM` 在 SSR 与
* 单测中可能被运行时切换;缓存会让多端测试串味。
*/
/**
* 将 uni 系统信息中的 `platform` / `osName` / `system` 归一为上行 `p`。
*
* 与私有版 `report.js``sys.platform` → `a|i|h`)数据源一致,但输出采用完整单词,
* 并覆盖 H5 桌面端windows / macos / linux。小程序依赖 `getDeviceInfo` 等合并后的
* `platform`(如 `ios` / `android``devtools` 无有效机型时再退 `system` / `osName`。
*
* @param info 来自 `mergedSystemInfo()` 的字段子集;均可缺省。
* @returns 小写 OS 名;无法判断时返回空串。
*/
function normalizeStatOsP(info) {
var _a, _b, _c, _d, _e;
const fromToken = (raw) => {
const s = raw.toLowerCase().trim();
if (!s)
return '';
if (s === 'devtools')
return '';
if (s === 'android')
return 'android';
if (s === 'ios' || s === 'iphone')
return 'ios';
if (s.includes('android'))
return 'android';
if (s.includes('iphone') || s === 'iphone os' || /\bios\b/.test(s))
return 'ios';
if (s.includes('harmony') || s === 'ohos' || s === 'openharmony')
return 'harmonyos';
if (s.includes('windows') || s === 'windows_nt')
return 'windows';
if (s === 'mac' || s === 'darwin' || s.includes('mac os') || s === 'macos')
return 'macos';
if (s.includes('linux') && !s.includes('android'))
return 'linux';
return '';
};
const p0 = fromToken((_a = info.platform) !== null && _a !== void 0 ? _a : '');
if (p0)
return p0;
const p1 = fromToken((_b = info.osName) !== null && _b !== void 0 ? _b : '');
if (p1)
return p1;
const sys = ((_c = info.system) !== null && _c !== void 0 ? _c : '').toLowerCase();
if (sys.includes('android'))
return 'android';
if (sys.includes('iphone') || /\bios\b/.test(sys))
return 'ios';
if (sys.includes('harmony') || sys.includes('ohos'))
return 'harmonyos';
if (sys.includes('windows'))
return 'windows';
if (sys.includes('mac os') || sys.includes('darwin'))
return 'macos';
if (sys.includes('linux'))
return 'linux';
const plus = getGlobalObject().plus;
const p2 = fromToken((_e = (_d = plus === null || plus === void 0 ? void 0 : plus.os) === null || _d === void 0 ? void 0 : _d.name) !== null && _e !== void 0 ? _e : '');
if (p2)
return p2;
return '';
}
/**
* 与私有版 `pageInfo.js#get_platform_name` 中 `aliArr.reverse().join('')` 等价:
* 得到 uni-app 注入的「阿里系小程序」`UNI_PLATFORM` 原始键。
*
* 苹果审核会扫描源码中的敏感品牌连续词,故**禁止**在字面量中直接写出完整键名;
* 仅通过片段拼接构造(`mp-` + `ali` + `p` + `a` + `y` 逆序拼接)。
*/
function uniPlatformMpAliRaw() {
const parts = ['y', 'a', 'p', 'mp-ali'];
return [...parts].reverse().join('');
}
/** 私有版兼容映射UNI_PLATFORM → 短码。 */
const PLATFORM_MAP = {
app: 'n',
'app-plus': 'n',
'app-harmony': 'n',
'mp-harmony': 'mhm',
h5: 'h5',
'mp-weixin': 'wx',
[uniPlatformMpAliRaw()]: 'ali',
'mp-baidu': 'bd',
'mp-toutiao': 'tt',
'mp-qq': 'qq',
'mp-kuaishou': 'ks',
'mp-lark': 'lark',
'mp-xhs': 'xhs',
'mp-jd': 'jd',
'quickapp-native': 'qn',
'quickapp-webview': 'qw',
};
/**
* 取 `process.env.UNI_PLATFORM` 原值,未设置返回空字符串。
*
* 单独抽出是为了:
* - 单测可以专门校验"未注入 UNI_PLATFORM"路径,不被 PLATFORM_MAP 遮蔽。
* - 调用方做小程序差异判断(如阿里系再细分 ali/dt时无需再 `process.env.*`。
*/
function getRawPlatform() {
var _a;
return (_a = process.env.UNI_PLATFORM) !== null && _a !== void 0 ? _a : '';
}
/**
* 取标准化后的平台短码。
*
* 阿里系细分逻辑:
* - 命中 `uniPlatformMpAliRaw()` 对应宿主时,若 `globalThis.my.env.clientName === 'dingtalk'` → `dt`。
* - 其他阿里系小程序、H5 中嵌入支付宝端等)继续返回 `'ali'`。
*
* 未识别平台返回 `'unknown'`,禁止把陌生 raw 值直接当作 Platform 透传,
* 避免上行字段污染(私有版的 `return … || process.env.VUE_APP_PLATFORM` 是潜在风险点)。
*/
function getPlatform() {
var _a;
const raw = getRawPlatform();
const mapped = PLATFORM_MAP[raw];
if (!mapped)
return 'unknown';
if (mapped === 'ali') {
const my = getGlobalObject().my;
if (((_a = my === null || my === void 0 ? void 0 : my.env) === null || _a === void 0 ? void 0 : _a.clientName) === 'dingtalk')
return 'dt';
return 'ali';
}
return mapped;
}
/** 当前是否运行在 App / nvue / HarmonyOS App 端。 */
function isApp() {
const raw = getRawPlatform();
return raw === 'app' || raw === 'app-plus' || raw === 'app-harmony';
}
/** 当前是否运行在小程序端(含各平台)。 */
function isMp() {
return getRawPlatform().startsWith('mp-');
}
/** 当前是否运行在 H5 端。 */
function isH5() {
return getRawPlatform() === 'h5';
}
/**
* 当前页面/上下文是否为 nvue。
*
* uni-app nvue 页面的 `__UNI_FEATURE_NVUE__` 编译期常量为 true
* 运行时无可靠 API统一通过编译期 define 注入的 `globalThis.__NVUE__` 判断。
* 没有注入则保守返回 false。
*/
function isNvue() {
return Boolean(getGlobalObject().__NVUE__);
}
/**
* 设备 ID 适配。
*
* 私有版痛点(参考 `pageInfo.js#getUuid` / `get_uuid` / `get_odid`
* - `get_uuid` 优先用 `sys.deviceId`,但 `sys` 是模块加载期 `uni.getSystemInfoSync()`
* 的快照SSR/早期阶段可能不存在 `deviceId` 字段,导致退化路径被频繁走到。
* - 退化路径里 `uni.setStorageSync(UUID_KEY, UUID_VALUE)` —— 这里 `UUID_VALUE` 是
* 字面量字符串 `'__DC_UUID_VALUE'`,会让所有"写入失败"的设备共享同一个 uuid
* 直接污染统计漏斗(缺陷 #28
*
* 公有版职责:
* 1. `getUuid()`(上行 `did`):稳定 + 持久化。
* - **App / H5 / 微信小程序(`mp-weixin`**:优先 `uni.getDeviceInfo().deviceId`
* 再退 `getSystemInfoSync().deviceId`、storage、本地 anon。
* - **其余宿主**:不走 `getDeviceInfo` 首取,直接 `getSystemInfoSync().deviceId` →
* storage → anon与历史兜底一致
* 2. 任何 storage / uni 调用全部走 `tryRun` 兜底,绝不抛出。
* 3. 内存级缓存:避免每次构建 statData 都触发一次同步 storage IO。
* 4. `__resetCache()` 仅供测试。
*
* 说明:老版 `odid``plus.device.uuid`)已移除,不再参与装配与导出。
*/
const STORAGE_KEY_UUID = 'device:uuid';
/**
* uni-h5 `getDeviceInfo().deviceId` 的底层 localStorage 键(见 `packages/uni-h5/src/helpers/uuid.ts`)。
*
* H5 端直接复用同一个键读写 deviceId绕开 uni 运行时,保证 `did` 与页面
* `uni.getDeviceInfo().deviceId` 同源一致,且跨刷新稳定。
*/
const WEB_UUID_KEY = '__DC_STAT_UUID';
let cachedUuid = null;
/**
* App、H5、微信小程序上优先用拆分 API `getDeviceInfo().deviceId`;其它平台保持原兜底顺序。
*/
function preferGetDeviceInfoDeviceIdFirst() {
if (isApp() || isH5())
return true;
return getRawPlatform() === 'mp-weixin';
}
/**
* 读取 `uni.getSystemInfoSync().deviceId`;任何异常 / 缺失返回空串。
*
* 不复用 `adapter/system.getSystemInfo`deviceId 在 uni-app 字段表里属于"敏感字段"
* 公有版默认不暴露在 `SystemInfoStatic` 中,仅在本 adapter 内部使用。
*/
function readSysDeviceId() {
const root = resolveUniRuntime();
const u = root != null && typeof root === 'object'
? root
: undefined;
if (!u || typeof u.getSystemInfoSync !== 'function')
return '';
return tryRun(() => { var _a; return (_a = u.getSystemInfoSync().deviceId) !== null && _a !== void 0 ? _a : ''; }, '');
}
/**
* 读取 `uni.getDeviceInfo().deviceId`(官方推荐的设备标识来源之一)。
*
* API 不存在或抛错时返回空串,由 `getUuid` 继续走 `getSystemInfoSync` / storage 兜底。
*/
function readGetDeviceInfoDeviceId() {
const root = resolveUniRuntime();
const u = root != null && typeof root === 'object'
? root
: undefined;
if (!u || typeof u.getDeviceInfo !== 'function')
return '';
return tryRun(() => { var _a; return (_a = u.getDeviceInfo().deviceId) !== null && _a !== void 0 ? _a : ''; }, '');
}
/**
* 生成兜底设备 iddid**纯数字串**,与常见线上形态一致(毫秒时间戳 + 6 位随机数,约 19 位)。
*
* 与 `infra/sid.genSid` 区别uuid 设备级持久化sid 每会话新生且带 `-xxxx-xxxx` 形后缀。
*/
function generateAnonUuid() {
const ms = nowMs();
const rnd = Math.floor(Math.random() * 1000000)
.toString()
.padStart(6, '0');
return `${ms}${rnd}`;
}
/**
* 把设备 id 写回统计命名空间(`UNI_STAT_DATA:<appid>:device:uuid`),供后续启动读回锁定。
* 写入失败静默吞掉,不影响本次返回。
*/
function persistUuid(uuid) {
tryRun(() => storage.set(STORAGE_KEY_UUID, uuid), undefined);
}
/**
* 取浏览器 `localStorage`cookie 被禁用 / 无 localStorage 时返回 undefined
*
* 直接走 `getGlobalObject()`globalThis / window**绕开可能不可用的 `uni` 运行时**
* 与 `adapter/web.getWebInfo` 直读 `location` 同思路。
*/
function getWebLocalStorage() {
return tryRun(() => {
const g = getGlobalObject();
if (g.navigator && g.navigator.cookieEnabled === false)
return undefined;
const ls = g.localStorage;
if (ls &&
typeof ls.getItem === 'function' &&
typeof ls.setItem === 'function') {
return ls;
}
return undefined;
}, undefined);
}
/**
* H5直接从浏览器 `localStorage` 读取 uni-h5 写入的稳定 deviceId`__DC_STAT_UUID`)。
* 取不到返回空串。
*/
function readWebDeviceId() {
const ls = getWebLocalStorage();
if (!ls)
return '';
return tryRun(() => {
const v = ls.getItem(WEB_UUID_KEY);
return typeof v === 'string' ? v : '';
}, '');
}
/**
* H5把 did 直接写入浏览器 `localStorage`(与 uni-h5 同键 `__DC_STAT_UUID`)。
* 使下次刷新读回同一值,并与页面 `uni.getDeviceInfo().deviceId` 对齐。
*/
function writeWebDeviceId(uuid) {
const ls = getWebLocalStorage();
if (!ls)
return;
tryRun(() => ls.setItem(WEB_UUID_KEY, uuid), undefined);
}
/**
* 从 uni 运行时解析设备 id。
*
* - App / H5 / 微信小程序:优先 `getDeviceInfo().deviceId`,再退 `getSystemInfoSync().deviceId`
* H5 上 `getDeviceInfo().deviceId` 取自 uni-h5 持久化的 `__DC_STAT_UUID`,跨刷新稳定。
* - 其余宿主:直接 `getSystemInfoSync().deviceId`。
*
* 取不到时返回空串,由 `getUuid` 继续走 storage / anon 兜底。
*/
function resolveDeviceIdFromUni() {
if (preferGetDeviceInfoDeviceIdFirst()) {
const fromDeviceInfo = readGetDeviceInfoDeviceId();
if (fromDeviceInfo)
return fromDeviceInfo;
}
return readSysDeviceId();
}
/**
* 取设备 uuid上行映射为 `did`)。
*
* 解析顺序:
* 1. 内存缓存(同进程内恒定,保证同次启动 `did` 与 `sid` 前半段一致)。
* 2. H5直接读浏览器 `localStorage['__DC_STAT_UUID']`uni-h5 写入的 deviceId
* 3. uni 设备源getDeviceInfo / getSystemInfoSync取到即写回 storage 锁定。
* 4. uni storage 已持久化的 did。
* 5. uni storage 正常且无历史值 → 生成 anon 并落库。
* 6. uni storage 读取异常H5 直接写浏览器 localStorage 并缓存;其它端返回不缓存、不落库的临时 did。
*
* 任何环节失败都不抛错,最差返回新生成的临时 uuid保证上行字段非空。
*/
function getUuid() {
if (cachedUuid)
return cachedUuid;
// H5直读浏览器 localStorage 的 deviceId与页面 uni.getDeviceInfo().deviceId 同源。
if (isH5()) {
const fromWeb = readWebDeviceId();
if (fromWeb) {
cachedUuid = fromWeb;
return cachedUuid;
}
}
// uni 设备源;取到后写回 storage 锁定。
const fromDevice = resolveDeviceIdFromUni();
if (fromDevice) {
persistUuid(fromDevice);
if (isH5())
writeWebDeviceId(fromDevice);
cachedUuid = fromDevice;
return cachedUuid;
}
// 设备源不可用:回落到 uni storage 已持久化的 did。
// safeRead 区分「确无历史值」与「storage 读取异常」,后者不落库以免覆盖真实值。
const storedRead = storage.safeRead(STORAGE_KEY_UUID);
if (storedRead.ok) {
const stored = storedRead.value;
if (typeof stored === 'string' && stored.length > 0) {
if (stored.startsWith('device-anon-')) {
const upgraded = generateAnonUuid();
persistUuid(upgraded);
if (isH5())
writeWebDeviceId(upgraded);
cachedUuid = upgraded;
return cachedUuid;
}
cachedUuid = stored;
return cachedUuid;
}
// 无历史值 → 首次生成并落库。
const generated = generateAnonUuid();
persistUuid(generated);
if (isH5())
writeWebDeviceId(generated);
cachedUuid = generated;
return cachedUuid;
}
// uni storage 读取异常。
const ephemeral = generateAnonUuid();
if (isH5()) {
// H5 写浏览器 localStorage 并缓存,下次刷新读回同一值。
writeWebDeviceId(ephemeral);
cachedUuid = ephemeral;
return cachedUuid;
}
// 其它端:临时 did不缓存、不落库避免覆盖磁盘上可能仍存在的真实 did。
return ephemeral;
}
/**
* 会话 ID 生成器。
*
* 形如(与典型调试示例一致):
* - 有 diduuid`${did}-${8位base36}-${4位base36}`,例如 `1777261806777339018-moih1mhr-40gn`
* - 无 did先生成与兜底 did 同形的数字主体,再拼同样后缀(避免 `anon-` 前缀)。
*
* 设计要点:
* 1. 长度可控,避免上报字段超限。
* 2. 仅依赖 `Math.random` 与 `Date.now`,不引入 crypto。
*/
const SUFFIX_HEAD_LEN = 8;
const SUFFIX_TAIL_LEN = 4;
/**
* 生成 base36 随机串。
*
* @param len 期望长度;不足时用 '0' **末尾填充**`padEnd`)补齐,保证长度稳定。
*/
function randomPart(len) {
const r = Math.random()
.toString(36)
.slice(2, 2 + len);
return r.length >= len ? r : r.padEnd(len, '0');
}
/**
* 会话实例后缀:`xxxxxxxx-xxxx`(与常见上报示例形态一致)。
*/
function sessionInstanceSuffix() {
return `${randomPart(SUFFIX_HEAD_LEN)}-${randomPart(SUFFIX_TAIL_LEN)}`;
}
/**
* 无设备 id 时的数字主体(与 device 兜底 did 生成规则对齐,避免引入循环依赖故略重复)。
*/
function anonNumericBody() {
const ms = nowMs();
const rnd = Math.floor(Math.random() * 1000000)
.toString()
.padStart(6, '0');
return `${ms}${rnd}`;
}
/**
* 生成会话 ID。`uuid` 为空字符串 / undefined 时退化为「数字主体 + 后缀」。
*/
function genSid(uuid) {
if (uuid && uuid.length > 0) {
return `${uuid}-${sessionInstanceSuffix()}`;
}
return `${anonNumericBody()}-${sessionInstanceSuffix()}`;
}
/**
* 客户端会话状态机(公有版核心新增)。
*
* 设计文档:`03-公有版架构设计.md` §3。
*
* 状态:
* - `None`:未生成过 session首次启动 / clearStorage 后)。
* - `Active`:前台有有效 session可继续 touch 推进 lastActive。
* - `Background`:应用进入后台,等待返回前台时判定是否超时。
*
* 触发器 → cst 映射:
* - `cold_launch`:进程冷启动 → cst=1。
* - `app_show` (从后台返回)
* - now - bgTs >= backgroundTimeoutSec → 新 session, cst=2与私有版 pageTime
* 可读性对齐:配置为 10 秒时,隐藏端与显示端秒戳相差 10 即视为超时)。
* - 否则复用旧 session, cst=0。
* - `wx_scene_changed`scene 与上次不同 → 新 session, cst=2。
* - `page_show` (前台已有 session)
* - now - lastActive >= pageInactiveTimeoutSec → 新 session, cst=3。
* - 否则 touch & 复用, cst=0。
*
* 关键设计:所有 storage 操作都带 try / safeRead 兜底;任何路径都不抛异常,
* 失败 → 退化生成新 session避免阻塞采集链路。
*/
const KEY_SID = 'session:id';
const KEY_SST = 'session:start';
const KEY_SCT = 'session:sct';
const KEY_SEQ = 'session:seq';
const KEY_LAST_ACTIVE = 'session:lastActive';
const KEY_BG_TS = 'session:bgTs';
const KEY_LAST_SCENE = 'session:lastScene';
const DEFAULT_CONFIG = {
backgroundTimeoutSec: 300,
pageInactiveTimeoutSec: 1800,
};
let config$1 = Object.assign({}, DEFAULT_CONFIG);
let cached$2 = null;
/** 配置注入runtime/install.ts 在启动时调一次)。 */
function configure$1(c) {
config$1 = Object.assign({}, DEFAULT_CONFIG, c);
}
/**
* 工具:把 storage 读取到的值转 number非法值返回 0。
*/
function readNum(key) {
const r = storage.safeRead(key);
if (!r.ok)
return 0;
const v = r.value;
if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
return v;
if (typeof v === 'string' && v.length > 0) {
const n = Number(v);
if (Number.isFinite(n) && n >= 0)
return n;
}
return 0;
}
function readStr(key) {
const r = storage.safeRead(key);
if (!r.ok)
return '';
return typeof r.value === 'string' ? r.value : '';
}
/**
* 计算 `now - from` 的非负秒差防止设备时钟回拨NTP 校时 / 用户手动改时间)导致
* `elapsed < 0` 使后台 / 无操作超时判定**永不触发**、会话被异常拉长。
*
* 与 `infra/time.elapsedSec` 同语义(负值钳零),此处因状态机入参 `now` 由调用方注入、
* 不直接走 `nowSec()`,故就地实现保持纯函数可测。
*/
function elapsedNonNeg(now, from) {
const diff = now - from;
return diff > 0 ? diff : 0;
}
/**
* 从 storage 重建 snapshot任意字段缺失返回 null。
*/
function loadFromStorage() {
const sid = readStr(KEY_SID);
if (!sid)
return null;
return {
sid,
sst: readNum(KEY_SST),
sct: (readNum(KEY_SCT) || CST.ColdLaunch),
seq: readNum(KEY_SEQ),
lastActive: readNum(KEY_LAST_ACTIVE),
bgTs: readNum(KEY_BG_TS),
lastScene: readStr(KEY_LAST_SCENE),
};
}
function ensureCache() {
if (cached$2 !== null)
return cached$2;
cached$2 = loadFromStorage();
return cached$2;
}
/**
* 创建一个新 session写入 storage 并返回新的 snapshot。
*
* 内部职责:
* - 重置 seq=0、lastActive=now、bgTs=0。
*
* 注:原"上一会话 sidpid"机制已移除——参数文档无 pid 字段,后端无入库口径,
* 新会话的字段仅随当次 lt=1 携带。
*/
function createNew(now, sct, scene) {
const sid = genSid(getUuid());
const next = {
sid,
sst: now,
sct,
seq: 0,
lastActive: now,
bgTs: 0,
lastScene: scene,
};
storage.set(KEY_SID, sid);
storage.set(KEY_SST, now);
storage.set(KEY_SCT, sct);
storage.set(KEY_SEQ, 0);
storage.set(KEY_LAST_ACTIVE, now);
storage.set(KEY_BG_TS, 0);
storage.set(KEY_LAST_SCENE, scene);
cached$2 = next;
return next;
}
/**
* 主入口:根据 trigger 与上下文,确保 session 处于正确状态。
*
* 结果包含 isNew / cst供 lifecycleHooks 决定本次 lt=1 是否携带 fvts/lvts/tvc。
*/
function ensureSession(t, ctx) {
const { now, scene = '' } = ctx;
const snap = ensureCache();
if (t === 'cold_launch') {
const created = createNew(now, CST.ColdLaunch, scene);
return { snapshot: created, isNew: true, cst: CST.ColdLaunch };
}
if (!snap) {
// 没有现存 session罕见app_show 但 storage 被清)→ 视为冷启动
const created = createNew(now, CST.ColdLaunch, scene);
return { snapshot: created, isNew: true, cst: CST.ColdLaunch };
}
if (t === 'app_show') {
const enterCandidates = [];
if (ctx.backgroundEnteredAt && ctx.backgroundEnteredAt > 0) {
enterCandidates.push(ctx.backgroundEnteredAt);
}
if (snap.bgTs > 0) {
enterCandidates.push(snap.bgTs);
}
const enterTs = enterCandidates.length > 0 ? Math.min(...enterCandidates) : 0;
const elapsed = enterTs > 0
? elapsedNonNeg(now, enterTs)
: elapsedNonNeg(now, snap.lastActive);
const sceneChanged = !!scene && !!snap.lastScene && scene !== snap.lastScene;
const fromBackground = enterTs > 0;
if (sceneChanged ||
(fromBackground && elapsed >= config$1.backgroundTimeoutSec)) {
const created = createNew(now, CST.BackgroundTimeout, scene);
return { snapshot: created, isNew: true, cst: CST.BackgroundTimeout };
}
// 未超时:清 bgTs更新 lastActive
touch(now);
storage.set(KEY_BG_TS, 0);
if (cached$2)
cached$2.bgTs = 0;
return { snapshot: cached$2, isNew: false, cst: 0 };
}
if (t === 'wx_scene_changed') {
if (scene && scene !== snap.lastScene) {
const created = createNew(now, CST.BackgroundTimeout, scene);
return { snapshot: created, isNew: true, cst: CST.BackgroundTimeout };
}
return { snapshot: snap, isNew: false, cst: 0 };
}
// page_show判定前台无操作超时
const elapsed = elapsedNonNeg(now, snap.lastActive);
if (elapsed >= config$1.pageInactiveTimeoutSec) {
const created = createNew(now, CST.PageInactiveTimeout, scene || snap.lastScene);
return { snapshot: created, isNew: true, cst: CST.PageInactiveTimeout };
}
touch(now);
return { snapshot: cached$2, isNew: false, cst: 0 };
}
/**
* 标记应用进入后台。写入 bgTs供下次 app_show 判定超时。
*/
function markBackground(now) {
if (!cached$2)
cached$2 = loadFromStorage();
if (!cached$2)
return;
storage.set(KEY_BG_TS, now);
cached$2.bgTs = now;
}
/**
* 更新 lastActivepage_show 与**用户主动行为事件**collector 在收到 lt=21 自定义/
* 拦截器事件时调用触发。这样「前台无操作超时cst=3」与文档「无任何 page/event
* 触达」语义一致:用户持续点按但不翻页时不会被误判为无操作而开新会话。
*/
function touch(now) {
if (!cached$2)
cached$2 = loadFromStorage();
if (!cached$2)
return;
storage.set(KEY_LAST_ACTIVE, now);
cached$2.lastActive = now;
}
/**
* 取下一个 seq先递增 storage 中的 seq再返回新值。
*
* 失败兜底:若 storage 异常,仍以内存 cached.seq 自增;保证序号单调,但跨进程可能跳号。
*/
function nextSeq() {
if (!cached$2)
cached$2 = loadFromStorage();
if (!cached$2)
return 0;
const next = cached$2.seq + 1;
cached$2.seq = next;
storage.set(KEY_SEQ, next);
return next;
}
/** 取当前 snapshot未初始化时尝试从 storage 加载,仍为空返回 null。 */
function getSnapshot() {
return ensureCache();
}
/**
* 同步更新 session 的 lastScene不触发新会话。
*
* 用于同一次回前台多 hook 携带不同 scene 时,避免重复 lt=1 后补写正确 scene。
*/
function syncLastScene(scene) {
if (!scene)
return;
if (!cached$2)
cached$2 = loadFromStorage();
if (!cached$2)
return;
storage.set(KEY_LAST_SCENE, scene);
cached$2.lastScene = scene;
}
/**
* 页面路由适配。
*
* 私有版痛点(参考 `pageInfo.js#get_route / get_page_route / get_page_vm`
* - `get_page_route` 失败兜底用 `uni.getStorageSync('_STAT_LAST_PAGE_ROUTE')`
* 但这个 key 写入位置散落在 `report.js` 多处,时序复杂、容易脏。
* - 百度小程序 `_self.$mp.page.is` 取页面路由,逻辑硬编码在 `get_route` 里,
* 新平台进来必须改这一处主流程。
* - `get_page_vm` 直接调 `getCurrentPages()`:在 `onHide` 之后窗口栈可能为空。
*
* 公有版职责:
* 1. `getCurrentRoute()`:稳定获取当前页 path支持显式传入 pageVm 与多端兜底。
* 2. `getCurrentRouteWithQuery()`:取带 query 的完整 fullPath。
* 3. `parseQuery()`:解析 query string 为对象(不依赖 url-search-paramsnvue 兼容)。
* 4. 全部 try/catch永远返回 `string` / `object`,不返回 undefined。
*
* 与私有版兼容:上行字段 `url`(不含 query/ `urlref`(前页 url等仍由
* `domain/statData` 拼装,本层只提供原料。
*/
/**
* 判定当前 vm 是页面还是应用(对齐私有版 `pageInfo.js#get_page_types`)。
*
* Vue2 下应用前后台走 mixin 的 App `onShow` / `onHide`,不能仅靠 `uni.onAppShow`。
*/
function getPageVmType(vm) {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (!vm)
return null;
const internalMpType = (_c = (_b = (_a = vm.$) === null || _a === void 0 ? void 0 : _a.type) === null || _b === void 0 ? void 0 : _b.mpType) !== null && _c !== void 0 ? _c : (_d = vm.type) === null || _d === void 0 ? void 0 : _d.mpType;
if (vm.mpType === 'page' ||
vm.$mpType === 'page' ||
((_e = vm.$mp) === null || _e === void 0 ? void 0 : _e.mpType) === 'page' ||
((_f = vm.$options) === null || _f === void 0 ? void 0 : _f.mpType) === 'page' ||
internalMpType === 'page') {
return 'page';
}
if (vm.mpType === 'app' ||
vm.$mpType === 'app' ||
((_g = vm.$mp) === null || _g === void 0 ? void 0 : _g.mpType) === 'app' ||
((_h = vm.$options) === null || _h === void 0 ? void 0 : _h.mpType) === 'app' ||
internalMpType === 'app') {
return 'app';
}
return null;
}
/**
* 取栈顶页面实例vm
*
* 优先 `getCurrentPages()`;若不可用或栈为空返回 `undefined`。
*/
function getTopPageVm() {
var _a;
const fn = getGlobalObject().getCurrentPages;
if (typeof fn !== 'function')
return undefined;
const pages = tryRun(() => fn(), []) || [];
if (!Array.isArray(pages) || pages.length === 0)
return undefined;
const top = pages[pages.length - 1];
return (_a = top === null || top === void 0 ? void 0 : top.$vm) !== null && _a !== void 0 ? _a : top;
}
/**
* 取当前页面路径(不含 query
*
* 取值顺序:
* 1. 显式 pageVm 优先mixin 收到的 self/this
* 2. 百度小程序:`vm.$mp.page.is` / `vm.$scope.is`。
* 3. 通用:`vm.route` → `vm.$scope.route` → `vm.$mp.page.route`。
* 4. 取栈顶 page 兜底。
* 5. 全失败返回 ''。
*/
function getCurrentRoute(pageVm) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
const vm = pageVm !== null && pageVm !== void 0 ? pageVm : getTopPageVm();
if (!vm)
return '';
if (getPlatform() === 'bd') {
const r = (_e = (_c = (_b = (_a = vm.$mp) === null || _a === void 0 ? void 0 : _a.page) === null || _b === void 0 ? void 0 : _b.is) !== null && _c !== void 0 ? _c : (_d = vm.$scope) === null || _d === void 0 ? void 0 : _d.is) !== null && _e !== void 0 ? _e : '';
if (r)
return r;
}
return (_l = (_h = (_f = vm.route) !== null && _f !== void 0 ? _f : (_g = vm.$scope) === null || _g === void 0 ? void 0 : _g.route) !== null && _h !== void 0 ? _h : (_k = (_j = vm.$mp) === null || _j === void 0 ? void 0 : _j.page) === null || _k === void 0 ? void 0 : _k.route) !== null && _l !== void 0 ? _l : '';
}
/**
* 取当前页 fullPath含 query无 query 返回与 `getCurrentRoute` 一致。
*
* 取值顺序vm.$page.fullPath → vm.$scope.$page.fullPath → 退化 route。
* 与私有版一致fullPath === '/' 时退到 route避免根路径 query 丢失。
*/
function getCurrentRouteWithQuery(pageVm) {
var _a, _b;
const vm = pageVm !== null && pageVm !== void 0 ? pageVm : getTopPageVm();
if (!vm)
return '';
const page = (_a = vm.$page) !== null && _a !== void 0 ? _a : (_b = vm.$scope) === null || _b === void 0 ? void 0 : _b.$page;
if (page) {
if (page.fullPath && page.fullPath !== '/')
return page.fullPath;
if (page.route)
return page.route;
}
return getCurrentRoute(vm);
}
/**
* 应用生命周期 + 场景值适配。
*
* 公有版职责:
* 1. 把 `uni.onAppShow / onAppHide / onLaunch` 这一组事件抽象成"订阅 + 解绑"形式,
* 供 `domain/session` 与 `pipeline/collector` 复用,避免业务层直接吃 uni API。
* 2. 兜底所有调用uni 缺失时 unsubscribe 为 noop订阅失败不抛。
* 3. `getLaunchScene()`:私有版 `get_scene` 仅限 wx公有版补全 mp-qq / mp-toutiao /
* mp-baidu / 阿里系小程序宿主 / mp-lark / mp-kuaishou并允许覆写页面自带 scene
*
* 注意:本模块不维护订阅注册表(去重逻辑由 `infra/interceptor` 与 `runtime/install`
* 处理),保持单一职责。
*/
function getUni$9() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 启动时取场景值。优先级:
* 1. 调用方显式传入 `override`(如页面 onLoad 收到的 options.scene
* 2. `uni.getLaunchOptionsSync().scene`(多端通用)。
* 3. 不识别的平台返回空字符串。
*
* 公有版扩展:所有小程序宿主(`mp-*`,含 wx/qq/tt/bd/阿里系/lark/ks/xhs/jd/harmony 等)
* 均支持 `getLaunchOptionsSync().scene`,故统一以 `isMp()` 判定,避免逐个平台维护白名单时
* 漏掉新增小程序端导致 scene 恒为空H5 / App / 快应用无场景值,返回空串)。
*/
function getLaunchScene(override) {
if (override !== undefined && override !== null && override !== '') {
return String(override);
}
const u = getUni$9();
if (typeof (u === null || u === void 0 ? void 0 : u.getLaunchOptionsSync) !== 'function')
return '';
// 仅小程序宿主有有意义的 scene其它端即便存在 getLaunchOptionsSync 也无场景值。
if (!isMp())
return '';
return tryRun(() => {
const opts = u.getLaunchOptionsSync();
const scene = opts === null || opts === void 0 ? void 0 : opts.scene;
return scene === undefined || scene === null ? '' : String(scene);
}, '');
}
/**
* uniPush ClientID 适配。
*
* 私有版(`core/stat.js#pushEvent`)逻辑:
* - 调 `uni.getPushClientId({ success(res){ cid = res.cid } })`,无 cid 直接丢弃。
* - 没有超时;某些机型 push 服务异常时,回调永远不来,导致这次 launch 的 push 上报丢失。
* - 返回值包在 success 回调里,无法 await不便 pipeline 串接。
*
* 公有版职责:
* 1. `getPushClientId({ enabled, timeoutMs })` 返回 `Promise<PushClientResult>`
* 永不 reject超时 / 失败 / 关闭统一返回 `{ ok: false, cid: '' }`。
* 2. `enabled` 默认 false合规要求显式开启调用方应从 `config.uniPushClientID`
* 透传。
* 3. 不缓存:业务方需要会话维度复用时在 `domain/push.ts` 中缓存(待 Phase 5 接入)。
*/
function getUni$8() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 异步取 push clientId。
*
* 任意异常路径都 resolve永不 reject调用方只需根据 `ok` 字段判断是否上报。
*/
function getPushClientId(opts = {}) {
const { enabled = false, timeoutMs = 3000 } = opts;
return new Promise((resolve) => {
if (!enabled) {
resolve({ ok: false, cid: '', reason: 'disabled' });
return;
}
const u = getUni$8();
if (!u || typeof u.getPushClientId !== 'function') {
resolve({ ok: false, cid: '', reason: 'unsupported' });
return;
}
let settled = false;
const finish = (r) => {
if (settled)
return;
settled = true;
resolve(r);
};
const timer = setTimeout(() => finish({ ok: false, cid: '', reason: 'timeout' }), timeoutMs);
tryRun(() => u.getPushClientId({
success: (res) => {
clearTimeout(timer);
const cid = typeof (res === null || res === void 0 ? void 0 : res.cid) === 'string' ? res.cid : '';
if (!cid) {
finish({ ok: false, cid: '', reason: 'fail' });
return;
}
finish({ ok: true, cid });
},
fail: () => {
clearTimeout(timer);
finish({ ok: false, cid: '', reason: 'fail' });
},
}), undefined);
});
}
/**
* 生命周期 → collector 调度桥。
*
* 把 vue mixin / `uni.onAppShow|onAppHide` / push clientId 这些"运行时事件源"
* 翻译成 collector 的 `report({lt, ...})` 调用,统一处理:
* - 会话状态机ensureSession / markBackground
* - 入口页登记entryPage
* - lastRoute / urlref / urlref_ts 维护。
* - 新会话首报:仅发一条 `lt=1`Launch新会话字段sid/cst/fvts/lvts/tvc随之上行
* `app_show` 新 sid 后立即 **flush(true)**`page_show`cst=3新 sid 在本轮 lt=11
* 入队后 **flush(true)**(同批内 `LT_ORDER` 保证 lt=1 先于 lt=11。与 `app_hide` 的
* lt=3+flush 一并降低「锚点未送达、后续已用新 sid」的丢失风险与参数文档对齐
* 不再发已废弃的 `lt=0`)。
* - push CID 异步抓取后再发 `lt=101`,超时 / 失败静默丢弃。
*
* 暴露:
* - `bindLifecycle(app, opts?)`:返回 mixin / onAppShow / onAppHide 句柄与
* unbind 函数runtime/install.ts 据此装到 vue & uni 上。
* - 内部句柄(`onLaunch / onAppShow / onAppHide / onPageShow / onPageHide / onError`
* 单独导出便于单测精准触发。
*
* 注意:本模块**不直接**依赖任何 adapter除 `getCurrentRoute*` 与 `getLaunchScene`
* adapter 调用全部走 `tryRun` 兜底,单端缺失不影响调度。
*/
const EMPTY_TITLE_SNAP = { ttn: '', ttpj: '', ttc: '' };
/** 模块级状态。`bindLifecycle` 返回的 unbind 仅断订阅,不重置 state。 */
const state$1 = {
lastRoute: '',
lastRouteFull: '',
beforeLastRoute: '',
beforeLastRouteFull: '',
lastRouteEnterTime: 0,
lastPageTitleSnap: Object.assign({}, EMPTY_TITLE_SNAP),
lastIey: false,
prevIey: false,
isHide: false,
wasBackgrounded: false,
pendingBackgroundResume: false,
backgroundEnteredAt: 0,
suppressNextPageLogAfterResume: false,
backgroundResumeLt1At: 0,
};
/** Vue2 H5 hide 过程中偶发 page onShow间隔≈0s低于此阈值不消费 pending。 */
const BACKGROUND_RESUME_DEBOUNCE_SEC = 1;
/** 同一次回前台多 hook 重复 lt=1 的去重窗口(秒)。 */
const BACKGROUND_RESUME_LT1_DEDUP_SEC = 3;
/** 小程序等page onHide 延迟判定「真进后台」;切页会在短时内 onShow 并取消。 */
const PAGE_APP_HIDE_DEFER_MS = 120;
let pageAppHideDeferTimer;
/**
* Vue3 小程序等已绑定 `uni.onAppShow` 时,后台恢复应仅由 `handleAppShow` 消费。
*
* mixin / page onShow 过早消费会在 QQ 等端与 uni 回调形成双 hook且 scene 不一致
* (如 2001 vs 1011导致重复 lt=1。Vue2 / H5 仍走 mixin 提前消费以适配 Page 先于 App onShow。
*/
function shouldEarlyConsumeBackgroundResumeInMixin() {
return !shouldBindUniAppLifecycle();
}
/**
* 记录本进程内最近一次后台恢复 lt=1 上报时刻。
*/
function markBackgroundResumeLt1Emitted(now) {
state$1.backgroundResumeLt1At = now;
}
/**
* 同一次回前台是否已在去重窗口内上报过后台恢复 lt=1。
*/
function shouldSkipDuplicateBackgroundResumeLt1(now) {
return (state$1.backgroundResumeLt1At > 0 &&
now - state$1.backgroundResumeLt1At <= BACKGROUND_RESUME_LT1_DEDUP_SEC);
}
/**
* 取消 page onHide 触发的延迟进后台判定Vue2/Vue3 共用)。
*/
function cancelPageAppHideDefer() {
if (pageAppHideDeferTimer !== undefined) {
clearTimeout(pageAppHideDeferTimer);
pageAppHideDeferTimer = undefined;
}
}
/**
* H5 进后台时部分工程只触发 page onHideVue2/Vue3 均可能出现),
* 需在 visibility 已为 hidden 时补记 lt=3普通切页不会满足 hidden。
*/
function tryAppHideFromPageOnHideWhenH5Hidden(app, opts) {
var _a;
if (!isH5())
return;
if (state$1.pendingBackgroundResume)
return;
const vis = (_a = globalThis.document) === null || _a === void 0 ? void 0 : _a.visibilityState;
if (vis === 'hidden') {
handleAppHide(app, opts);
}
}
/**
* 小程序等非 H5page onHide 后短时延迟补记 lt=3若随后 page onShow 则取消(切页)。
*/
function tryAppHideFromPageOnHideWhenMpDefer(app, opts) {
if (isH5())
return;
if (state$1.pendingBackgroundResume)
return;
cancelPageAppHideDefer();
pageAppHideDeferTimer = setTimeout(() => {
pageAppHideDeferTimer = undefined;
if (state$1.pendingBackgroundResume)
return;
handleAppHide(app, opts);
}, PAGE_APP_HIDE_DEFER_MS);
}
// #ifndef VUE3
/**
* Vue2 部分端进后台只触发 page onHide须在此时补 `handleAppHide`。
*
* 普通切页也会触发 page onHide**不能**无条件补记(否则多报 lt=3
* 对齐私有版 `pageHide`:仅当 `__licationHide` 为真时不再发页面离开;此处等价于:
* - H5`document.visibilityState === 'hidden'` 才补记;
* - 其它端:短时延迟,若下一页 onShow 则取消(切页),否则视为进后台。
*/
function tryVue2AppHideFromPageOnHide(app, opts) {
if (state$1.pendingBackgroundResume)
return;
if (isH5()) {
tryAppHideFromPageOnHideWhenH5Hidden(app, opts);
return;
}
tryAppHideFromPageOnHideWhenMpDefer(app, opts);
}
// #endif
// #ifdef VUE3
/**
* Vue3 进后台补记 lt=3
* - H5page onHide + visibility hiddenApp onHide 常不触发);
* - 小程序等:`uni.onAppHide` 为主路径page onHide 延迟为兜底(部分端/时序不触发 uni 回调)。
*/
function tryVue3AppHideFromPageOnHide(app, opts) {
if (state$1.pendingBackgroundResume)
return;
if (isH5()) {
tryAppHideFromPageOnHideWhenH5Hidden(app, opts);
return;
}
tryAppHideFromPageOnHideWhenMpDefer(app, opts);
}
// #endif
/**
* 取 collector未 install 时返回 undefined调用方需负责 noop
*/
function safeCollector(app) {
return app.getCollector();
}
/**
* 将 onLaunch / onShow 透传的 path 归一为 `markEntryPage` 使用的 route去 query、去前导 `/`)。
*
* 与 `getCurrentRoute()` 常见返回值对齐,避免入口登记与实际页面 path 不一致。
*/
function normalizePathForEntryMark(raw) {
var _a;
if (!raw || typeof raw !== 'string')
return '';
const noQuery = (_a = raw.split('?')[0]) !== null && _a !== void 0 ? _a : '';
return noQuery.startsWith('/') ? noQuery.slice(1) : noQuery;
}
/**
* 新会话首报:仅发一条 `lt=1`Launch新会话字段随之上行。
*
* 重要约束(修复 lvts=0 / lvts 缺失缺陷):
* - 进程内首次 lt=1cold_launch调用 `buildVisitFields`
* - cst=2/3 新会话 lt=1 调用 `buildVisitFieldsForSessionRenewal`,仍携带 fvts/lvts/tvc
* 避免 lvts 缺失被服务端误判为新用户。
* - 通过 `firstVisitEmittedInProcess` 哨兵区分上述两条路径。
* - `cst` 入参仅用于将来可能的本地侧打印 / 监控;上行字段已由 statData 从 session
* snapshot 中读取(出口字段名为 `cst`)。
* - `url` 参数:参数文档要求 `lt=1` 携带当前启动页的完整 url冷启动 / app_show
* 可从 launch options.path 兜底page_show 触发的 cst=3 由调用方直接传当前页路径。
*/
function reportNewSession(c, _cst, scene, now, attachVisit, url = '') {
let visit;
if (attachVisit && !firstVisitEmittedInProcess) {
firstVisitEmittedInProcess = true;
visit = tryRun(() => buildVisitFields(now), undefined);
}
else {
// 续会话attachVisit=false或同进程内冷启 lt=1 已发过又被二次触发
// attachVisit=true 但 firstVisitEmittedInProcess 已 true都复用 renewal 字段,
// 确保 lt=1 始终携带 fvts/lvts/tvc杜绝"裸 lt=1 缺 lvts 被服务端按新增计入"。
visit = tryRun(() => buildVisitFieldsForSessionRenewal(now), undefined);
}
const payload = {
lt: LT.Launch,
t: now,
sc: scene,
visit,
};
// url 仅当非空时携带;避免 lt=1 上行体出现 url=""(参数文档要求 url 至多 255 字符,但允许缺省)。
if (url)
payload.url = url;
c.report(payload);
}
/** 进程内是否已发过首批访问字段fvts/lvts/tvc。 */
let firstVisitEmittedInProcess = false;
/**
* 标题三元组快照代数hide 优先写入快照并 ++show 尾部 microtask 携带快照时的代数,
* 若已被 hide 抢先递增则丢弃 microtask避免「新页刚灌的 ttpj」顶替「离开页」应有的 ttn/ttc。
*/
let titleSnapGeneration = 0;
/** 在同步栈清空后再采样标题,确保晚于页面自己的 onShow / setNavigationBarTitle。 */
function scheduleDeferredTitleSnapshot() {
const gen = titleSnapGeneration;
const run = typeof queueMicrotask === 'function'
? queueMicrotask
: (fn) => {
void Promise.resolve().then(fn);
};
run(() => {
tryRun(() => {
if (gen !== titleSnapGeneration)
return;
state$1.lastPageTitleSnap = Object.assign({}, getCurrentTitle());
}, undefined);
});
}
/**
* App.onLaunch冷启动入口。
*
* 流程:
* 1. ensureSession('cold_launch') → cst=1必产新 session。
* 2. 发一条 lt=1携带 sid/cst/fvts/lvts/tvc/sc/url
* 3. 异步抓 push CID成功后发 lt=101。
* 4. 兜底 onLaunch options 可能携带 scene / path小程序path 透传成 lt=1 的 url。
*/
function handleLaunch(app, options = {}, opts = {}) {
const c = safeCollector(app);
if (!c)
return;
const now = nowSec();
const scene = tryRun(() => getLaunchScene(options.scene), '');
const result = tryRun(() => ensureSession('cold_launch', { now, scene }), null);
if (!result)
return;
// 冷启动同样视为新会话:清旧入口登记,再按 launch path 登记入口,最后发 lt=1不含 iey入口仅 lt=11
tryRun(() => clearEntry(), undefined);
const url = options.path || '';
const entryKey = normalizePathForEntryMark(url);
if (entryKey) {
tryRun(() => markEntryPage(entryKey), undefined);
}
reportNewSession(c, result.cst || CST.ColdLaunch, scene, now, true, url);
if (opts.enablePush) {
void getPushClientId({ enabled: true, timeoutMs: opts.pushTimeoutMs })
.then((r) => {
if (!r.ok || !r.cid)
return;
const c2 = safeCollector(app);
if (!c2)
return;
c2.report({ lt: LT.Push, cid: r.cid, t: nowSec() });
})
.catch((e) => logger.warn('[uni统计 2.0] push cid fetch failed', e));
}
}
/**
* 消费「从后台回前台」会话判定cst=2返回 true 表示已处理(含防抖跳过但仍保留 pending
*
* Vue2Page onShow 常早于 App onShow且 hide 过程中可能误触发一次 page onShow
* 若在那时清空 pending/wasBackgroundedApp onShow 将看不到后台标记(用户截图现象)。
*/
function tryConsumeBackgroundResume(app, options = {}, _opts = {}, _from = 'unknown') {
if (!state$1.pendingBackgroundResume) {
return false;
}
const bgEnterAt = state$1.backgroundEnteredAt;
if (bgEnterAt <= 0) {
return false;
}
const c = safeCollector(app);
if (!c) {
return false;
}
const now = nowSec();
const elapsed = now - bgEnterAt;
if (elapsed < BACKGROUND_RESUME_DEBOUNCE_SEC) {
state$1.suppressNextPageLogAfterResume = true;
return true;
}
state$1.wasBackgrounded = false;
state$1.suppressNextPageLogAfterResume = true;
state$1.lastRouteEnterTime = now;
const scene = tryRun(() => getLaunchScene(options.scene), '');
const result = tryRun(() => ensureSession('app_show', {
now,
scene,
backgroundEnteredAt: bgEnterAt,
}), null);
state$1.pendingBackgroundResume = false;
state$1.backgroundEnteredAt = 0;
if (!result || !result.isNew) {
return true;
}
tryRun(() => clearEntry(), undefined);
const url = options.path || state$1.lastRoute || '';
const entryKey = normalizePathForEntryMark(url);
if (entryKey) {
tryRun(() => markEntryPage(entryKey), undefined);
}
reportNewSession(c, result.cst || CST.BackgroundTimeout, scene, now, false, url);
markBackgroundResumeLt1Emitted(now);
void c
.flush(true)
.catch((e) => logger.warn('[uni统计 2.0] flush after new session (app_show) failed', e));
return true;
}
/**
* 应用从后台进入前台。
*
* 流程:
* 1. `tryConsumeBackgroundResume`pending→ ensureSession('app_show') / cst=2。
* 2. isNew=true 时发一条 lt=1并 **flush(true)**。
* 3. 无 pending 时仅处理 scene 变化等(少见)。
*/
function handleAppShow(app, options = {}, opts = {}) {
if (tryConsumeBackgroundResume(app, options, opts, 'handleAppShow'))
return;
const c = safeCollector(app);
if (!c)
return;
const now = nowSec();
const scene = tryRun(() => getLaunchScene(options.scene), '');
if (shouldSkipDuplicateBackgroundResumeLt1(now)) {
tryRun(() => syncLastScene(scene), undefined);
return;
}
const result = tryRun(() => ensureSession('app_show', { now, scene }), null);
if (!result || !result.isNew) {
return;
}
tryRun(() => clearEntry(), undefined);
const url = options.path || state$1.lastRoute || '';
const entryKey = normalizePathForEntryMark(url);
if (entryKey) {
tryRun(() => markEntryPage(entryKey), undefined);
}
reportNewSession(c, result.cst || CST.BackgroundTimeout, scene, now, false, url);
markBackgroundResumeLt1Emitted(now);
void c
.flush(true)
.catch((e) => logger.warn('[uni统计 2.0] flush after new session (app_show) failed', e));
}
/**
* 应用进入后台。
*
* 流程:
* 1. markBackground(now):写 bgTs让下次 app_show 能算超时。
* 2. 若存在当前页且启用页面日志:先发一条 lt=11闭合"离开当前页"语义(含 url/urlref/urlref_ts/iey/ppiey/title
* 3. 再发 lt=3保留"应用进入后台"语义urlref=urlref_ts 指向后台前最后可见页)。
* 4. 进入后台后强制 flushforce=true尽量在被 kill 前送出。
*/
function handleAppHide(app, opts = {}) {
if (state$1.pendingBackgroundResume)
return;
const c = safeCollector(app);
if (!c)
return;
const now = nowSec();
state$1.wasBackgrounded = true;
state$1.pendingBackgroundResume = true;
state$1.backgroundEnteredAt = now;
tryRun(() => markBackground(now), undefined);
const deltaStay = state$1.lastRouteEnterTime > 0 ? now - state$1.lastRouteEnterTime : 0;
const stayed = clampUrlrefStaySec(deltaStay);
if (state$1.lastRoute && opts.enablePageLog !== false) {
const exitedUrl = state$1.lastRouteFull || state$1.lastRoute;
const ref = state$1.beforeLastRouteFull || state$1.beforeLastRoute || '';
const snap = state$1.lastPageTitleSnap;
const payload = {
lt: LT.Page,
t: now,
url: exitedUrl,
urlref_ts: stayed,
iey: state$1.lastIey,
ppiey: state$1.prevIey,
ttn: snap.ttn,
ttpj: snap.ttpj,
ttc: snap.ttc,
};
if (ref)
payload.urlref = ref;
c.report(payload);
if (state$1.lastIey) {
tryRun(() => markEntryDeparted(), undefined);
state$1.lastIey = false;
}
}
c.report({
lt: LT.Hide,
t: now,
urlref: state$1.lastRoute,
urlref_ts: stayed,
});
void c
.flush(true)
.catch((e) => logger.warn('[uni统计 2.0] flush on hide failed', e));
}
/**
* Page.onShow页面前台展示。
*
* `lt=11`(页面日志)在**进入新页**的 `onShow` 触发,但语义描述的是**刚刚离开的页面**
*(只有离开后才能闭合停留时长、导航栏标题等):
*
* - `url`:离开页的完整路径(含 query来自上一次 onShow 结束时登记的 `lastRouteFull`。
* - `urlref`:再上一层的来源页(「上上个页面」),来自 `beforeLastRouteFull`
* 首次从启动页外跳(只有一层来源)时不带 `urlref`。
* - `urlref_ts`:离开页停留秒数(`now - lastRouteEnterTime`,不足 1 秒按 1 秒,对齐私有版)。
* - `iey` / `ppiey`:分别对应**离开页**是否仍为有效入口、`urlref` 指向页是否仍为有效入口
* (会话内仅**首次离开**登记入口为 1循环回到入口后再离开不算
* - `ttn` / `ttpj` / `ttc`三维独立内存API 导航栏 / pages.json / uni.report('title')
* **同一事件可同时非空**。离开页快照优先在 **`onHide` 且 `clearPageTitle` 之前**落盘;
* 无 hide 场景依赖 **microtask**(晚于业务 `onShow`)— 由 `titleSnapGeneration` 防止被下一页 show 尾部误覆盖。
*
* 首次应用内 onShow无前序页面不发 `lt=11`。`enablePageLog=false` 时跳过整段 `lt=11`。
*/
function handlePageShow(app, vm, opts = {}) {
const c = safeCollector(app);
if (!c)
return;
if (state$1.pendingBackgroundResume &&
shouldEarlyConsumeBackgroundResumeInMixin()) {
tryConsumeBackgroundResume(app, {}, opts, 'handlePageShow');
}
const now = nowSec();
const route = tryRun(() => getCurrentRoute(vm), '');
const url = tryRun(() => getCurrentRouteWithQuery(vm), '') || route;
/**
* H5/部分端存在"App.onShow 也会打到 mixin onShow"的情况,此时 this 并非页面 vm
* route/fullPath 为空。该事件应由 `handleAppShow` 处理,不能当 page_show。
*/
if (!route && !url)
return;
const result = tryRun(() => ensureSession('page_show', { now }), null);
if (!result)
return;
// 每页重置「自定义上报标题」维ttc注入 pages.json 导航标题 → `ttpj`。
//
// **禁止**在此处调用 `clearPageTitle()`uni-app 页面 `onLoad` 早于统计 mixin 的 `onShow`
// 业务常在 `onLoad` 里 `uni.setNavigationBarTitle`,拦截器已写入 `ttn`;若此处再清 page
// 会把刚设好的 ttn 抹掉。**跨页**时由 `handlePageHide` 在快照后 `clearPageTitle` 即可。
tryRun(() => setReportTitle(''), undefined);
tryRun(() => setConfigTitle(getPagesJsonNavigationTitle(route)), undefined);
if (result.isNew) {
// 新会话:清 entry → 先登记当前页为会话入口(与 lt=1「落地即入口」一致→ 再发 lt=1。
tryRun(() => clearEntry(), undefined);
}
if (route) {
tryRun(() => markEntryPage(route), undefined);
}
if (result.isNew) {
// cst=3复用 committed visit 字段,与私有版 sendReportRequest 对齐。
// 注意lt=1新会话首报**不受** enablePageLog 控制 —— 与私有版语义一致,
// is_page_report 仅拦截 pageShow/pageHide不影响 launch/appShow/appHide。
reportNewSession(c, result.cst || CST.PageInactiveTimeout, '', now, false, url);
}
// 存在上一页 → 发 lt=11描述「离开的上一页」而非当前 vm 所在页。
const shouldSuppressPageLog = state$1.suppressNextPageLogAfterResume;
if (state$1.lastRoute &&
opts.enablePageLog !== false &&
!shouldSuppressPageLog) {
const deltaStay = state$1.lastRouteEnterTime > 0 ? now - state$1.lastRouteEnterTime : 0;
const stayed = clampUrlrefStaySec(deltaStay);
const exitedUrl = state$1.lastRouteFull || state$1.lastRoute;
const ref = state$1.beforeLastRouteFull || state$1.beforeLastRoute || '';
const snap = state$1.lastPageTitleSnap;
const payload = {
lt: LT.Page,
t: now,
url: exitedUrl,
urlref_ts: stayed,
// 离开页是否入口页 / urlref 指向页是否入口页(进入新页前状态尚未被本轮覆盖)。
iey: state$1.lastIey,
ppiey: state$1.prevIey,
};
if (ref)
payload.urlref = ref;
// 三维并列上行,不因其一存在而省略其它;空串由 builder/omit 统一处理
payload.ttn = snap.ttn;
payload.ttpj = snap.ttpj;
payload.ttc = snap.ttc;
c.report(payload);
if (state$1.lastIey) {
tryRun(() => markEntryDeparted(), undefined);
}
}
// 轮换路由链:当前页在下一轮成为「上一页」。
state$1.beforeLastRoute = state$1.lastRoute;
state$1.beforeLastRouteFull = state$1.lastRouteFull;
state$1.prevIey = state$1.lastIey;
state$1.lastIey = !!route && tryRun(() => isEntryForIey(route), false);
state$1.lastRoute = route;
state$1.lastRouteFull = url;
state$1.lastRouteEnterTime = now;
state$1.suppressNextPageLogAfterResume = false;
// 不在此处同步快照:此时 lastRoute 已指向新页getCurrentTitle 会是新页 ttpj+空 ttn造成顶替。
// 离开页快照见 handlePageHide优先否则见 scheduleDeferredTitleSnapshot。
scheduleDeferredTitleSnapshot();
state$1.isHide = false;
// cst=3 新会话:本 tick 内可能已入队 lt=1 与若有上一页lt=11serializer 按 LT_ORDER
// 保证同批内 lt=1 先于 lt=11。此处强制 flush避免仍等 reportInterval 才被杀死丢锚点。
if (result.isNew) {
void c
.flush(true)
.catch((e) => logger.warn('[uni统计 2.0] flush after new session (page_show) failed', e));
}
}
/**
* Page.onHide / Page.onUnload页面隐藏 / 卸载。
*
* 私有版用 `isHide` 区分 onUnload 是隐藏还是真离开;本模块同样兼容。
*
* 公有版调整(与 `docs/uni统计上报参数.md` 对齐):
* - `lt=11` 不在 onHide 上报;页面离开闭环由「下一次 `handlePageShow` 或 `handleAppHide`」触发。
* - onHide 仅做收尾:标记 isHide、清掉自定义 title避免下次新页空标题。
* - lastRoute / lastRouteEnterTime / lastIey 保持不变,由 `handlePageShow` 统一切换。
*/
function handlePageHide(app, _vm) {
const c = safeCollector(app);
if (!c)
return;
state$1.isHide = true;
// 离开前快照此时仍保留「本页」ttpj/ttn/ttc清空 page 维后仅丢 ttn故必须先快照
titleSnapGeneration++;
state$1.lastPageTitleSnap = Object.assign({}, getCurrentTitle());
tryRun(() => clearPageTitle(), undefined);
}
/**
* 已经被本模块"异步重抛"过的错误实例,用于阻断 `onError → 重抛 → onError` 死循环。
*
* ## 选 WeakSet 的原因
* - 弱引用语义:业务方在外部 catch 这些 error 后error 仍可被 GC不内存泄漏。
* - uni-app 全端原生支持H5 / 微信/支付宝/百度/字节 等小程序 / App-iOS/Android /
* nvue / uvue / 鸿蒙)—— vue runtime 自身大量使用 WeakSet/WeakMap 做响应式,
* 任何不支持 WeakSet 的环境vue 本身就起不来。
*
* ## 为什么仍然加 typeof 守卫
* 作为 SDK 必须 defensive。万一极端环境自定义沙箱阉割、业务代码 `delete
* globalThis.WeakSet`、SSR mock 等)导致 `new WeakSet()` 抛错,会让整个统计模块
* 在初始化期 `ReferenceError` 加载失败 —— 过激的失败模式。
*
* 降级策略:退化为 has=false / add=noop 的 stub。后果是失去防重入保护但 SDK
* 仍可正常工作;最坏情况(小程序端 setTimeout 重抛被 mixin 二次接住)会触发
* 一次额外的 setTimeout仍是异步、不会同步阻塞第二次 setTimeout 抛出后会
* 到达全局 onError仍然不会无限循环 —— 影响完全可控。
*
* ## 仅处理 object 类型
* 非 object 错误(极少见的 `throw 'string'` / `throw 42` 等)无法进 WeakSet
* 且重抛非 object 在多数端的全局 onError 不会再次触发 vue mixin 的 onError
* 无重入风险,无需特殊处理。
*/
const rethrownErrors = typeof WeakSet === 'function'
? new WeakSet()
: // 极端环境降级has=false 永不命中add=noop本模块只用 has/add 两个方法,
// 其它方法delete / [Symbol.toStringTag])调用方不依赖,类型断言即可。
{
has: () => false,
add: () => rethrownErrors,
};
/**
* onError上报错误lt=31+ 异步重抛,让错误回归原生 "Uncaught Error" 通路。
*
* ## 设计目标:统计是**旁路监听**,绝不侵入业务方的报错体验
*
* ### 私有版(含早期公有版)的两种错误做法都不达标
*
* 1. **私有版 `src/index.js#onError`**:仅 `stat.error(e)`**完全吞掉错误**。
* 一旦 mixin 注册了 onErroruni-app/Vue 视为业务已处理 → Vue 不再 console.error
* → 业务方在 H5 端排错时控制台一片空白,看不到任何 stack。
*
* 2. **早期公有版 `console.error(e)` 兜底**:能看到 stack但 devtools 会把
* `console.error` 的**调用文件**(即 SDK 路径 `uni-stat-public.es.js:行号`
* 显示在控制台日志右侧的"来源"列。业务方误以为统计 SDK 出现在他们的错误栈里,
* 与"旁路监听"承诺相悖。
*
* ### 当前方案:`setTimeout(() => { throw e }, 0)` 异步重抛(**仅非小程序**
*
* - **H5 / App 等**:错误进入浏览器 / 原生 "Uncaught Exception" 通路(同 `window.onerror`
* 与**完全没接入统计**时的默认行为一致,且控制台「来源」指向用户任务而非 SDK。
*
* - **各小程序(`mp-*`****不重抛**。运行时已在首次异常路径打印 `MiniProgramError` 等;
* 若再 `setTimeout(throw)`,会二次进入全局 `onError`,且微信往往传入**新的包装对象**
* `WeakSet` 无法按引用去重 → 多条 `lt=31`、控制台刷屏。统计在此只做旁路上报。
*
* ### 防重入(主要针对仍走重抛的环境)
*
* 重抛后可能被二次回调;用 `rethrownErrors`(WeakSet) 标记已处理的 error 实例。
*
* ### 顺序
*
* 1. **先标记重入防护** —— 防止极端竞态下 setTimeout 在同步上报完成前已 fire。
* 2. **再上报** —— 同步执行,确保 lt=31 一定入队。
* 3. **非小程序****最后**异步重抛 —— `setTimeout 0` 排到下一 task小程序端跳过此步。
*
* 外层 `try/catch` 仅兜底 `reportError` 自身抛错(与私有版一致);`tryRun` 兜底
* `setTimeout` 在极端环境(如 SSR / 被 mock 的 timer下不可用的情况。
*/
function handleError(app, e) {
const isObj = typeof e === 'object' && e !== null;
if (isObj && rethrownErrors.has(e))
return;
if (isObj)
rethrownErrors.add(e);
try {
app.reportError(e);
}
catch (err) {
logger.warn('[uni统计 2.0] handleError failed', err);
}
if (isMp()) {
return;
}
tryRun(() => {
setTimeout(() => {
throw e;
}, 0);
}, undefined);
}
function getUni$7() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 是否由 mixin 分发 App 级 onShow/onHide对齐私有版 `stat.show` / `stat.hide`)。
*
* - Vue2始终走 mixin`load_stat` 不注册 uni.onAppShow/Hide
* - Vue3仅 H5 / nvue 走 mixin小程序等走 `uni.onAppShow` / `onAppHide`。
*
* 使用赋值而非连续 `return`:公有版 dist 经 Rollup 打包时,连续 return 会导致
* `#ifdef VUE3` 分支被 tree-shake应用构建再剥离 `#ifndef VUE3` 后函数体为空 → undefined。
*/
function shouldMixinDispatchAppLifecycle() {
let result = isH5() || getPlatform() === 'n' || isNvue();
// #ifndef VUE3
result = true;
// #endif
// #ifdef VUE3
result = isH5() || getPlatform() === 'n' || isNvue();
// #endif
return result;
}
/**
* 是否注册 `uni.onAppShow` / `onAppHide`(对齐私有版 `index.js#load_stat` VUE3 分支)。
*
* 仅 Vue3 且非 H5、非 nvue即小程序等为 trueVue2 必须为 false。
*/
function shouldBindUniAppLifecycle() {
let result = !isH5() && getPlatform() !== 'n' && !isNvue();
// #ifndef VUE3
result = false;
// #endif
// #ifdef VUE3
result = !isH5() && getPlatform() !== 'n' && !isNvue();
// #endif
return result;
}
const uniAppHookRegistry = {
showBound: false,
hideBound: false,
appShowCb: undefined,
appHideCb: undefined,
};
/**
* 订阅应用级 `uni.onAppShow` / `onAppHide``uni` 或 API 未就绪时返回 false可稍后重试。
*
* show/hide 分别绑定:避免 `onAppShow` 晚就绪时连 `onAppHide` 也无法注册lt=3 缺失)。
*/
function tryBindUniAppLifecycle(app, opts = {}) {
if (!shouldBindUniAppLifecycle())
return false;
const u = getUni$7();
if (!u)
return false;
if (!uniAppHookRegistry.showBound && typeof u.onAppShow === 'function') {
uniAppHookRegistry.appShowCb = (e) => handleAppShow(app, e !== null && e !== void 0 ? e : {}, opts);
tryRun(() => u.onAppShow(uniAppHookRegistry.appShowCb), undefined);
uniAppHookRegistry.showBound = true;
}
if (!uniAppHookRegistry.hideBound && typeof u.onAppHide === 'function') {
uniAppHookRegistry.appHideCb = () => handleAppHide(app, opts);
tryRun(() => u.onAppHide(uniAppHookRegistry.appHideCb), undefined);
uniAppHookRegistry.hideBound = true;
}
return uniAppHookRegistry.showBound && uniAppHookRegistry.hideBound;
}
/** 解绑 `tryBindUniAppLifecycle` 注册的回调。 */
function unbindUniAppLifecycle() {
if (!uniAppHookRegistry.showBound && !uniAppHookRegistry.hideBound)
return;
const cur = getUni$7();
if (uniAppHookRegistry.showBound &&
uniAppHookRegistry.appShowCb &&
(cur === null || cur === void 0 ? void 0 : cur.offAppShow)) {
tryRun(() => cur.offAppShow(uniAppHookRegistry.appShowCb), undefined);
}
if (uniAppHookRegistry.hideBound &&
uniAppHookRegistry.appHideCb &&
(cur === null || cur === void 0 ? void 0 : cur.offAppHide)) {
tryRun(() => cur.offAppHide(uniAppHookRegistry.appHideCb), undefined);
}
uniAppHookRegistry.showBound = false;
uniAppHookRegistry.hideBound = false;
uniAppHookRegistry.appShowCb = undefined;
uniAppHookRegistry.appHideCb = undefined;
}
/**
* 装配 vue mixinVue3 小程序等另由 `tryBindUniAppLifecycle` 订阅 uni 应用前后台。
*
* 与私有版 `src/index.js` + `core/stat.js#show|hide` 对齐:
* - Vue2仅 `Vue.mixin`App/Page 均在 mixin 的 onShow/onHide 内分支。
* - Vue3H5/nvue 的 App 前后台在 mixin其它端用 uni.onAppShow/onAppHide。
*/
function bindLifecycle(app, opts = {}) {
let bound = true;
const mixin = {
onLaunch(options = {}) {
handleLaunch(app, options, opts);
},
onLoad() {
// 保留钩子位用于未来扩展query 收集等);当前 noop。
},
onShow() {
const vmType = getPageVmType(this);
cancelPageAppHideDefer();
if (state$1.pendingBackgroundResume &&
shouldEarlyConsumeBackgroundResumeInMixin()) {
tryConsumeBackgroundResume(app, {}, opts, 'mixin.onShow');
}
state$1.isHide = false;
if (vmType === 'page') {
handlePageShow(app, this, opts);
}
if (shouldMixinDispatchAppLifecycle() && vmType === 'app') {
handleAppShow(app, {}, opts);
}
},
onHide() {
state$1.isHide = true;
if (getPageVmType(this) === 'page') {
handlePageHide(app);
// #ifndef VUE3
tryVue2AppHideFromPageOnHide(app, opts);
// #endif
// #ifdef VUE3
tryVue3AppHideFromPageOnHide(app, opts);
// #endif
}
if (shouldMixinDispatchAppLifecycle() &&
getPageVmType(this) === 'app' &&
!state$1.pendingBackgroundResume) {
handleAppHide(app, opts);
}
},
onUnload() {
if (state$1.isHide) {
state$1.isHide = false;
return;
}
handlePageHide(app);
},
onError(e) {
handleError(app, e);
},
};
if (shouldBindUniAppLifecycle()) {
tryBindUniAppLifecycle(app, opts);
}
return {
mixin,
tryBindUniAppHooks: () => shouldBindUniAppLifecycle() && tryBindUniAppLifecycle(app, opts),
unbind() {
if (!bound)
return;
bound = false;
unbindUniAppLifecycle();
},
};
}
/**
* 公有版常量与可配置项的集中定义版本号、URL、超时阈值等
*
* 该模块只导出**编译期常量**与**默认值**;运行时可变配置走 `runtime/StatApp` 注入。
*/
/**
* 上行字段 `usv` 取值:**uni-app 编译器版本号**(与权威参数文档
* `docs/uni统计上报参数.md` 中 `usv: "4.24"` 示例对齐)。
*
* 与私有版 `src/config.ts` 保持同源做法:直接读 `process.env.UNI_COMPILER_VERSION`
* 由 uni-cli 在用户应用打包阶段通过 vite `define` 替换成字面量字符串;
* 运行时取不到时回退为空串,避免拼到 URL 时变成 `undefined`。
*
* 注意:这里**不再**硬编码统计实现版本。统计入口由 `src/plugin/index.ts`
* 的统计类型(`public` / `private`)控制;
* 与 `usv` 字段无关。
*/
const STAT_VERSION_PUBLIC = process.env.UNI_COMPILER_VERSION || '';
/** 1.0 通道HTTP默认上报地址。 */
const STAT_URL = 'https://tongji.dcloud.io/uni/stat';
/** H5 image 兜底通道(绕过跨域)。 */
const STAT_H5_URL = 'https://tongji.dcloud.io/uni/stat.gif';
/** 默认上报间隔。queue 节流阈值。 */
const REPORT_INTERVAL_SEC = 10;
/** HTTP 协议层最大重试次数(含首次)。 */
const HTTP_MAX_RETRIES = 3;
/** Cloud 协议层最大重试次数(含首次)。 */
const CLOUD_MAX_RETRIES = 2;
/** Image 协议层最大重试次数(含首次)。 */
const IMAGE_MAX_RETRIES = 2;
/** 重试基础延迟(指数退避)。 */
const RETRY_BASE_DELAY_MS = 1000;
/**
* 微信小程序是否用 `wx.preloadAssets` + `WebTrack.gif` GET 上报。
*
* - `true`(默认):`mp-weixin` 走 preload 信标;无 API 时回退 `uni.request` GET。
* - `false`:微信与其它宿主一样走 `uni.request` GETquery 与 H5 一致)。
*
* 可在 `createImageChannel({ mpWeixinPreloadReport: false })` 覆盖。
*/
const MP_WEIXIN_USE_PRELOAD_ASSETS_REPORT = true;
/**
* 微信 `wx.preloadAssets` 单次等待上限ms
*
* 冷启动首包常慢于 10sDNS/TLS/首连),而 image 通道默认 `timeoutMs=10000` 会先于
* `success` 触发 SDK 超时Network 里请求可能已是 200。默认放宽到 30s`uni.request` GET 仍用 10s。
*/
const MP_WEIXIN_PRELOAD_TIMEOUT_MS = 30000;
/**
* 微信小程序 preload 冷启动首包 flush 延迟ms
*
* `onLaunch` 入队 lt=1 后,`queue.shouldFlush()` 会因 `lastFlushAt=0` 立即为 true
* 若在 App 尚未完成启动时调用 `wx.preloadAssets`,易出现 30s 无 success。延迟后再 flush
* 用于验证「启动时机」是否为根因(方案 C。设为 `0` 则关闭延迟。
*/
const MP_WEIXIN_PRELOAD_FIRST_FLUSH_DELAY_MS = 2000;
/**
* App 渠道首包 flush 延迟ms
*
* App 自定义渠道来自原生 `plus.runtime.channel`。极少数设备/基座上,统计 install
* 或首个 lt=1 入队时该值可能还未就绪;给首个自动 flush 一个很短的窗口,发送前
* `collector.resolveUploadFields` 会再次读取并补齐 `ch`。设为 `0` 可关闭。
*/
const APP_CHANNEL_FIRST_FLUSH_DELAY_MS = 300;
/**
* 单条事件序列化后允许的最大字节数。
*
* 阈值取舍:
* - GET 上报(`/WebTrack` / `/WebTrack.gif`URL 上限约 6KB见 `docs/image-url-too-long-修复说明.md`
* 扣掉 host / ProjectId / TopicId / Source / Time 等固定 query 后,留给
* `Logs=encodeURIComponent(payload.requests)` 约 5.8KB 量级。
* - `encodeURIComponent` 对纯 ASCII 膨胀 ~1.05x,对中英混排 ~1.52x对纯中文最坏 3x。
* - 取 **4KB 作为单条事件上限**:保证 ASCII 场景(含大段 Error stack能放进单批
* 纯中文极端场景下,由 `chunkEvents` 单条独占一片 + image preflight 在 URL 编码后
* 再做一次 6144 字节硬截断兜底。
* - 业务错误 stack 通常 ~13KB4KB 足够;超 4KB 的单条多半是 base64 图片 / 大段 JSON
* 这类**应该被业务自身收敛**的场景,直接丢弃并 warn 比静默卡死管道更安全。
*
* 超过本阈值的单条事件直接在 `queue.enqueue()` 内丢弃并 warn —— 不入桶、不持久化、
* 不进入重试队列,避免 81KB 这种"任何 batch 切多细都过不了"的死信卡死管道。
*
* 参考排错文档:`docs/image-url-too-long-修复说明.md`。
*/
const SINGLE_EVENT_MAX_BYTES = 4 * 1024;
/**
* 单批 `requests`(已 `JSON.stringify(events)`)允许的最大字节数。
*
* 与 GET URL 长度上限相关:`encodeURIComponent` 保守按 3x 估,
* 本常量与 `createImageChannel.maxRequestBytes()` 取 min 后由 collector 切片。
*/
const BATCH_REQUESTS_MAX_BYTES = 4 * 1024;
/** 单批最多容纳的事件数;与字节阈值取 min 作为切片边界。 */
const BATCH_MAX_EVENTS = 30;
/**
* 内存上报桶(主队列)允许容纳的事件总数上限。
*
* 设置原因:通道长期不可用时,失败批次会反复 `rollback` 回桶,且每次 `enqueue`/`rollback`
* 都会 `persistBucket` 落盘。若无上限,内存与 storage 会随离线时长无界增长,最终可能触发
* 小程序 storage 配额异常甚至 OOM。超过本上限时`enqueue` 按 FIFO 丢弃**最旧**事件
* (优先从当前最大的桶丢,尽量保住体量小但关键的 lt=1/lt=3并 warn。
*
* 取值 1000以单条均值约 0.51KB 估算,约占 0.51MB远低于各端 storage 配额;
* 正常在线10s flush场景永远触不到仅在长时间离线积压时生效。
*/
const QUEUE_MAX_EVENTS = 1000;
/**
* 单条 retry 队列条目允许的最大重放次数。
*
* 设置原因:`recoverRetry` 每次冷启串行重放历史 payload对永久错误例如曾经误塞入
* 队列的超长 payload、协议早期版本的脏数据只会反复 fail永远卡在队列前部把后续
* 健康 payload 也拖死。超过本阈值后由 `markAttempt` 自动 ack 删除(死信清理)。
*/
const RETRY_MAX_ATTEMPTS = 5;
const IMAGE_REPORT_DEFAULTS = {
host: 'https://tongji-collector.dcloud.net.cn',
/** 正式环境 */
projectId: '964f0397-af5d-45bf-99d6-8fb3500d7849',
topicId: '8563e231-f4cd-4ab0-8870-917e4b04e810',
// 以下为历史测试环境(已停用,勿删便于回切排查)
// projectId: '9fad19a2-b7f1-47f5-87ff-8621f545ab61',
// topicId: '99b55c91-ed80-406e-b205-e9d18aca744d',
};
/**
* uni-app appid。优先取构建期 `process.env.UNI_APP_ID`;未注入时返回 `''`
* 由调用方决定是否上报为 `'default'`。
*/
function getAppId$1() {
var _a;
return (_a = process.env.UNI_APP_ID) !== null && _a !== void 0 ? _a : '';
}
/**
* 2.0 通道uniCloud importObject 上报。
*
* 与私有版协议 1:1
* `uni.__stat_uniCloud_space.importObject('uni-stat-receiver', { customUI: true }).report(payload)`
*
* 与私有版差异(修复点):
* - 私有版 `sendRequest` 仅在 1.0 通道有 `_retry` 重试2.0 通道**完全没有重试**
* 云函数偶发抖动会直接丢数据。本实现统一接入 `withRetry`(指数退避)。
* - 私有版直接读全局 `uni.__stat_uniCloud_space`,无法测试。本实现支持依赖注入
* `uniCloudSpace`(测试) / `getUniCloudSpace()`(运行时)。
* - `available()` 在 `space.importObject` 不可用时返回 false调用方据此决定是否
* 回退到 1.0 通道(由 selector 决策,本通道自身不做回退)。
*/
/**
* 解析当前可用的 uniCloud space。
*
* 优先级opts.uniCloudSpace > uni.__stat_uniCloud_space`uni` 解析见 `infra/uniRuntime`)。
* 都不可用返回 undefined由 `available()` / `send()` 自行处理。
*/
/**
* 校验云对象返回值是否表示业务失败。**只识别 uniCloud 标准失败约定,默认成功**
* 以避免把成功返回误判为失败而触发无谓重试:
* - `success === false`(显式布尔失败)
* - `errCode` 为非 0 的 numberuniCloud 云对象错误码约定0 / 缺省 = 成功)
*
* **刻意不判断通用 `code` 字段**:部分接口用 `code: 200` 表示成功,若按「非 0 即失败」
* 处理会把成功误判为失败、误入重试队列。未知返回形态一律视为成功(保守)。
*
* 命中失败约定时抛错,交由 `withRetry` / collector 走重试链路。
*/
function assertCloudResultOk(res) {
if (!res || typeof res !== 'object')
return;
const r = res;
if (r.success === false) {
throw new Error('cloud receiver reported success=false');
}
if (typeof r.errCode === 'number' && r.errCode !== 0) {
throw new Error('cloud receiver reported errCode=' + String(r.errCode));
}
}
function resolveSpace(injected) {
if (injected)
return injected;
const raw = resolveUniRuntime();
const u = raw != null && typeof raw === 'object'
? raw
: undefined;
return u === null || u === void 0 ? void 0 : u.__stat_uniCloud_space;
}
function createCloudChannel(opts = {}) {
var _a, _b;
const receiverName = (_a = opts.receiverName) !== null && _a !== void 0 ? _a : 'uni-stat-receiver';
const maxRetries = (_b = opts.maxRetries) !== null && _b !== void 0 ? _b : CLOUD_MAX_RETRIES;
function getReceiver() {
const space = resolveSpace(opts.uniCloudSpace);
if (!space || typeof space.importObject !== 'function')
return undefined;
try {
return space.importObject(receiverName, { customUI: true });
}
catch (e) {
logger.warn('[uni统计 2.0] cloud importObject threw', e);
return undefined;
}
}
function once(payload) {
const receiver = getReceiver();
if (!receiver || typeof receiver.report !== 'function') {
return Promise.reject(new Error('uniCloud space unavailable'));
}
return Promise.resolve(receiver.report(payload)).then((res) => {
// 云对象未 throw 但**业务结果显式失败**时,仍按失败处理以触发重试,
// 避免"resolve 即成功"漏掉服务端拒收。仅识别明确的失败约定,默认视为成功,
// 防止把未知返回形态误判为失败(保守)。
assertCloudResultOk(res);
});
}
return {
name: '2.0',
available() {
const space = resolveSpace(opts.uniCloudSpace);
return !!(space && typeof space.importObject === 'function');
},
send(payload) {
return __awaiter(this, void 0, void 0, function* () {
try {
yield withRetry(() => once(payload), {
times: maxRetries,
baseDelayMs: RETRY_BASE_DELAY_MS,
sleep: opts.sleep,
});
}
catch (e) {
logger.warn('[uni统计 2.0] 统计上报失败(云函数已重试)', e);
throw e;
}
});
},
};
}
/**
* 公有版调试日志:面向业务方的"采集 / 上报"过程日志封装。
*
* 与 `logger.debug` 的差异:
* - `logger.debug` 是底层 console.log + 闸门;调用点散落,文案随意。
* - 本模块提供**统一文案 / 统一格式**的高层包装,覆盖:
* 1. 采集动作:每个 lt 都有中文动作名 +「采集 → 数据」标记。
* 2. 上报生命周期:开始 / 成功 / 失败 / 冷启续传。
* 3. 启动摘要通道版本、上报间隔、ak 是否就位等。
* - 所有 helper 都内嵌 `logger.isDebug()` 判断;非 debug 模式下零开销,
* 调用方无需再写 `if (logger.isDebug()) ...`。
*
* 文案风格参考私有版 `utils/pageInfo.js#log`:直接面向业务调试,**中文**为主,
* 关键字段lt / 通道 / 用时 / 错误原因)一目了然。
*
* 注意:不在此处吞错;任意 console.log 异常仍会冒泡。运行时调用方需要 `tryRun` 兜底
* 时自行处理(一般 console.log 不会抛错,故未做包装)。
*/
/**
* `lt` → 用户友好的中文动作名映射。
*
* 与私有版 `pageInfo.js#log` 的 msg_type 对齐。
* 注:`lt=0` 已废弃(详见 `domain/eventTypes.ts` 头注释),新会话信息直接随 lt=1 上行。
*
* 未知 lt 走默认 "未知事件 (lt=X)",便于排查异常上行。
*/
function getActionLabel(lt) {
switch (lt) {
case LT.Launch:
return '应用启动';
case LT.Hide:
return '应用进入后台';
case LT.Page:
return '页面切换';
case LT.Event:
return '事件触发';
case LT.Error:
return '应用错误';
case LT.Push:
return 'PUSH 设备标识';
default:
return `未知事件 (lt=${String(lt !== null && lt !== void 0 ? lt : '?')})`;
}
}
/**
* 计算 bucket`Record<lt, StatData[]>`)内的事件总数。
*
* 仅在 debug 路径需要,单独抽出避免与 queue.size() 模块循环依赖。
*/
function bucketSize(bucket) {
let n = 0;
for (const lt of Object.keys(bucket)) {
const arr = bucket[lt];
if (Array.isArray(arr))
n += arr.length;
}
return n;
}
/**
* 把 bucket 摘要成 "lt=1×1, lt=11×3, lt=21×2" 形式,方便控制台扫读。
*/
function bucketSummary(bucket) {
const parts = [];
for (const lt of Object.keys(bucket)) {
const arr = bucket[lt];
if (Array.isArray(arr) && arr.length > 0) {
parts.push(`lt=${lt}×${arr.length}`);
}
}
return parts.join(', ') || '<空>';
}
/**
* 单次事件采集日志。
*
* 文案示意:
* ```text
* [uni统计 2.0] === 统计数据采集:应用启动 (lt=1) ===
* [uni统计 2.0] {lt: '1', t: 1714123456, ut: 'h5', ...}
* [uni统计 2.0] === 采集结束 ===
* ```
*/
function logCollect(data) {
if (!logger.isDebug())
return;
const lt = data.lt;
const label = getActionLabel(lt);
logger.debug(`=== 统计数据采集:${label} (lt=${String(lt !== null && lt !== void 0 ? lt : '?')}) ===`);
logger.debug(data);
logger.debug('=== 采集结束 ===');
}
/**
* 启动 / 配置摘要。`installPublicStat` 装配完毕后调用一次,方便业务方一眼确认接入状态。
*/
function logBoot(info) {
if (!logger.isDebug())
return;
const timeoutParts = [];
if (info.backgroundTimeoutSec != null) {
timeoutParts.push(`后台超时(新会话): ${info.backgroundTimeoutSec}s`);
}
if (info.pageInactiveTimeoutSec != null) {
timeoutParts.push(`前台无操作超时: ${info.pageInactiveTimeoutSec}s`);
}
const timeoutSeg = timeoutParts.length > 0 ? ` | ${timeoutParts.join(' | ')}` : '';
const lines = [
'=== uni统计 2.0 已启用 ===',
`上报间隔: ${info.reportIntervalSec}s${timeoutSeg} | 应用APPID: ${info.ak || '<未注入>'}${info.appName ? ` | 应用名: ${info.appName}` : ''}${info.vueMode ? ` | ${info.vueMode}` : ''}`,
];
if (info.debugFromManifest) {
lines.push('调试模式:已从 manifest.uniStatistics.debug 自动开启');
}
lines.push('=== 后续将在每次采集 / 上报时输出过程日志 ===');
logger.debug(lines.join('\n'));
}
/**
* 即将上报:取出 batch、选定 channel 后调用。
*
* 文案示意:
* ```text
* // 通道=${info.channel}
* [uni统计 2.0] === 准备上报: 共 4 条事件 (lt=1×1, lt=11×2, lt=21×1) [_id=p-xxxx] ===
* ```
*/
function logReportStart(info) {
if (!logger.isDebug())
return;
const total = bucketSize(info.bucket);
const summary = bucketSummary(info.bucket);
logger.debug(`=== 准备上报:共 ${total} 条事件 (${summary}) ===`);
}
/**
* 仅输出失败的"原因 / 重试落盘"细节,不输出 `=== 上报失败 ===` headline。
*
* 用于 collector 在切片化发送时**每次失败 send 后立即给出可观察性**:业务方能看到
* 是哪一批因什么失败、是否进入了重试队列;而最终的"上报失败 / 上报完成(部分失败)"
* 总览由 `logReportSummary` 统一输出,避免一次失败被打两次 headline。
*/
function logReportFailureReason(info) {
if (!logger.isDebug())
return;
logger.debug(`原因: ${describeError(info.error)}`);
if (info.persistedId) {
logger.debug(`已暂存重试队列 [retryId=${info.persistedId}],下次启动自动续传`);
}
else {
logger.debug('未能写入重试队列:本批数据已丢弃');
}
}
/**
* 单批次上报的最终汇总。
*
* 设计原则:**对外只暴露"成功 / 失败"两种结果,不暴露"切片"等内部实现细节**。
*
* 切片是 collector 为了适配 image 通道 URL 长度上限 / 全局 batch 字节阈值而做的
* 内部分批发送策略;业务方关心的只是"这一批数据有没有送达、送达多少、丢失多少"。
* 因此本汇总以**事件数**(而非片数)为统计维度,文案与单批 `logReportSuccess` /
* `logReportFailure` 完全对齐——业务方感知不到内部走了几次 send。
*
* 三种状态文案:
* - 全成功:`=== 上报成功: N 条事件已送达, 用时 Tms ===`(与 logReportSuccess 同)
* - 全失败:`=== 上报失败: N 条事件未送达, 用时 Tms ===`(与 logReportFailure 同)
* - 部分失败:`=== 上报完成:成功 X 条,失败 Y 条,用时 Tms ===`
*
* 失败原因 / 重试落盘 id 等细节由 collector 在每次失败 send 后通过 logReportFailure
* 输出,本汇总不再重复,避免噪音。
*/
function logReportSummary(info) {
if (!logger.isDebug())
return;
if (info.failedCount === 0) {
logger.debug(`=== 上报成功: ${info.okCount} 条事件已送达, 用时 ${info.elapsedMs}ms ===`);
}
else if (info.okCount === 0) {
logger.debug(`=== 上报失败: ${info.failedCount} 条事件未送达, 用时 ${info.elapsedMs}ms ===`);
}
else {
logger.debug(`=== 上报完成:成功 ${info.okCount} 条,失败 ${info.failedCount} 条,用时 ${info.elapsedMs}ms ===`);
}
}
/**
* 无可用通道:通常是 channelVersion=2 但 uniCloud space 未关联,或 image 配置缺失。
*/
function logNoChannel(info) {
if (!logger.isDebug())
return;
logger.debug(`=== 上报跳过:当前无可用通道,已回滚 ${bucketSize(info.bucket)} 条事件入队 ===`);
}
/**
* 冷启续传:进入 recoverRetry 时调用。
*/
function logRecoverStart(count) {
if (!logger.isDebug())
return;
logger.debug(`=== 冷启续传:发现 ${count} 条历史 payload开始逐条重发 ===`);
}
/**
* 冷启续传 - 单条结果。
*/
function logRecoverItem(info) {
if (!logger.isDebug())
return;
// const idTag = info.payloadId ? ` [_id=${info.payloadId}]` : ''
if (info.ok) {
logger.debug(`续传成功 (${info.index}/${info.total})`);
}
else {
logger.debug(`续传失败 (${info.index}/${info.total})${describeError(info.error)}`);
}
}
/**
* 把 unknown 错误压成可读字符串;保留 message + name避免业务方在控制台只看到 `[object Object]`。
*/
function describeError(e) {
if (!e)
return '<无错误对象>';
if (e instanceof Error) {
return `${e.name}: ${e.message}`;
}
if (typeof e === 'string')
return e;
return safeStringify(e) || String(e);
}
/**
* 上行体瘦身:去掉值为空字符串 `''` 的字段。
*
* - **调试日志**`collector.report` 在瘦身前把完整 `StatData` 交给 `logCollect`,空串字段仍会打印,
* 便于对照「是真的没采集到」还是「协议口径为空」。
* - **入队 / 发送**:经本函数后再 `queue.enqueue`,缩短 image GET URLencode 后的 Logs
* 仅减少体积,不改变非空字段语义。
*
* 注意:
* - 只处理**顶层**键;`StatData` 事件对象为单层 KV。
* - 仅剔除 `v === ''`,保留 `0`、`false`、`null`(若上游传入);当前 builder 不会主动写入 null。
*/
/**
* 返回浅拷贝:值为 `''` 的键不拷贝到结果对象。
*/
function omitEmptyStringFieldsForUpload(data) {
const out = {};
for (const key of Object.keys(data)) {
const v = data[key];
if (v === '')
continue;
out[key] = v;
}
return out;
}
/**
* 上报体序列化(重写私有版 `utils/pageInfo.js#handle_data`)。
*
* 修复缺陷 #4私有版用 `for...in` 拿到的 key 永远是字符串,写成 `i === 0` 与 `i === 3`
* 导致两条边界分支从未命中:`lt=3`(应用进入后台)应排最后用于服务端 session 闭合——被混入中间。
*
* 公有版严格契约:
* 1. 输出顺序固定:`1 → 11 → 21 → 31 → 101 → 3`(可在 `LT_ORDER` 中扩展)。
* `lt=0` 已废弃(参考 `domain/eventTypes.ts` 头注释),不再参与排序。
* 2. 同一 lt 内事件按 push 顺序保留(稳定排序)。
* 3. 纯函数:不读 storage、不调 console、不依赖 `'3'`。
* 4. 输入桶为空 → 返回 `'[]'`,调用方应在外层判空。
*
* 数据形状(公有版只支持 v2 协议,元素为 JSON 对象;不再走 v1 的 `key=val&...` 字符串):
* `JSON.stringify([{...stat1}, {...stat2}])`
*/
/**
* 上报顺序权重表。值越小越靠前;未知 lt 落到最末(靠近 lt=3 之前),同时打 warn。
*
* 顺序设计依据:
* - lt=1会话日志含 sid/cst/fvts/lvts/tvc最先
* - lt=11/21/31/101按事件类型轻重排开
* - lt=3应用进入后台永远最后用于服务端归一会话停留时长。
*/
const LT_ORDER = {
'1': 1,
'11': 2,
'21': 3,
'31': 4,
'101': 5,
'3': 100,
};
const UNKNOWN_LT_WEIGHT = 50;
/**
* 拉平 + 排序 + 序列化。
*
* @param buckets 按 lt 分组的事件桶。
* @returns 上行 `requests` 字段的 JSON 字符串(`'[{...}]'`)。
*/
function handleData(buckets) {
return JSON.stringify(flatten(buckets));
}
/**
* 仅做拉平 + 排序,便于 collector 在不需要 stringify 的场景下做断言或二次处理(如分片)。
*
* 排序规则:
* - 主键:`LT_ORDER[lt] ?? UNKNOWN_LT_WEIGHT`。
* - 次键:原始 push 顺序(依靠 Array.prototype.sort 在 Node 11+ 已稳定)。
*
* 修复缺陷 #4 关键断言:`lt='3'` 必落最后;`lt='1'` 必落最前。
*/
function flatten(buckets) {
const ltKeys = Object.keys(buckets);
ltKeys.sort((a, b) => weightOf(a) - weightOf(b));
const out = [];
for (let i = 0; i < ltKeys.length; i++) {
const lt = ltKeys[i];
const list = buckets[lt];
if (!list || list.length === 0)
continue;
for (let j = 0; j < list.length; j++)
out.push(list[j]);
}
return out;
}
function weightOf(lt) {
const w = LT_ORDER[lt];
return typeof w === 'number' ? w : UNKNOWN_LT_WEIGHT;
}
/**
* 把已 flatten 的事件数组按"事件数 + 字节数"双阈值贪婪切片。
*
* 用于 collector flush 阶段:把一次 flush 出来的整桶切成多个 ReportPayload
* 避免单批拼成 GET URL 后超过网关 / CDN / 浏览器 URL 上限(典型 8KB
*
* 行为:
* - **不丢任何事件**:单条事件即便已超 maxBytes 也独占一片(由 queue.enqueue 的
* `SINGLE_EVENT_MAX_BYTES` 兜底,正常路径走不到这里)。
* - **保持顺序**:贪婪累加,不打乱 flatten 排序结果,保证 lt=1 在最前 / lt=3 在最后的契约。
* - **空数组返回 []**:调用方据此跳过本次发送。
*/
function chunkEvents(events, opts = {}) {
var _a, _b;
const maxEvents = (_a = opts.maxEvents) !== null && _a !== void 0 ? _a : Infinity;
const maxBytes = (_b = opts.maxBytes) !== null && _b !== void 0 ? _b : Infinity;
const out = [];
if (!Array.isArray(events) || events.length === 0)
return out;
const safeMaxEvents = maxEvents > 0 ? maxEvents : Infinity;
const safeMaxBytes = maxBytes > 0 ? maxBytes : Infinity;
let cur = [];
let curBytes = 2; // 头尾 '[]'
for (let i = 0; i < events.length; i++) {
const e = events[i];
let s = '';
try {
s = JSON.stringify(e);
}
catch (_c) {
// 单条不可序列化交给 collector 丢弃serializer 不打 console由外层日志代理
continue;
}
// 加入后会占用:当前是空片 → s.length否则 +1逗号分隔
const inc = cur.length === 0 ? s.length : s.length + 1;
const wouldExceed = cur.length >= safeMaxEvents ||
(cur.length > 0 && curBytes + inc > safeMaxBytes);
if (wouldExceed) {
out.push(cur);
cur = [];
curBytes = 2;
}
cur.push(e);
curBytes += cur.length === 1 ? s.length : s.length + 1;
}
if (cur.length > 0)
out.push(cur);
return out;
}
/**
* 切片版 handleData返回多个 `requests` 字符串,对应多个 ReportPayload。
*
* `chunkEvents` 已保证排序与边界,本函数只做 stringify。
*/
function handleDataChunked(buckets, opts = {}) {
const events = flatten(buckets);
if (events.length === 0)
return [];
const chunks = chunkEvents(events, opts);
const out = [];
for (let i = 0; i < chunks.length; i++) {
out.push(JSON.stringify(chunks[i]));
}
return out;
}
/**
* Pipeline 层共享类型。
*
* 单独抽出避免 channel / queue / retry 之间循环 import。
*/
/**
* 永久性通道错误:本次 payload 自身有问题(与网络无关),重试同一份 payload 永远不会过。
*
* 典型场景:
* - H5 GET URL 超过 `maxUrlLength`,重发同一份必定再次超长;
* - TLS host / projectId / topicId 未配置,换网络也救不了;
* - 浏览器内既无 `Image` 全局也没有 `uni.request`:环境本身缺失,重试无意义。
*
* 设计意图:
* - **不进 channel 内部 `withRetry`**:永久错误一抛立刻冒泡到 collector避免协议层空转 N 次。
* - **不进 retry 队列**collector 的 `report()` 捕获到 permanent 时跳过 `retry.persist`
* 避免下次冷启 `recoverRetry` 反复读出 → 反复失败 → 反复落盘的死循环
* (这是 `image url too long` 卡死队列的根因)。
* - **死信清理**`recoverRetry` 重放历史 payload 时若再次拿到 permanent 错误,
* 直接 `retry.ack(_id)` 删除,不再写回。
*
* 错误识别:用 `instanceof PermanentChannelError`。为兼容跨 bundle / 跨上下文(少见但
* 防御性写法),同时设置 `permanent = true` 标志位,`isPermanentChannelError` 双重判定。
*/
class PermanentChannelError extends Error {
constructor(message) {
super(message);
/** 兼容跨 bundle 的标志位;与 `instanceof` 任一为真即视为永久错误。 */
this.permanent = true;
this.name = 'PermanentChannelError';
// 修复 ts/babel 转译后 prototype 链丢失,导致 instanceof 失效
Object.setPrototypeOf(this, PermanentChannelError.prototype);
}
}
/**
* 类型守卫:判定一个 unknown 错误是否为永久性通道错误。
*
* 兼容三种来源:
* 1. `instanceof PermanentChannelError`(同一 bundle
* 2. `err.name === 'PermanentChannelError'`(跨 bundle 但同名);
* 3. `err.permanent === true`(任意错误显式标记)。
*/
function isPermanentChannelError(err) {
if (!err || typeof err !== 'object')
return false;
if (err instanceof PermanentChannelError)
return true;
const e = err;
if (e.name === 'PermanentChannelError')
return true;
if (e.permanent === true)
return true;
return false;
}
/**
* Collectordomain 与 pipeline 的编排层。
*
* 职责(与 runtime/lifecycleHooks 配合):
* 1. `report(input)`把外部输入lt + 事件上下文)转成 statData 并入队;
* 自动填充 session 快照、seq仅本地状态使用不再上行
* 2. `flush(force?)`:从 queue 取快照 → serializer → 选 channel → 发送;
* 成功调用 `visit.commit(now)`;失败 `queue.rollback` + `retry.persist`。
* 3. `recoverRetry()`:冷启动时由 runtime 触发,把上次未送达的 payload 重试。
*
* 设计原则:
* - 依赖全部注入;本模块不直接 import 任何 adapter便于测试与多端切换。
* - 不持有业务字段;所有 statData 字段由 `domain/statData.builder` 拼装。
* - 错误吞掉 + 日志collector 层异常**不应**抛回到生命周期回调,避免污染业务页面。
*/
/**
* 默认 payload id 生成;与 retry.ts 的 genId 风格一致但前缀不同,便于日志区分。
*/
function defaultGenPayloadId(nowMs) {
return ('p-' + nowMs.toString(36) + '-' + Math.random().toString(36).slice(2, 6));
}
function createCollector(deps) {
/** 是否已完成进程内首次 flush含延迟触发的那一次。 */
let firstFlushDone = false;
/** 已安排的延迟 flush 定时器,避免重复 schedule。 */
let deferredFlushTimer = null;
/** 取消已安排的延迟首 flush`flush(true)` 等显式调用前使用)。 */
function cancelDeferredFlush() {
if (deferredFlushTimer == null)
return;
clearTimeout(deferredFlushTimer);
deferredFlushTimer = null;
}
/**
* `report` 达到阈值后的自动 flush 入口;仅此处做冷启动延迟(方案 C
*/
function triggerAutoFlush() {
var _a;
const deferMs = Math.max(0, Math.floor((_a = deps.firstFlushDeferMs) !== null && _a !== void 0 ? _a : 0));
if (!firstFlushDone && deferMs > 0) {
if (deferredFlushTimer != null)
return;
deferredFlushTimer = setTimeout(() => {
deferredFlushTimer = null;
firstFlushDone = true;
void flushImpl(false).catch((e) => logger.warn('[uni统计 2.0] auto-flush failed', e));
}, deferMs);
return;
}
firstFlushDone = true;
void flushImpl(false).catch((e) => logger.warn('[uni统计 2.0] auto-flush failed', e));
}
/**
* 构造 EventContext 并入队。
*
* 不再附加 pid上一会话 sid参数文档无该字段新会话信息由 lt=1 自身的
* `sid / cst / fvts / lvts / tvc` 表达。
*/
function report(input) {
tryRun(() => {
const t = typeof input.t === 'number' ? input.t : deps.nowSec();
const snap = deps.session.getSnapshot();
let sessionForCtx;
if (snap) {
const seq = deps.session.nextSeq();
sessionForCtx = Object.assign({}, snap, { seq });
}
// 用户主动行为事件lt=21自定义事件 / login / pay / share 拦截器)刷新
// 前台无操作计时器避免用户持续操作却无翻页时被误判「无操作超时」开新会话cst=3
// 仅 lt=21 视为「用户触达」lt=1/3/11/31/101 由会话状态机自身管理。
if (snap && input.lt === LT.Event && deps.session.touch) {
deps.session.touch(t);
}
const ctx = Object.assign({}, input, {
t,
session: sessionForCtx,
});
const data = deps.builder.build(ctx);
// 调试日志打印完整对象(含空串);入队发送侧去掉 '' 键以缩短 image URL
logCollect(data);
deps.queue.enqueue(omitEmptyStringFieldsForUpload(data));
if (deps.queue.shouldFlush()) {
triggerAutoFlush();
}
}, undefined);
}
/**
* flush 前补运行时字段。
*
* 典型场景App 端首个 Launch 事件入队时 `plus.runtime.channel` 暂不可读,
* 但真正发送前已经就绪;此时应以发送前的原生运行时值覆盖本批事件的 `ch`。
*/
function applyUploadFields(bucket) {
const fields = deps.resolveUploadFields ? deps.resolveUploadFields() : {};
const keys = Object.keys(fields).filter((key) => {
const v = fields[key];
return v !== '' && v !== undefined && v !== null;
});
if (keys.length === 0)
return;
for (const lt of Object.keys(bucket)) {
const list = bucket[lt];
if (!Array.isArray(list))
continue;
for (let i = 0; i < list.length; i++) {
const item = list[i];
for (let j = 0; j < keys.length; j++) {
const key = keys[j];
item[key] = fields[key];
}
}
}
}
function applyUploadFieldsToRequests(requests) {
const fields = deps.resolveUploadFields ? deps.resolveUploadFields() : {};
const keys = Object.keys(fields).filter((key) => {
const v = fields[key];
return v !== '' && v !== undefined && v !== null;
});
if (keys.length === 0)
return requests;
try {
const events = JSON.parse(requests);
if (!Array.isArray(events))
return requests;
for (let i = 0; i < events.length; i++) {
const item = events[i];
if (!item || typeof item !== 'object')
continue;
for (let j = 0; j < keys.length; j++) {
const key = keys[j];
item[key] = fields[key];
}
}
return JSON.stringify(events);
}
catch (_a) {
return requests;
}
}
function applyUploadFieldsToPayload(payload) {
const requests = applyUploadFieldsToRequests(payload.requests);
if (requests === payload.requests)
return payload;
return Object.assign({}, payload, { requests });
}
/**
* 真正发送:取快照、序列化、挑通道、按双阈值切片、串行发送,根据结果 commit/persist。
*
* 切片策略(修复 image url too long 死循环):
* - 用 `handleDataChunked(snapshot, { maxEvents, maxBytes })` 把整桶切成 N 份;
* - 单片失败:永久错(`PermanentChannelError`)→ 直接丢弃,不 persist、不影响其他片
* 非永久错 → 调用 `retry.persist`,下次冷启 `recoverRetry` 重放该片。
* - **任意片失败** → 不 commit visit**全部片成功** → commit 一次。
* 切片场景下 `lt=1` 必定落在第一片serializer 已按 `LT_ORDER` 排序),
* 若需要更精细的"首批成功就 commit",下一步迭代再做。
* - 通道不可用:依旧整桶 rollback 回 queue与切片前行为一致
*
* @param force 强制 flush忽略节流阈值
*/
function flushImpl() {
return __awaiter(this, arguments, void 0, function* (force = false) {
var _a, _b, _c, _d, _e;
if (!deps.queue.shouldFlush(force))
return;
// 无网:不摘队列,等网络恢复后再 flush公有版门闸
if (deps.isNetworkOffline) {
let offline = false;
try {
offline = yield deps.isNetworkOffline();
}
catch (_f) {
offline = false;
}
if (offline) {
logger.warn('[uni统计 2.0] 当前无网络,延后 flush');
return;
}
}
const snapshot = deps.queue.flush();
if (!snapshot)
return;
applyUploadFields(snapshot);
const channel = deps.selectChannel();
if (!channel) {
logger.warn('[uni统计 2.0] 无可用上报线路,本批已回滚队列');
logNoChannel({ bucket: snapshot });
deps.queue.rollback(snapshot);
return;
}
// 切片阈值 = min(全局配置, 通道物理上限)
// - 全局BATCH_REQUESTS_MAX_BYTES业务可调
// - 通道image GET URL 经 encodeURIComponent 膨胀,原文不能按 URL 上限直接用
// → 由 image 通道 maxRequestBytes() 反推(见 image.ts
// 这样 100 条事件在 image 通道下不会再切出"原文 4KB / encoded 7.5KB"超长片。
const globalMaxBytes = (_b = (_a = deps.batchLimits) === null || _a === void 0 ? void 0 : _a.maxBytes) !== null && _b !== void 0 ? _b : BATCH_REQUESTS_MAX_BYTES;
const channelMaxBytes = typeof channel.maxRequestBytes === 'function'
? channel.maxRequestBytes()
: Number.POSITIVE_INFINITY;
const limits = {
maxEvents: (_d = (_c = deps.batchLimits) === null || _c === void 0 ? void 0 : _c.maxEvents) !== null && _d !== void 0 ? _d : BATCH_MAX_EVENTS,
maxBytes: Math.min(globalMaxBytes, channelMaxBytes),
};
const chunks = handleDataChunked(snapshot, limits);
if (chunks.length === 0) {
// 快照已被 flush() 从队列摘除,但切片结果为空(极端:桶内全是空数组 key
// 或所有事件 JSON.stringify 失败)。若直接 return 会**静默丢数**,故回滚回队列等待下次。
logger.warn('[uni统计 2.0] flush 切片结果为空,已回滚队列', snapshot);
deps.queue.rollback(snapshot);
return;
}
const startMs = deps.nowMs();
let totalCount = 0;
for (const lt of Object.keys(snapshot)) {
const arr = snapshot[lt];
if (Array.isArray(arr))
totalCount += arr.length;
}
logReportStart({ channel: channel.name, bucket: snapshot });
// 切片是适配 image URL 长度限制 / 全局 batch 字节阈值的内部分批策略,业务方
// 不应感知。统计维度统一为**事件数**:成功片累计 okEvents、失败片累计 failedEvents
// + per-slice logReportFailure保留原因末尾由 logReportSummary 输出统一汇总。
// visit 字段fvts/lvts/tvc只随 lt=1 上行,而 serializer 已按 LT_ORDER 把 lt=1
// 排到最前 → 必定落在第一片chunks[0])。因此「访问是否被服务端接收」只取决于
// 第一片是否成功,与后续 lt=21/31 等切片成败无关。
// - 桶内有 lt=1以 chunks[0] 成功与否决定 commit / rollback部分成功也可 commit
// 避免后续片失败把已被接收的访问回滚,造成本地与服务端口径偏差)。
// - 桶内无 lt=1visit pending 本就为空commit/rollback 均为 noop沿用「全部成功才 commit」
// 的旧语义,保持既有行为与测试稳定。
const hasLaunch = Array.isArray(snapshot['1']) && snapshot['1'].length > 0;
let okEvents = 0;
let failedEvents = 0;
let allOk = true;
let firstChunkOk = true;
for (let i = 0; i < chunks.length; i++) {
const requests = chunks[i];
const payload = {
usv: deps.config.usv,
t: deps.nowSec(),
requests,
_id: ((_e = deps.genPayloadId) !== null && _e !== void 0 ? _e : (() => defaultGenPayloadId(deps.nowMs())))(),
};
const sliceEvents = countEvents(requests);
try {
yield channel.send(payload);
okEvents += sliceEvents;
}
catch (e) {
allOk = false;
if (i === 0)
firstChunkOk = false;
failedEvents += sliceEvents;
if (isPermanentChannelError(e)) {
// 永久错:丢弃本片,不 persist、不污染下次冷启
logger.warn('[uni统计 2.0] 统计上报失败(本批已丢弃,不可重试)', e, 'sliceBytes=' + requests.length);
logReportFailureReason({ error: e, persistedId: undefined });
continue;
}
logger.warn('[uni统计 2.0] 统计上报失败(已暂存,下次启动自动重试)', e);
const id = deps.retry.persist(payload);
if (!id) {
logger.warn('[uni统计 2.0] 统计暂存重试失败(无 retryId本批已丢弃');
}
logReportFailureReason({ error: e, persistedId: id });
}
}
const visitAccepted = hasLaunch ? firstChunkOk : allOk;
if (visitAccepted) {
tryRun(() => deps.visit.commitVisitOnAck(deps.nowSec()), undefined);
}
else {
tryRun(() => deps.visit.rollbackPendingVisit(), undefined);
}
// 单批最终汇总:业务方视角只看到"成功/失败/部分失败",不暴露切片实现。
// 文案见 debugLog.ts#logReportSummary。
logReportSummary({
channel: channel.name,
okCount: okEvents,
failedCount: failedEvents,
elapsedMs: deps.nowMs() - startMs,
});
});
}
/** 估算一片的事件数(容错:解析失败按 0 计)。仅供日志展示。 */
function countEvents(requests) {
try {
const arr = JSON.parse(requests);
return Array.isArray(arr) ? arr.length : 0;
}
catch (_a) {
return 0;
}
}
/**
* 把上次进程留在 storage 中的 retry 队列依次重放。
*
* 串行执行,失败的条目保留在队列里(不动 _id调用方会在下次冷启再次重放。
*/
function recoverRetry() {
return __awaiter(this, void 0, void 0, function* () {
if (deps.isNetworkOffline) {
let offline = false;
try {
offline = yield deps.isNetworkOffline();
}
catch (_a) {
offline = false;
}
if (offline) {
logger.warn('[uni统计 2.0] 当前无网络,延后续传重试');
return;
}
}
const items = deps.retry.loadAll();
if (items.length === 0)
return;
const channel = deps.selectChannel();
if (!channel) {
logger.warn('[uni统计 2.0] 续传重试跳过:当前无可用上报线路');
return;
}
logRecoverStart(items.length);
let i = 0;
for (const payload of items) {
i++;
const uploadPayload = applyUploadFieldsToPayload(payload);
try {
yield channel.send(uploadPayload);
if (payload._id)
deps.retry.ack(payload._id);
logRecoverItem({
index: i,
total: items.length,
payloadId: payload._id,
ok: true,
});
}
catch (e) {
// 永久错:直接 ack 删除死信,避免下次冷启再次重放再次失败
if (isPermanentChannelError(e)) {
if (payload._id)
deps.retry.ack(payload._id);
logger.warn('[uni统计 2.0] 续传重试失败(不可重试,已从队列移除)', e, 'id=' + payload._id);
logRecoverItem({
index: i,
total: items.length,
payloadId: payload._id,
ok: false,
error: e,
});
continue;
}
if (payload._id && deps.retry.markAttempt) {
// markAttempt 内部超过 maxAttempts 会自动 ack 兜底(参见 retry.ts
deps.retry.markAttempt(payload._id);
}
logger.warn('[uni统计 2.0] 续传重试失败(保留队列,下次启动再试)', e);
logRecoverItem({
index: i,
total: items.length,
payloadId: payload._id,
ok: false,
error: e,
});
}
}
});
}
/**
* 对外 flush显式调用含 `flush(true)`)立即发送,并取消尚未触发的延迟首 flush。
*/
function flush() {
return __awaiter(this, arguments, void 0, function* (force = false) {
cancelDeferredFlush();
firstFlushDone = true;
return flushImpl(force);
});
}
/** 取消延迟首 flush 定时器,防止 collector 被弃后仍触发幽灵 flush。 */
function destroy() {
cancelDeferredFlush();
// 置为「已完成首 flush」即便有残留闭包再次调用 triggerAutoFlush 也不会重排定时器。
firstFlushDone = true;
}
return { report, flush, recoverRetry, destroy };
}
/**
* 1.0 通道HTTP POST 上报。
*
* 兼容私有版同协议(`uni.request(POST STAT_URL)`),并修复其历史缺陷:
* - #1 `_retry` 未初始化导致 NaN本实现以 `withRetry({times})` 显式控制。
* - #16 H5 在 nvue/部分小程序无 `Image`:本实现以 `uni.request` 为主TLS 公有版 image
* 通道的 H5 路径亦优先 `uni.request` GET 以读取 HTTP 状态,避免误报成功。
*
* 接口契约:
* - `available()`:在任何 uni 平台都返回 trueHTTP 是兜底通道)。
* - `send(payload)`:成功 resolve3 次重试全失败抛错(供 retry.persist 落盘)。
* - 不缓存任何状态;每次 `send` 是无状态的。
*/
function getUni$6() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 把 payload 拼成 query string供 H5 image fallback 使用。
*
* 私有版用 `get_sgin(get_encodeURIComponent_options(data))` 还会算签名;公有版去掉签名
* (服务端历史阶段仅 1.0 走签名2.0 已弃用),保持 query 简单可读:
* `?usv=3&t=...&requests=URL_ENCODED_JSON`
*/
function toQuery(payload) {
const out = [];
out.push('usv=' + encodeURIComponent(String(payload.usv)));
out.push('t=' + encodeURIComponent(String(payload.t)));
out.push('requests=' + encodeURIComponent(payload.requests));
return out.join('&');
}
/**
* H5 image 通道。仅在 `Image` 全局存在时调用;否则返回 false 让外层退回 `uni.request`。
*
* 不等待 onload/onerrorimage 兜底语义即"发出去就算"),同步 resolve。
* 若 `new Image()` 本身抛错也吞掉,转给 fallback。
*/
function tryImageRequest(payload, h5Url = STAT_H5_URL) {
const ImageCtor = getGlobalObject().Image;
if (typeof ImageCtor !== 'function')
return false;
return tryRun(() => {
const img = new ImageCtor();
img.src = h5Url + '?' + toQuery(payload);
return true;
}, false);
}
function createHttpChannel(opts = {}) {
var _a, _b, _c, _d, _e;
const url = (_a = opts.url) !== null && _a !== void 0 ? _a : STAT_URL;
const h5Url = (_b = opts.h5Url) !== null && _b !== void 0 ? _b : STAT_H5_URL;
const ut = (_c = opts.ut) !== null && _c !== void 0 ? _c : '';
const timeoutMs = (_d = opts.timeoutMs) !== null && _d !== void 0 ? _d : 10000;
const maxRetries = (_e = opts.maxRetries) !== null && _e !== void 0 ? _e : HTTP_MAX_RETRIES;
function once(payload) {
if (ut === 'h5' && opts.preferImageOnH5 !== false) {
if (tryImageRequest(payload, h5Url))
return Promise.resolve();
}
const u = getUni$6();
if (!u || typeof u.request !== 'function') {
return Promise.reject(new Error('uni.request unavailable'));
}
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
reject(new Error('http timeout'));
}, timeoutMs);
u.request({
url,
method: 'POST',
data: payload,
timeout: timeoutMs,
success: (res) => {
var _a;
if (settled)
return;
settled = true;
clearTimeout(timer);
const code = (_a = res === null || res === void 0 ? void 0 : res.statusCode) !== null && _a !== void 0 ? _a : 0;
if (code >= 200 && code < 300)
resolve();
else
reject(new Error('http status ' + code));
},
fail: (e) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
});
});
}
return {
name: '1.0',
available() {
const u = getUni$6();
return !!(u && typeof u.request === 'function');
},
send(payload) {
return __awaiter(this, void 0, void 0, function* () {
try {
yield withRetry(() => once(payload), {
times: maxRetries,
baseDelayMs: RETRY_BASE_DELAY_MS,
sleep: opts.sleep,
});
}
catch (e) {
logger.warn('[uni统计 2.0] 统计上报失败HTTP 已重试)', e);
throw e;
}
});
},
};
}
/**
* 公有版默认通道:火山 TLS Web 采集。
*
* **官方 GET**`uni.request`App / 小程序 / H5·微信回退
* `GET ${host}/WebTrack?ProjectId&TopicId&Logs&Source&Time&…`
* 与文档 `curl GET 'http://${host}/WebTrack?ProjectId=…&TopicId=…&key=val'` 一致。
*
* **信标 GET**H5 `fetch`/`Image`、微信 `preloadAssets`
* `GET ${host}/WebTrack.gif?…`query 与 `/WebTrack` 相同,路径为 1×1 像素接口)。
* H5 优先 `fetch(keepalive)` 读真实状态码;采集端返回 `200 application/json` 且带
* `Access-Control-Allow-Origin: *`,故可跨域判定成败(旧 `<img>` 信标无法读状态,降级兜底)。
*
* **已废弃 POST**`POST ${host}/WebTracks?ProjectId&TopicId` + JSON body。
*/
/** 官方 GET 接口路径(`uni.request`)。 */
const WEBTRACK_API_PATH = '/WebTrack';
/** 浏览器 / 微信 preload 信标路径。 */
const WEBTRACK_BEACON_PATH = '/WebTrack.gif';
/**
* 解析运行时 `uni.request` API。
*/
function getUni$5() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/** URL 中除 `Logs` 外的固定 query 字节预算(保守值)。 */
const REPORT_URL_BASE_OVERHEAD = 256;
/** `encodeURIComponent` 字节膨胀比上界(用于 collector 切片反推)。 */
const REPORT_ENCODE_RATIO = 3.0;
/**
* 拼装统计上报 queryProjectId / TopicId / Logs / Source / Time
*
* @param payload 上报 payload`requests` 为 `JSON.stringify(events)`。
* @param opts host / projectId / topicId / path / nowMs。
*/
function buildStatReportUrl(payload, opts) {
var _a;
const t = ((_a = opts.nowMs) !== null && _a !== void 0 ? _a : (() => Date.now()))();
const logs = encodeURIComponent(payload.requests);
const host = opts.host.replace(/\/+$/, '');
return (host +
opts.path +
'?ProjectId=' +
encodeURIComponent(opts.projectId) +
'&TopicId=' +
encodeURIComponent(opts.topicId) +
'&Logs=' +
logs +
'&Source=webImg' +
'&Time=' +
t);
}
/**
* 将 `uni.request` 返回的 `data` 压成短串,便于在 Error.message 中展示。
*
* @param data success 回调中的 `res.data`
* @param maxLen 最大字符数
*/
function summarizeHttpErrorBody(data, maxLen = 320) {
if (data == null)
return '';
if (typeof data === 'string') {
return data.length <= maxLen ? data : data.slice(0, maxLen) + '…';
}
try {
const s = JSON.stringify(data);
return s.length <= maxLen ? s : s.slice(0, maxLen) + '…';
}
catch (_a) {
return String(data).slice(0, maxLen);
}
}
/**
* H5 最后兜底:`Image` 触发 `/WebTrack.gif``onload` / `onerror` 均 resolve仅超时 reject。
*
* ## 为什么 onload/onerror 都判成功(且为何不再作为首选)
*
* 采集端 `/WebTrack.gif` 成功时返回 `200 application/json`(空体),**并非合法图片**
* 浏览器无法把响应解码为图片 → 即便上报成功也会触发 `onerror`。因此 `<img>` 信标
* 物理上**无法区分**「成功JSON 响应」与「真实失败DNS/网络/拦截/4xx/5xx
* 只能一律 resolve否则每次成功都会被误判失败并重试。
*
* 这是一个有损降级:仅在**既无 `fetch` 又无 `uni.request`** 的极旧 H5 环境才会走到。
* 正常环境优先 `fetchBeaconAwait`(读真实状态码),次选 `uni.request` GET读 statusCode
*
* @param url 完整信标 URL
* @param ms 超时毫秒
*/
function imageBeaconAwait(url, ms) {
const ImageCtor = getGlobalObject().Image;
if (typeof ImageCtor !== 'function') {
return Promise.reject(new PermanentChannelError('当前环境无法完成统计上报'));
}
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
reject(new Error('统计上报超时'));
}, ms);
const img = new ImageCtor();
img.onload = () => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve();
};
img.onerror = () => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve();
};
img.src = url;
});
}
/**
* H5 首选:`fetch` 触发 `/WebTrack.gif`,读取真实 HTTP 状态码判定成败。
*
* ## 为什么用 fetch 替代 `<img>` 信标
*
* 采集端 `/WebTrack.gif` 成功返回 `200 application/json`(空体),且响应头带
* `Access-Control-Allow-Origin: *`,因此 H5 可**跨域读取** `res.ok`
* - 2xx → 送达成功resolve
* - 其余状态码 / 网络异常DNS、断网、CSP/拦截、4xx/5xx→ reject交由
* `withRetry` 重试,最终失败落盘 retry**不再被静默 ACK**。
*
* `keepalive: true` 保证页面卸载(如 `lt=3` hide 期)请求仍能发出,等价于
* `<img>` 信标的「卸载存活」能力,因此可安全取代旧的 onerror=成功 兜底。
*
* `credentials: 'omit'`:仅需读状态码,无需携带 cookie同时规避
* `Allow-Origin:*` 与 `include` 凭证模式在浏览器侧的冲突。
*
* @param url 完整信标 URL
* @param ms 超时毫秒
*/
function fetchBeaconAwait(url, ms) {
const g = getGlobalObject();
const fetchFn = g.fetch;
if (typeof fetchFn !== 'function') {
return Promise.reject(new Error('fetch unavailable'));
}
const controller = typeof g.AbortController === 'function'
? new g.AbortController()
: undefined;
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
if (controller)
tryRun(() => controller.abort(), undefined);
reject(new Error('统计上报超时'));
}, ms);
fetchFn(url, {
method: 'GET',
keepalive: true,
credentials: 'omit',
signal: controller ? controller.signal : undefined,
}).then((res) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
if (res && res.ok) {
resolve();
return;
}
reject(new Error('统计上报 HTTP ' + (res ? res.status : 0)));
}, (e) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
});
});
}
/**
* 读取微信 `wx.preloadAssets`(仅 mp-weixin 信标使用)。
*/
function getWxPreloadAssets() {
const wx = getGlobalObject().wx;
return typeof (wx === null || wx === void 0 ? void 0 : wx.preloadAssets) === 'function' ? wx.preloadAssets : undefined;
}
/**
* 规范化 `wx.preloadAssets` 的 fail 入参。
*/
function formatWxPreloadFail(err) {
if (err instanceof Error)
return err;
if (err != null && typeof err === 'object' && 'errMsg' in err) {
const msg = err.errMsg;
if (typeof msg === 'string' && msg.length > 0)
return new Error(msg);
}
if (err == null)
return new Error('preloadAssets fail (empty err)');
return new Error(String(err));
}
/**
* 微信:`wx.preloadAssets` 拉取 `/WebTrack.gif`;仅 `success` 视为送达。
*
* @param url 完整信标 URL
* @param ms 超时毫秒
* @param preload 已校验存在的 `wx.preloadAssets`
*/
function mpWeixinPreloadAssetsBeaconAwait(url, ms, preload) {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
reject(new Error('统计上报超时(preloadAssets)'));
}, ms);
try {
preload({
data: [{ type: 'image', src: url }],
success: () => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve();
},
fail: (err) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
reject(formatWxPreloadFail(err));
},
});
}
catch (e) {
if (settled)
return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
}
/**
* 微信小程序是否启用 preload 信标(开关开且宿主为 mp-weixin
*/
function isMpWeixinPreloadEnabled(opts) {
var _a, _b;
const enabled = (_a = opts.mpWeixinPreloadReport) !== null && _a !== void 0 ? _a : MP_WEIXIN_USE_PRELOAD_ASSETS_REPORT;
if (!enabled)
return false;
const raw = (_b = opts.rawPlatform) !== null && _b !== void 0 ? _b : getRawPlatform();
return raw === 'mp-weixin';
}
function createImageChannel(opts = {}) {
var _a, _b, _c, _d, _e, _f, _g;
const host = (_a = opts.host) !== null && _a !== void 0 ? _a : IMAGE_REPORT_DEFAULTS.host;
const projectId = (_b = opts.projectId) !== null && _b !== void 0 ? _b : IMAGE_REPORT_DEFAULTS.projectId;
const topicId = (_c = opts.topicId) !== null && _c !== void 0 ? _c : IMAGE_REPORT_DEFAULTS.topicId;
const timeoutMs = (_d = opts.timeoutMs) !== null && _d !== void 0 ? _d : 10000;
const maxRetries = (_e = opts.maxRetries) !== null && _e !== void 0 ? _e : IMAGE_MAX_RETRIES;
const maxUrlLength = (_f = opts.maxUrlLength) !== null && _f !== void 0 ? _f : 6 * 1024;
const preferBeacon = opts.preferImageBeacon !== false;
const nowMs = opts.nowMs;
const ut = (_g = opts.ut) !== null && _g !== void 0 ? _g : '';
const isH5 = ut === 'h5';
const mpWeixinPreload = isMpWeixinPreloadEnabled(opts);
function configured() {
return !!(host && projectId && topicId);
}
const reportOpts = { host, projectId, topicId, nowMs };
/**
* 校验配置并拼装 URL超长抛 `PermanentChannelError`。
*
* @param payload 批次数据
* @param path `WEBTRACK_API_PATH` 或 `WEBTRACK_BEACON_PATH`
*/
function preflightUrl(payload, path) {
if (!configured()) {
throw new PermanentChannelError('统计上报未配置:请设置 TLS host、projectId、topicId');
}
const url = buildStatReportUrl(payload, {
host: reportOpts.host,
projectId: reportOpts.projectId,
topicId: reportOpts.topicId,
nowMs: reportOpts.nowMs,
path,
});
if (url.length > maxUrlLength) {
throw new PermanentChannelError('统计上报 URL 过长: ' + url.length + ' > ' + maxUrlLength);
}
return url;
}
/**
* `uni.request` GET `/WebTrack`(官方普通 GET非信标
*/
function webTrackGetViaRequest(url) {
const u = getUni$5();
if (!u || typeof u.request !== 'function') {
return Promise.reject(new PermanentChannelError('当前环境无法完成统计上报'));
}
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
reject(new Error('统计上报超时'));
}, timeoutMs);
u.request({
url,
method: 'GET',
timeout: timeoutMs,
success: (res) => {
var _a;
if (settled)
return;
settled = true;
clearTimeout(timer);
const code = (_a = res === null || res === void 0 ? void 0 : res.statusCode) !== null && _a !== void 0 ? _a : 0;
if (code >= 200 && code < 300) {
resolve();
return;
}
const hint = summarizeHttpErrorBody(res === null || res === void 0 ? void 0 : res.data);
reject(new Error(hint ? `统计上报 HTTP ${code}: ${hint}` : `统计上报 HTTP ${code}`));
},
fail: (e) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
});
});
}
/**
* H5 发送方式选择(均能判定真实成败,失败进 retry
* 1. 首选 `fetch` 信标 `/WebTrack.gif`:跨域读 `res.ok``keepalive` 保证卸载期送达;
* 2. 次选 `uni.request` GET `/WebTrack`:读 `statusCode`
* 3. 末选 `Image` 信标 `/WebTrack.gif`:仅极旧环境(无 fetch、无 uni.request兜底
* 无法读状态,发出即视为送达(有损)。
*
* `preferImageBeacon: false` 时跳过信标,强制走 `uni.request` GET测试/特殊场景)。
*/
function onceH5(payload) {
const g = getGlobalObject();
const u = getUni$5();
const hasRequest = !!(u && typeof u.request === 'function');
if (preferBeacon && typeof g.fetch === 'function') {
return fetchBeaconAwait(preflightUrl(payload, WEBTRACK_BEACON_PATH), timeoutMs);
}
if (hasRequest) {
return webTrackGetViaRequest(preflightUrl(payload, WEBTRACK_API_PATH));
}
if (preferBeacon && typeof g.Image === 'function') {
return imageBeaconAwait(preflightUrl(payload, WEBTRACK_BEACON_PATH), timeoutMs);
}
return Promise.reject(new PermanentChannelError('当前环境无法完成统计上报'));
}
/**
* 微信:优先 `/WebTrack.gif` preload否则 `uni.request` GET `/WebTrack`。
*/
function onceMpWeixin(payload) {
const preloadFn = getWxPreloadAssets();
if (preloadFn) {
return mpWeixinPreloadAssetsBeaconAwait(preflightUrl(payload, WEBTRACK_BEACON_PATH), MP_WEIXIN_PRELOAD_TIMEOUT_MS, preloadFn);
}
logger.warn('[uni统计 2.0] wx.preloadAssets 不可用,回退 uni.request GET /WebTrack');
return webTrackGetViaRequest(preflightUrl(payload, WEBTRACK_API_PATH));
}
/**
* 按宿主选择发送方式。
*/
function dispatchReport(payload) {
if (isH5)
return onceH5(payload);
if (mpWeixinPreload)
return onceMpWeixin(payload);
return webTrackGetViaRequest(preflightUrl(payload, WEBTRACK_API_PATH));
}
return {
name: 'image',
available() {
return configured();
},
maxRequestBytes() {
const raw = (maxUrlLength - REPORT_URL_BASE_OVERHEAD) / REPORT_ENCODE_RATIO;
return Math.max(512, Math.floor(raw));
},
send(payload) {
return __awaiter(this, void 0, void 0, function* () {
try {
yield withRetry(() => dispatchReport(payload), {
times: maxRetries,
baseDelayMs: RETRY_BASE_DELAY_MS,
sleep: opts.sleep,
});
}
catch (e) {
if (isPermanentChannelError(e)) {
logger.warn('[uni统计 2.0] 统计上报失败(不可重试)', e);
}
else {
logger.warn('[uni统计 2.0] 统计上报失败(已重试)', e);
}
throw e;
}
});
},
};
}
/**
* 上行字段集中拼装。
*
* 私有版痛点:`sendXxxRequest` 系列函数中各自 `Object.assign(getStatData(), ...)`
* 字段散落、重复、字段名硬编码、新增字段需改多处。公有版集中到本模块,按事件类型
* 决定字段子集。
*
* 设计要点:
* - 通过依赖注入(`createStatDataBuilder(deps)`)解耦 adapter / domain便于单测。
* - 字段全部经过 `s/n` 兜底转换,禁止 undefined 出现在最终上行体(统一用空串 / 0
* - 事件类型驱动:仅 lt=1 携带 fvts/lvts/tvc / sc 等启动字段;其他事件不携带。
* - 仅做拼装,不做副作用:不写 storage、不调 ensureSession这些由 collector 编排)。
* - 严守 ES2015 baseline禁用 `ObjectExpression > SpreadElement`,统一用 `Object.assign`。
*
* 与 `docs/uni统计上报参数.md` 对齐说明:
* - 设备 ID 使用文档字段名 `did`(内部 SessionSnapshot/Adapter 仍以 uuid 命名,仅出口处映射)。
* - `on`:优先 **`romName`**(厂商 ROM如 HyperOS及 **`romVersion`**,否则 **`osName`**。
* - 会话创建类型使用文档字段名 `cst`(内部 storage 仍以 sct 命名,仅出口处映射)。
* - 不再上行 `sst / seq / pid`(及历史 `odid`
* * sst/seq 仅本地用于会话状态机,不参与服务端入库;
* * pid上一会话 sid当前后端无入库口径。
* 这些字段在 SessionSnapshot 里仍保留,确保会话过期判断、调试日志可继续使用。
*/
/** 字段值兜底:把 undefined / null / NaN 转为类型默认值,避免污染上行 JSON。 */
function s(v, def = '') {
if (typeof v === 'string')
return v;
if (typeof v === 'number' && Number.isFinite(v))
return String(v);
return def;
}
function n(v, def = 0) {
if (typeof v === 'number' && Number.isFinite(v))
return v;
if (typeof v === 'string' && v.length > 0) {
const x = Number(v);
if (Number.isFinite(x))
return x;
}
return def;
}
/**
* 创建 statData 构建器。
*
* 调用方典型用法:
* ```ts
* const builder = createStatDataBuilder(deps)
* const data = builder.build({ lt: LT.Page, t: nowSec(), route: '...' })
* ```
*/
function createStatDataBuilder(deps) {
/**
* 复用频率高的"基础字段"——每条事件都带。
*
* 字段映射(参考 `docs/uni统计上报参数.md`
* - `did` ← 内部 `device.uuid`(出口字段重命名为文档口径)
* - `p` ← `platform.p` 或 `system.osP`(仅操作系统 slug`ios` / `android` …)
* - `on` ← `system.on`ROM 展示名优先,否则 `osName`
* - `mpsdk` ← `system.sdkVersion`
* - `mpv` ← `system.mpvHostVersion`(宿主客户端版本,与私有版 `sys.version` 同源)
* - `domain` ← `web.domain`H5 含协议域名,如 `https://www.example.com`,非 H5 为空串)
* - `pr/ww/wh/sw/sh/lang` 来自 `locale`(实时取,修复缺陷 #18
* - `lat/lng` 当前 LocationResult 仅含字符串经纬度cn/pn/ct 留空待 adapter 扩展
*
* 不再装配 `odid`(老 App 兼容字段已移除)。
*/
function baseFields() {
var _a, _b, _c;
const { config, platform, system, locale, device, net, location, pkg, legacy, web, } = deps;
return {
ak: s(config.ak),
usv: s(config.usv),
v: s((_a = config.v) !== null && _a !== void 0 ? _a : system.appVersion),
ch: s(config.ch),
ut: s(platform.ut),
p: s((_b = platform.p) !== null && _b !== void 0 ? _b : system.osP),
on: s(system.on),
did: s(device.uuid),
brand: s(system.brand),
md: s(system.md),
sv: s(system.sv),
mpsdk: s(system.sdkVersion),
mpv: s(system.mpvHostVersion),
pr: n(locale.pr, 1),
ww: n(locale.ww),
wh: n(locale.wh),
sw: n(locale.sw),
sh: n(locale.sh),
lang: s(locale.lang),
net: s(net.net, 'unknown'),
lat: s(location.lat),
lng: s(location.lng),
mpn: s((_c = legacy === null || legacy === void 0 ? void 0 : legacy.mpn) !== null && _c !== void 0 ? _c : pkg.mpn),
tdaid: s(pkg.tdaid),
pkn: s(pkg.pkn),
an: s(pkg.an),
domain: s(web.domain),
};
}
/**
* 会话字段:所有 lt 都要带。
*
* 与文档对齐:仅上行 `sid` 与 `cst`
* 内部状态字段 `sst / seq` 不再随上行体发出,仅保留在 SessionSnapshot 中。
*/
function sessionFields(ctx) {
if (!ctx.session)
return {};
return {
sid: ctx.session.sid,
cst: ctx.session.sct,
};
}
/** 页面字段lt=11/3 / 普通页面事件携带。 */
function pageFields(ctx) {
const out = {};
if (ctx.url !== undefined)
out.url = s(ctx.url);
if (ctx.urlref !== undefined)
out.urlref = s(ctx.urlref);
if (ctx.urlref_ts !== undefined)
out.urlref_ts = n(ctx.urlref_ts);
if (ctx.ttn !== undefined)
out.ttn = s(ctx.ttn);
if (ctx.ttpj !== undefined)
out.ttpj = s(ctx.ttpj);
if (ctx.ttc !== undefined)
out.ttc = s(ctx.ttc);
return out;
}
/**
* 入口标记:**仅 lt=11** 携带 iey + ppiey缺省按 0lt=1 / lt=3 等不参与入口字段。
*/
function entryFields(ctx) {
if (ctx.lt === '11') {
return {
iey: toIey(ctx.iey !== undefined ? ctx.iey : false),
ppiey: toIey(ctx.ppiey !== undefined ? ctx.ppiey : false),
};
}
return {};
}
/** 访问字段:仅 lt=1应用启动 / 新会话)且 collector 显式传入 visit 时携带。 */
function visitFields(ctx) {
if (ctx.lt !== '1')
return {};
if (!ctx.visit)
return {};
return {
fvts: ctx.visit.fvts,
lvts: ctx.visit.lvts,
tvc: ctx.visit.tvc,
};
}
/** 启动场景:仅 lt=1 携带。 */
function launchFields(ctx) {
if (ctx.lt !== '1')
return {};
if (ctx.sc === undefined)
return {};
return { sc: s(ctx.sc) };
}
/**
* 错误事件特化字段lt=31 时把 `errMsg`(含 stack截断后写入 `em`。
*
* 截断动机:长 Error stack尤其是 jest / Node 调用栈)轻易超过 3KB会让单条事件
* 触发 `SINGLE_EVENT_MAX_BYTES` 被 enqueue 丢弃;这里在 builder 阶段先做一次软截断,
* 既能保留头部关键定位信息(错误类型、消息、第一层 stack又能保证事件可达。
*
* 阈值3KB保留 1KB buffer 给其他字段,整体仍在 SINGLE_EVENT_MAX_BYTES = 4KB 内)。
*/
function errorFields(ctx) {
if (ctx.lt !== '31' || !ctx.errMsg)
return {};
const ERR_MSG_MAX = 3 * 1024;
const TRUNC_SUFFIX = '…[truncated]';
let em = s(ctx.errMsg);
if (em.length > ERR_MSG_MAX) {
em = em.slice(0, ERR_MSG_MAX - TRUNC_SUFFIX.length) + TRUNC_SUFFIX;
}
return { em };
}
/** Push 事件特化字段。 */
function pushFields(ctx) {
if (ctx.lt !== '101' || !ctx.cid)
return {};
return { cid: s(ctx.cid) };
}
/**
* 拼装最终上行体。
*
* 合并顺序(**后者覆盖前者**
* base → session → page → entry → visit → launch → error → push → custom
* custom 放最后,业务可控扩展,但**不允许**覆盖 lt/t/sid 等关键字段(在此过滤)。
*/
function build(ctx) {
const safeCustom = {};
if (ctx.custom) {
const reserved = new Set([
'lt',
't',
'sid',
'cst',
'did',
'p',
'on',
'mpv',
'domain',
'fvts',
'lvts',
'tvc',
'sc',
]);
for (const k of Object.keys(ctx.custom)) {
if (!reserved.has(k))
safeCustom[k] = ctx.custom[k];
}
}
const out = { lt: ctx.lt, t: n(ctx.t) };
Object.assign(out, baseFields(), sessionFields(ctx), pageFields(ctx), entryFields(ctx), visitFields(ctx), launchFields(ctx), errorFields(ctx), pushFields(ctx), safeCustom);
return out;
}
return { build };
}
/**
* App 渠道包标识适配(对齐私有版 `utils/pageInfo.js#get_channel`)。
*
* HBuilderX 云打包会为每个渠道包写入 `plus.runtime.channel`(如 huawei、oppo
* 统计上行字段 `ch` 应优先读取该运行时值,而非 manifest 静态配置。
*
* 职责:
* - App 端(`isApp()`)尝试读取 `plus.runtime.channel`。
* - 若构建期平台变量缺失但运行时已存在 `plus.runtime`,也信任原生运行时作为 App 信号。
* - 任意 API 缺失 / 抛错 → 降级 `''`,不阻断 install。
* - 返回值统一为 `string`(原生偶发返回数字时转为字符串)。
*/
/**
* 将原生渠道值规范为上行用的字符串。
*
* @param value `plus.runtime.channel` 原值。
* @returns 非空字符串;无法识别时返回 `''`。
*/
function normalizeChannelValue(value) {
if (typeof value === 'string')
return value;
if (typeof value === 'number' && Number.isFinite(value))
return String(value);
return '';
}
/**
* 读取 App 渠道包标识(`plus.runtime.channel`)。
*
* 与私有版 `get_channel()` 对齐:仅原生 App 有意义;小程序 / H5 恒为 `''`。
*
* @returns 渠道字符串;未配置或读取失败时为 `''`。
*/
function getAppChannel() {
const plus = getGlobalObject().plus;
if (!isApp() && !(plus === null || plus === void 0 ? void 0 : plus.runtime))
return '';
const raw = tryRun(() => { var _a; return (_a = plus === null || plus === void 0 ? void 0 : plus.runtime) === null || _a === void 0 ? void 0 : _a.channel; }, undefined);
return normalizeChannelValue(raw);
}
/**
* 系统信息适配。
*
* 私有版的痛点(参考缺陷清单 #14、#18
* - `utils/util.js` 顶层 `export const sys = uni.getSystemInfoSync()`:模块加载即执行
* `uni.getSystemInfoSync`SSR / 单测 / nvue 早期阶段会直接抛错。
* - `lang / ww / wh` 等"可变"字段被一同缓存,用户切换系统语言或旋转屏幕后字段失真。
*
* 公有版职责:
* 1. `getSystemInfo()` 懒加载 + 缓存不可变字段brand/md/sv/v/ut/on …)。
* 2. `getLocaleAndScreen()` 实时取lang + ww/wh + sw/sh + pr—— 修复缺陷 #18。
* 3. SSR/单测:任一 API 不存在或抛错时,返回安全空对象,绝不抛。
* 4. `__resetCache()`:仅供测试,重置缓存。
*
* 小程序新基础库对 `getSystemInfoSync` 做了能力拆分,部分字段为空或恒为 0。
* 因此优先通过 `uni.getDeviceInfo / getAppBaseInfo / getWindowInfo` 取对应信息,
* 再以 `uni.getSystemInfoSync` 合并兜底(与 uni-app 运行时、uni-api 侧实践一致)。
*
* **小程序注意**`uni` 常由构建注入在模块作用域,仅读 `globalThis.uni` 会取不到
* 任何 API必须通过 `resolveUniRuntime()` 与 `package.ts` 等 adapter 对齐。
* 微信系再叠一层 `wx.getDeviceInfo / getAppBaseInfo / getWindowInfo`(与 `uni-api`
* `upx2px` 一致),避免 `uni` 代理未就绪时宽高全 0。
*/
let cachedStatic = null;
/**
* 解析 `uni` 根对象:优先 `globalThis.uni`,再回退宿主注入的模块级 `uni`。
*
* @see `infra/uniRuntime.ts` 说明(小程序上仅读 globalThis 会静默失败)。
*/
function getUni$4() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 微信系宿主上再取一层原生拆分 API与 `uni` 合并结果再叠加以补全字段。
*
* @returns 已按 sync→device→app→window 合并过的一条快照;非微信系返回 `null`。
*/
function mergeWxHostSnapshots() {
const raw = getRawPlatform();
if (raw !== 'mp-weixin' && raw !== 'mp-qq')
return null;
const wxHost = getGlobalObject().wx;
if (!wxHost)
return null;
const sync = typeof wxHost.getSystemInfoSync === 'function'
? tryRun(() => wxHost.getSystemInfoSync(), null)
: null;
const device = typeof wxHost.getDeviceInfo === 'function'
? tryRun(() => wxHost.getDeviceInfo(), null)
: null;
const appBase = typeof wxHost.getAppBaseInfo === 'function'
? tryRun(() => wxHost.getAppBaseInfo(), null)
: null;
const windowInfo = typeof wxHost.getWindowInfo === 'function'
? tryRun(() => wxHost.getWindowInfo(), null)
: null;
return mergeSystemSnapshots(sync, device, appBase, windowInfo);
}
/**
* 从左到右浅合并多个快照:后者非 `undefined` / `null` 的键覆盖前者。
*
* 合并顺序为「sync → device → appBase → window」使拆分 API 覆盖宿主裁剪后的
* `getSystemInfoSync` 残缺字段。
*/
function mergeSystemSnapshots(...parts) {
const out = {};
for (const p of parts) {
if (!p)
continue;
for (const k of Object.keys(p)) {
const v = p[k];
if (v !== undefined && v !== null)
out[k] = v;
}
}
return out;
}
/**
* 聚合当前运行时的系统信息:先 `getSystemInfoSync` 打底,再叠拆分 API。
*
* 各 API 均经 `tryRun` 包裹,任一失败不影响其余来源。
*/
function mergedSystemInfo() {
const u = getUni$4();
const sync = u && typeof u.getSystemInfoSync === 'function'
? tryRun(() => u.getSystemInfoSync(), null)
: null;
const device = u && typeof u.getDeviceInfo === 'function'
? tryRun(() => u.getDeviceInfo(), null)
: null;
const appBase = u && typeof u.getAppBaseInfo === 'function'
? tryRun(() => u.getAppBaseInfo(), null)
: null;
const windowInfo = u && typeof u.getWindowInfo === 'function'
? tryRun(() => u.getWindowInfo(), null)
: null;
const fromUni = mergeSystemSnapshots(sync, device, appBase, windowInfo);
const fromWx = mergeWxHostSnapshots();
const merged = fromWx ? mergeSystemSnapshots(fromUni, fromWx) : fromUni;
return merged;
}
/**
* 读取 H5 运行时 `__uniConfig.appVersion`manifest.versionName
*
* H5 发行摇树时模块加载期 `window.uni` 可能仍是 `{}` 空桩,`resolveUniRuntime`
* 无法调用 `getAppBaseInfo`;此时 `__uniConfig` 仍已由构建注入,可作为应用版本兜底。
*/
function resolveUniConfigAppVersion() {
return tryRun(() => {
const cfg = getGlobalObject().__uniConfig;
return typeof (cfg === null || cfg === void 0 ? void 0 : cfg.appVersion) === 'string' ? cfg.appVersion : '';
}, '');
}
/**
* 读取构建期注入的 `UNI_APP_VERSION_NAME`manifest.versionName
*
* 须**直接**访问 `process.env.UNI_APP_VERSION_NAME`,以便 Vite define 静态替换;
* 经中间变量读取会导致发行包内始终为空(与 `install.ts#parseInjectedUniStatistics` 同理)。
*/
function resolveBuildTimeAppVersion() {
const raw = process.env.UNI_APP_VERSION_NAME;
return typeof raw === 'string' ? raw : '';
}
/**
* 解析上行用的应用版本 `appVersion`(对应 statData 字段 `v` 的主要回退来源)。
*
* 优先级App 原生 `plus.runtime.version` → uni 拆分 API → H5 `__uniConfig` → 构建期 env。
*/
function resolveAppVersionForStat(plus, sys) {
var _a;
const fromPlus = (_a = plus === null || plus === void 0 ? void 0 : plus.runtime) === null || _a === void 0 ? void 0 : _a.version;
if (typeof fromPlus === 'string' && fromPlus)
return fromPlus;
const fromSys = sys.appVersion;
if (typeof fromSys === 'string' && fromSys)
return fromSys;
const fromUniConfig = resolveUniConfigAppVersion();
if (fromUniConfig)
return fromUniConfig;
return resolveBuildTimeAppVersion();
}
/**
* 组装上行 `on`优先厂商定制系统名ROM否则退回操作系统名 `osName`。
*
* App 端 `uni.getDeviceInfo` 会带出 `romName`/`romVersion`(见 uni-app-plus 原生 systemInfo
* 微信等小程序沙箱通常无 ROM 字段,此时与仅 `osName` 一致。
*
* @param sys `mergedSystemInfo()` 合并结果
* @returns 去首尾空白后的展示串;均无则空串
*/
function buildOnForStat(sys) {
const rom = typeof sys.romName === 'string' ? sys.romName.trim() : '';
if (rom) {
const romVer = typeof sys.romVersion === 'string' ? sys.romVersion.trim() : '';
return romVer ? `${rom} ${romVer}`.trim() : rom;
}
return typeof sys.osName === 'string' ? sys.osName.trim() : '';
}
/**
* 取静态系统信息(懒加载 + 缓存)。
*
* 字段映射策略:
* - `brand / md`:优先 `deviceBrand`/`deviceModel`(拆分 API再退化 `brand`/`model`。
* - `sv / v / sdkVersion`:优先 `osVersion`、`hostVersion`、`hostSDKVersion`,兼容旧字段。
* - `osP`:由 `platform` / `osName` / `system` 经 `normalizeStatOsP` 得到,供上行 `p`。
* - `mpvHostVersion``hostVersion ?? version`,与私有版 `sys.version` 同源。
* - `on``buildOnForStat`(优先 `romName`/`romVersion`,否则 `osName`),供上行 `on`。
* - `appVersion`:见 `resolveAppVersionForStat`H5 发行空桩时回退 `__uniConfig` / 构建 env
* - 缺失统一空字符串或 0避免上行 JSON 丢字段语义。
*/
function getSystemInfo() {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
if (cachedStatic)
return cachedStatic;
const sys = mergedSystemInfo();
const plus = getGlobalObject().plus;
const appVersion = resolveAppVersionForStat(plus, sys);
cachedStatic = {
brand: (_b = (_a = sys.deviceBrand) !== null && _a !== void 0 ? _a : sys.brand) !== null && _b !== void 0 ? _b : '',
md: (_d = (_c = sys.deviceModel) !== null && _c !== void 0 ? _c : sys.model) !== null && _d !== void 0 ? _d : '',
sv: (_f = (_e = sys.osVersion) !== null && _e !== void 0 ? _e : sys.system) !== null && _f !== void 0 ? _f : '',
v: (_h = (_g = sys.hostVersion) !== null && _g !== void 0 ? _g : sys.version) !== null && _h !== void 0 ? _h : '',
ut: ((_j = sys.deviceType) !== null && _j !== void 0 ? _j : 'unknown'),
appVersion,
appWgtVersion: (_p = (_o = (_l = (_k = plus === null || plus === void 0 ? void 0 : plus.runtime) === null || _k === void 0 ? void 0 : _k.appWgtVersion) !== null && _l !== void 0 ? _l : (_m = plus === null || plus === void 0 ? void 0 : plus.runtime) === null || _m === void 0 ? void 0 : _m.appWgtRevision) !== null && _o !== void 0 ? _o : sys.appWgtVersion) !== null && _p !== void 0 ? _p : '',
mpvHostVersion: ((_r = (_q = sys.hostVersion) !== null && _q !== void 0 ? _q : sys.version) !== null && _r !== void 0 ? _r : '').trim(),
on: buildOnForStat(sys),
sdkVersion: (_t = (_s = sys.hostSDKVersion) !== null && _s !== void 0 ? _s : sys.SDKVersion) !== null && _t !== void 0 ? _t : '',
statusBarHeight: typeof sys.statusBarHeight === 'number' ? sys.statusBarHeight : 0,
osP: normalizeStatOsP({
platform: sys.platform,
osName: sys.osName,
system: sys.system,
}),
};
return cachedStatic;
}
/**
* 取实时字段lang / 窗口尺寸 / 屏幕尺寸 / dpr
*
* 每次调用重新走拆分 API + sync 合并,不复用缓存,避免旋转屏、改语言后失真。
*/
function getLocaleAndScreen() {
var _a, _b;
const sys = mergedSystemInfo();
const prRaw = typeof sys.pixelRatio === 'number'
? sys.pixelRatio
: typeof sys.devicePixelRatio === 'number'
? sys.devicePixelRatio
: 1;
return {
lang: ((_b = (_a = sys.hostLanguage) !== null && _a !== void 0 ? _a : sys.language) !== null && _b !== void 0 ? _b : '').replace(/_/g, '-'),
ww: typeof sys.windowWidth === 'number' ? sys.windowWidth : 0,
wh: typeof sys.windowHeight === 'number' ? sys.windowHeight : 0,
sw: typeof sys.screenWidth === 'number' ? sys.screenWidth : 0,
sh: typeof sys.screenHeight === 'number' ? sys.screenHeight : 0,
pr: prRaw > 0 ? prRaw : 1,
};
}
/**
* 包信息适配(公有版新增字段 `tdaid / pkn / an`)。
*
* 详细矩阵参考 `04-字段字典与平台获取矩阵.md` §3。本模块职责
* - 启动时调用一次 `getPackageInfo()`,结果常驻内存;不入 storage。
* - 每端分支独立函数,便于单测精准 mock。
* - 任意端、任意 API 抛错 → 一律降级为 `''`**绝不**抛出。
*
* 字段语义提示:
* - `mpn`:兼容字段;各端「原生包名或小程序 appid」的统一口径与文档 `mpn` 对齐)。
* - `tdaid`:第三方平台 appid如微信小程序 appid
* - `pkn`:原生包名 / bundleIdApp小程序无独立包名时为空串**不与** tdaid 混填。
* - `an`应用展示名App = plus.runtime.appname小程序/H5 = `process.env.UNI_APP_NAME` 等)。
*/
let cached$1 = null;
function getUni$3() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
function getPlus() {
return getGlobalObject().plus;
}
/**
* 取小程序系列的 tdaid。各端 API 不同:
* - 微信/QQ`uni.getAccountInfoSync().miniProgram.appId`(基础库 ≥ 1.10.0)。
* - 支付宝:`my.getAppIdSync()`(部分版本可用)。
* - 头条/飞书:`tt.getEnvInfoSync().microapp.appId`。
* - 百度:`swan.getEnvInfoSync().common.appKey` 兜底。
* - 其他端:暂时返回 '';后续真机探测后再补。
*
* 任何分支抛错都返回 ''。
*/
function getMpTdaid(platform) {
const u = getUni$3();
switch (platform) {
case 'wx':
case 'qq': {
if (typeof (u === null || u === void 0 ? void 0 : u.getAccountInfoSync) === 'function') {
const id = tryRun(() => { var _a, _b; return (_b = (_a = u.getAccountInfoSync().miniProgram) === null || _a === void 0 ? void 0 : _a.appId) !== null && _b !== void 0 ? _b : ''; }, '');
if (id)
return id;
}
const wxHost = getGlobalObject().wx;
if (typeof (wxHost === null || wxHost === void 0 ? void 0 : wxHost.getAccountInfoSync) === 'function') {
const id2 = tryRun(() => { var _a, _b; return (_b = (_a = wxHost.getAccountInfoSync().miniProgram) === null || _a === void 0 ? void 0 : _a.appId) !== null && _b !== void 0 ? _b : ''; }, '');
if (id2)
return id2;
}
const envId = process.env.UNI_APP_ID;
return typeof envId === 'string' ? envId : '';
}
case 'ali':
case 'dt': {
const my = getGlobalObject().my;
if (!my)
return '';
const v1 = tryRun(() => { var _a, _b; return (_b = (_a = my.getAppIdSync) === null || _a === void 0 ? void 0 : _a.call(my)) !== null && _b !== void 0 ? _b : ''; }, '');
if (v1)
return v1;
return tryRun(() => { var _a, _b, _c; return (_c = (_b = (_a = my.getAccountInfoSync) === null || _a === void 0 ? void 0 : _a.call(my).miniProgram) === null || _b === void 0 ? void 0 : _b.appId) !== null && _c !== void 0 ? _c : ''; }, '');
}
case 'tt':
case 'lark': {
const tt = getGlobalObject().tt;
return tryRun(() => { var _a, _b, _c; return (_c = (_b = (_a = tt === null || tt === void 0 ? void 0 : tt.getEnvInfoSync) === null || _a === void 0 ? void 0 : _a.call(tt).microapp) === null || _b === void 0 ? void 0 : _b.appId) !== null && _c !== void 0 ? _c : ''; }, '');
}
case 'bd': {
const swan = getGlobalObject().swan;
return tryRun(() => { var _a, _b, _c; return (_c = (_b = (_a = swan === null || swan === void 0 ? void 0 : swan.getEnvInfoSync) === null || _a === void 0 ? void 0 : _a.call(swan).common) === null || _b === void 0 ? void 0 : _b.appKey) !== null && _c !== void 0 ? _c : ''; }, '');
}
default:
return '';
}
}
/**
* App 端 packageName / bundleId。
*
* Android 走 `plus.android.runtimeMainActivity().getPackageName()`
* iOS 走 `plus.ios.bundleId`,缺失时退化 `plus.runtime.appid`
* HarmonyOS 暂时取 `plus.runtime.appid` 兜底(待 OS API 稳定后扩展)。
*/
function getAppPkn() {
var _a, _b, _c;
const plus = getPlus();
if (!plus)
return '';
const osName = (_c = (_b = (_a = plus.os) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.toLowerCase()) !== null && _c !== void 0 ? _c : '';
if (osName.includes('android')) {
return tryRun(() => { var _a, _b, _c, _d, _e; return (_e = (_d = (_c = (_b = (_a = plus.android) === null || _a === void 0 ? void 0 : _a.runtimeMainActivity) === null || _b === void 0 ? void 0 : _b.call(_a)) === null || _c === void 0 ? void 0 : _c.getPackageName) === null || _d === void 0 ? void 0 : _d.call(_c)) !== null && _e !== void 0 ? _e : ''; }, '');
}
if (osName === 'ios' || osName === 'iphone os') {
const v = tryRun(() => { var _a, _b; return (_b = (_a = plus.ios) === null || _a === void 0 ? void 0 : _a.bundleId) !== null && _b !== void 0 ? _b : ''; }, '');
return v || tryRun(() => { var _a, _b; return (_b = (_a = plus.runtime) === null || _a === void 0 ? void 0 : _a.appid) !== null && _b !== void 0 ? _b : ''; }, '');
}
return tryRun(() => { var _a, _b; return (_b = (_a = plus.runtime) === null || _a === void 0 ? void 0 : _a.appid) !== null && _b !== void 0 ? _b : ''; }, '');
}
/**
* 取 plus.runtime.appname / plus.runtime.name。
*
* 旧版本 plus 上字段名不一致,两个都试一次。
*/
function getAppName() {
const plus = getPlus();
if (!plus)
return '';
return (tryRun(() => { var _a, _b; return (_b = (_a = plus.runtime) === null || _a === void 0 ? void 0 : _a.appname) !== null && _b !== void 0 ? _b : ''; }, '') ||
tryRun(() => { var _a, _b; return (_b = (_a = plus.runtime) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : ''; }, ''));
}
/**
* 取编译期注入的 UNI_APP_NAME。
*
* `plugin/index.ts` 后续会读取 `manifest.json#name` 注入此字段;当前若未注入返回 ''。
*/
function getEnvAppName() {
var _a;
return (_a = process.env.UNI_APP_NAME) !== null && _a !== void 0 ? _a : '';
}
/**
* 取 H5 端应用名:优先编译期注入,回退 `document.title`。
*/
function getH5AppName() {
const env = getEnvAppName();
if (env)
return env;
return tryRun(() => {
var _a, _b;
return (_b = (_a = getGlobalObject().document) === null || _a === void 0 ? void 0 : _a.title) !== null && _b !== void 0 ? _b : '';
}, '');
}
/**
* 启动时获取一次包信息;结果缓存于内存。
*
* 所有字段保证返回 `string`;缺失统一为 `''`,符合 `domain/statData.ts` 的字段处理约定。
*/
function getPackageInfo() {
if (cached$1)
return cached$1;
const platform = getPlatform();
let mpn = '';
let tdaid = '';
let pkn = '';
let an = '';
if (isApp()) {
tdaid = tryRun(() => { var _a, _b, _c; return (_c = (_b = (_a = getPlus()) === null || _a === void 0 ? void 0 : _a.runtime) === null || _b === void 0 ? void 0 : _b.appid) !== null && _c !== void 0 ? _c : ''; }, '');
pkn = getAppPkn() || tdaid;
an = getAppName() || getEnvAppName();
mpn = pkn || tdaid;
}
else if (isMp()) {
tdaid = getMpTdaid(platform);
pkn = '';
an = getEnvAppName();
mpn =
tdaid ||
(typeof process.env.UNI_APP_ID === 'string' ? process.env.UNI_APP_ID : '');
}
else if (isH5()) {
tdaid = '';
pkn = '';
an = getH5AppName();
mpn = '';
}
else {
// unknown / 快应用等:尝试 env 注入即可
tdaid = '';
pkn = '';
an = getEnvAppName();
mpn = '';
}
cached$1 = { mpn, tdaid, pkn, an };
return cached$1;
}
/**
* H5 / Web 平台适配。
*
* 职责:采集仅 Web 端有意义的上行字段原料(如含协议的页面域名 `domain`)。
* 非 H5 或运行时无 `window.location` 时一律返回空串,不抛错。
*/
const EMPTY_WEB_INFO = { domain: '' };
let cached = null;
/**
* 从 `location` 解析上行 `domain``https://host` / `http://host` 形式)。
*
* 仅 `http:` / `https:` 协议有效;`file:` 等返回空串。
*/
function readWebDomainFromLocation(loc) {
const protocol = typeof loc.protocol === 'string' ? loc.protocol.toLowerCase() : '';
if (protocol !== 'http:' && protocol !== 'https:')
return '';
if (typeof loc.origin === 'string' && loc.origin.trim()) {
return loc.origin.trim();
}
const host = typeof loc.host === 'string' && loc.host.trim()
? loc.host.trim()
: typeof loc.hostname === 'string'
? loc.hostname.trim()
: '';
if (!host)
return '';
return `${protocol}//${host}`;
}
/**
* 读取 H5 页面 Web 信息。
*
* 非 H5、SSR 或无 `location` 时 `domain` 为空串。
* 结果在进程内缓存SPA 内 origin 通常不变)。
*/
function getWebInfo() {
if (!isH5())
return EMPTY_WEB_INFO;
if (cached !== null)
return cached;
cached = tryRun(() => {
const win = getGlobalObject();
const loc = win.location;
if (!loc)
return EMPTY_WEB_INFO;
return { domain: readWebDomainFromLocation(loc) };
}, EMPTY_WEB_INFO);
return cached;
}
const registry = new Map();
/**
* 本模块每个 api 最近一次装入 uni 的 fanout 引用。
* 用于重装 / 解绑时按引用精准 `removeInterceptor(api, prevFanout)`,不波及第三方拦截器。
*/
const installedFanout = new Map();
/**
* 注册一个拦截器。同一 api 重复注册会去重,并自动按当前注册集合重装到 uni。
*
* @returns 解绑函数。调用后从集合中移除本次的 handlers并按剩余集合重新装配。
*/
function add(api, handlers) {
var _a;
const set = (_a = registry.get(api)) !== null && _a !== void 0 ? _a : new Set();
set.add(handlers);
registry.set(api, set);
reinstall(api);
return () => {
const cur = registry.get(api);
if (!cur)
return;
cur.delete(handlers);
if (cur.size === 0) {
registry.delete(api);
const prev = installedFanout.get(api);
installedFanout.delete(api);
if (prev) {
try {
// 精准移除本模块的 fanout保留第三方在同一 api 上的拦截器。
getUni$2().removeInterceptor(api, prev);
}
catch (_a) {
// 即使解绑失败也应保证下次重装时不带本次 handlers
}
}
}
else {
reinstall(api);
}
};
}
/**
* 把某个 api 的全部 handlers 合并成单个 fanout 拦截器。
*
* 闭包持有 `set` 引用registry 内的同一 Set故 fanout 会实时反映集合的增删。
*/
function buildFanout(set) {
return {
invoke(args) {
let blocked = false;
for (const h of set) {
if (!h.invoke)
continue;
const r = h.invoke(args);
if (r === false)
blocked = true;
}
return blocked ? false : undefined;
},
success(res) {
var _a;
for (const h of set)
(_a = h.success) === null || _a === void 0 ? void 0 : _a.call(h, res);
},
fail(err) {
var _a;
for (const h of set)
(_a = h.fail) === null || _a === void 0 ? void 0 : _a.call(h, err);
},
complete(res) {
var _a;
for (const h of set)
(_a = h.complete) === null || _a === void 0 ? void 0 : _a.call(h, res);
},
returnValue(res) {
let v = res;
for (const h of set) {
if (!h.returnValue)
continue;
v = h.returnValue(v);
}
return v;
},
};
}
/**
* 把 registry 中某个 api 的全部 handlers 合并成一个 fanout 拦截器,重新挂到 uni。
*
* 精准重装:先按引用移除本模块**上一次**装入的 fanout若有再装入新 fanout
* 全程不调用「不带第二参数」的 blanket remove故业务方 / 其它插件在同一 api 上的
* 拦截器不会被波及。
*/
function reinstall(api) {
const set = registry.get(api);
if (!set || set.size === 0)
return;
const fanout = buildFanout(set);
try {
const uni = getUni$2();
const prev = installedFanout.get(api);
if (prev) {
try {
uni.removeInterceptor(api, prev);
}
catch (_a) {
/* ignore旧 fanout 移除失败不阻断新 fanout 装入 */
}
}
uni.addInterceptor(api, fanout);
installedFanout.set(api, fanout);
}
catch (_b) {
// uni 不可用(例如 nvue 早期阶段):保留 registry 与 installedFanout等下次 reinstall 再试
}
}
function getUni$2() {
const raw = resolveUniRuntime();
const u = raw != null && typeof raw === 'object'
? raw
: undefined;
if (!u)
throw new Error('[uni统计 2.0] uni interceptor API is not available');
return u;
}
/**
* 仅供单测使用:清空 registry让本模块「像刚加载」一样。
*/
function __reset() {
registry.clear();
installedFanout.clear();
}
const interceptor = { add, __reset };
/**
* 拦截 `uni.login` 调用complete 时上报一条 `lt=21, e_n=login` 自定义事件。
*
* 与私有版差异:
* - 走 `infra/interceptor.add`,多次 register 不会覆盖回调(修复缺陷 #26
* - 通过 `reporter` 注入,便于单测断言。
*/
/**
* 注册 login 拦截器。
*
* @returns 解绑函数。同一 reporter 多次 register 视为多次回调fanout卸载时只摘当次。
*/
function registerLoginInterceptor(reporter) {
return interceptor.add('login', {
complete() {
reporter.report({ lt: LT.Event, custom: { e_n: 'login' } });
},
});
}
/**
* 拦截 `uni.setNavigationBarTitle`,把用户设置的标题写入 `domain/title` 内存。
*
* **不**直接 reporter.reporttitle 是字段维度的状态,由 statData.builder 在拼装
* 页面事件时一次性读出。这样保证 ttn 与 lt=11 / lt=3 事件强相关,避免私有版"标题在
* 全局对象、上报时机散落"的问题。
*/
/**
* 注册 setNavigationBarTitle 拦截器;不依赖 reporter。
*
* @returns 解绑函数。
*/
function registerNavigationBarInterceptor() {
return interceptor.add('setNavigationBarTitle', {
invoke(args) {
const a = args;
if (a && 'title' in a)
setPageTitle(a.title);
},
});
}
/**
* 拦截 `uni.requestPayment`
* - success → `lt=21, e_n=pay_success`
* - fail → `lt=21, e_n=pay_fail`
*
* 与私有版差异:经由 `infra/interceptor.add` 去重;多 reporter 注册都会触发fanout
*/
function registerPaymentInterceptor(reporter) {
return interceptor.add('requestPayment', {
success() {
reporter.report({ lt: LT.Event, custom: { e_n: 'pay_success' } });
},
fail() {
reporter.report({ lt: LT.Event, custom: { e_n: 'pay_fail' } });
},
});
}
/**
* 拦截 `uni.share` 调用success / fail 都上报一条 `lt=21, e_n=share` 自定义事件。
*
* 与私有版差异(修复缺陷 #26
* - 私有版 `interceptShare(true)` 在 `onLoad` 内重复 wrap `onShareAppMessage`
* 连续打开同一页面会导致 share 事件被多次上报。
* - 公有版通过 `infra/interceptor` 单次 fanout 注册onLoad 不再重复包装。
*/
function registerShareInterceptor(reporter) {
const fire = () => reporter.report({ lt: LT.Event, custom: { e_n: 'share' } });
return interceptor.add('share', {
success() {
fire();
},
fail() {
fire();
},
});
}
/**
* 拦截器统一装配入口。
*
* 使用:
* ```ts
* import { installAllInterceptors } from './interceptors'
* const uninstall = installAllInterceptors(collector) // collector 实现 InterceptorReporter
* // 卸载hot reload / unit test
* uninstall()
* ```
*
* 重复 install 安全:每次 install 都会返回独立的 unbinder多次 install 触发的 fanout
* 由 `infra/interceptor` 统一去重 + 解绑。
*/
/**
* 一次性装配全部拦截器。
*
* @returns 解绑函数(顺序解绑全部已注册的拦截器)。
*/
function installAllInterceptors(reporter) {
const unbinders = [
registerLoginInterceptor(reporter),
registerShareInterceptor(reporter),
registerPaymentInterceptor(reporter),
registerNavigationBarInterceptor(),
];
return () => {
for (const u of unbinders) {
try {
u();
}
catch (_a) {
// 单个解绑失败不影响其余
}
}
};
}
/**
* 老版本(私有版 1.0/2.0)→ 公有版数据迁移。
*
* 老版本通过 `utils/db.js` 把所有字段写到一个聚合 key
* `$$STAT__DBDATA:<appid>` → `{ '__first__visit__time': T, '__last__visit__time': T, ... }`
*
* 公有版改为按字段拆 key`UNI_STAT_DATA:<appid>:<key>`)。
*
* 本模块职责(只读老数据,不删):
* 1. **一次性**把已知字段从老聚合 key 拆解写入新命名空间。
* 2. 保留老 key不 remove让私有版同库共存场景仍能正常运行。
* 3. 通过新命名空间下的 `migration:done` 哨兵避免重复执行。
* 4. 任何步骤异常 → 静默吞掉,不影响采集主链路。
*
* 不做的事:
* - 不在迁移中写"今天本次启动"的 fvts/lvts那是 `domain/visit/firstVisit` 的职责。
* - 不抛错;调用方无需 try/catch。
*
* 调用时机:
* - 由 `runtime/install.ts` 在公有版启动早期调用一次(在 `loadVisitSnapshot` 之前),
* 保证 firstVisit 读到的是已迁移的新前缀数据。
*/
/** 已迁移哨兵 key写到新命名空间。值固定为 1。 */
const KEY_DONE = 'migration:done';
/**
* 老聚合 key 内字段 → 新拆分 key 的映射表。
*
* 仅迁移**对公有版有用**的字段;其它(如 `__page__residence__time`)保留老 key
* 由 Phase 5 的对应 domain 模块按需读取。
*/
const KEY_MAP = [
['__first__visit__time', 'visit:fvts'],
['__last__visit__time', 'visit:lvts'],
['__total__visit__count', 'visit:tvc'],
];
/** 取 UNI_APP_ID与 storage 内部保持一致的回退)。 */
function getAppId() {
const id = process.env.UNI_APP_ID;
if (typeof id === 'string' && id.length > 0)
return id;
return 'default';
}
/**
* 从底层 uni 读取老聚合 key不走 `infra/storage`,避免命名空间被改写)。
*
* 任何异常一律返回 `null`,由调用方决定 noop。
*/
function readLegacyAggregate() {
const u = resolveUniRuntime();
if (!u || typeof u.getStorageSync !== 'function')
return null;
const key = `${LEGACY_NAMESPACE_ROOT}:${getAppId()}`;
const raw = tryRun(() => u.getStorageSync(key), null);
if (raw && typeof raw === 'object')
return raw;
return null;
}
/** 哨兵:本进程内不重复 run。 */
let ran = false;
/**
* 执行迁移;幂等:
* - 进程内已 run → 直接 return false。
* - 新命名空间已有 `migration:done` → 直接 return false。
* - 老聚合 key 不存在 / 为空 → 写 `migration:done`return false。
* - 真正发生迁移 → return true。
*/
function migrateLegacyData() {
if (ran)
return false;
ran = true;
const doneR = storage.safeRead(KEY_DONE);
if (doneR.ok && doneR.value)
return false;
const legacy = readLegacyAggregate();
if (!legacy) {
storage.set(KEY_DONE, 1);
return false;
}
let migrated = 0;
for (let i = 0; i < KEY_MAP.length; i++) {
const [oldKey, newKey] = KEY_MAP[i];
if (!(oldKey in legacy))
continue;
const value = legacy[oldKey];
// 已经存在新值就不覆盖(避免覆盖公有版自身已写入的更新值)
const existing = storage.safeRead(newKey);
if (existing.ok && existing.value !== undefined)
continue;
storage.set(newKey, value);
migrated++;
}
storage.set(KEY_DONE, 1);
if (migrated > 0) {
logger.info('[uni统计 2.0] migrated legacy keys', migrated);
}
return migrated > 0;
}
/**
* 通道选择器:根据**统计版本**与**运行环境**返回最合适的 Channel。
*
* 选择规则(公有版默认 image
* - `version === 'image'`(默认):优先 imageimage 不可用时按 fallback 决策走 http
* image 通道未注入 → 静默走 http公有版默认场景
* - `version === '2'`:优先 cloudcloud 不可用时按 fallback 决策走 http私有版兼容
* - `version === '1'`:始终 http。
*
* 注意:
* - 公有版**不会**主动构造 cloud channelStatApp 仅在 version='2' 才创建),
* 因此默认运行路径不会再触发"cloud channel unavailable"警告。
* - 选择是**幂等无副作用**的:调用方每次发送前调用 `selectChannel()` 即可,
* channel 自身不缓存可用性。
*/
/**
* 根据策略挑选当前应使用的 channel。
*
* @returns 选中的 channel若没有可用通道返回 `undefined`。
*/
function selectChannel(opts) {
var _a;
const version = (_a = opts.version) !== null && _a !== void 0 ? _a : 'image';
const fallback = opts.fallbackToHttp !== false;
if (version === '1') {
if (opts.http && opts.http.available())
return opts.http;
return undefined;
}
if (version === '2') {
if (opts.cloud && opts.cloud.available())
return opts.cloud;
if (!fallback) {
logger.warn('[uni统计 2.0] 云函数上报不可用且已关闭 HTTP 兜底,本批已丢弃');
return undefined;
}
if (opts.http && opts.http.available()) {
logger.warn('[uni统计 2.0] 云函数上报不可用,已降级为 HTTP 上报');
return opts.http;
}
logger.warn('[uni统计 2.0] 无可用上报线路');
return undefined;
}
// image默认image > http
if (opts.image && opts.image.available())
return opts.image;
if (!fallback) {
if (opts.image) {
// 仅在 image 已构造但失效时给出警告,便于排查;未构造视为正常的"未启用"
logger.warn('[uni统计 2.0] 统计上报线路不可用且已关闭 HTTP 兜底,本批已丢弃');
}
return undefined;
}
if (opts.http && opts.http.available()) {
if (opts.image) {
// 同上,仅在 image 已构造但失效时打印降级日志
logger.warn('[uni统计 2.0] 统计上报线路不可用,已降级为 HTTP 上报');
}
return opts.http;
}
logger.warn('[uni统计 2.0] 无可用上报线路');
return undefined;
}
/**
* 网络类型适配。
*
* 公有版要求(`03-公有版架构设计.md` §5.9
* - 返回 `Promise<NetResult>`,失败一律给默认值,**绝不**中断上报链路(修复缺陷 #10
* - 提供 `onChange` 订阅,便于会话上下文里"网络切换 → 触发一次 sessionDirty"。
*
* 字段映射:上行字段 `net`,与私有版字段保持一致;可选值:
* `wifi | 2g | 3g | 4g | 5g | ethernet | unknown | none`。
*/
const DEFAULT_RESULT = { net: 'unknown', raw: '' };
/** 已知映射;命中外的统一归 unknown。 */
const NET_MAP = {
wifi: 'wifi',
'2g': '2g',
'3g': '3g',
'4g': '4g',
'5g': '5g',
ethernet: 'ethernet',
none: 'none',
unknown: 'unknown',
};
function getUni$1() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/**
* 把 uni 原始 networkType 字符串归一化为 `NetType`。
*
* 抽出独立函数,便于上层直接复用(例如 `onChange` 回调里也要做归一)。
*/
function normalizeNet(raw) {
var _a;
if (typeof raw !== 'string' || raw.length === 0)
return 'unknown';
return (_a = NET_MAP[raw.toLowerCase()]) !== null && _a !== void 0 ? _a : 'unknown';
}
/**
* 异步取当前网络类型。
*
* 一律 resolve永不 reject
* - uni 缺失 / 调用抛错 / fail 回调 / 超时 → resolve(`{ net: 'unknown', raw: '' }`)。
* - 默认 1500ms 超时;防止 H5 端 navigator.connection 卡住采集链路。
*/
function getNet(timeoutMs = 1500) {
return new Promise((resolve) => {
const u = getUni$1();
if (!u || typeof u.getNetworkType !== 'function') {
resolve(DEFAULT_RESULT);
return;
}
let settled = false;
const finish = (r) => {
if (settled)
return;
settled = true;
resolve(r);
};
const timer = setTimeout(() => finish(DEFAULT_RESULT), timeoutMs);
tryRun(() => u.getNetworkType({
success: (res) => {
var _a;
clearTimeout(timer);
const raw = (_a = res === null || res === void 0 ? void 0 : res.networkType) !== null && _a !== void 0 ? _a : '';
finish({ net: normalizeNet(raw), raw });
},
fail: () => {
clearTimeout(timer);
finish(DEFAULT_RESULT);
},
}), undefined);
});
}
/**
* 订阅网络变化。返回 unsubscribe 函数,方便测试与运行时解绑。
*
* 注意:私有版直接调 `uni.onNetworkStatusChange` 但没有解绑,导致 nvue 多次 install 时
* 会注册多个回调(缺陷 #16。公有版返回 unsubscribe 强制调用方持有;
* `runtime/install.ts` 会保证 install 多次时复用单实例。
*/
function onChange(cb) {
const u = getUni$1();
if (!u || typeof u.onNetworkStatusChange !== 'function') {
return () => {
/* noop */
};
}
const wrapped = (res) => {
var _a;
const raw = (_a = res === null || res === void 0 ? void 0 : res.networkType) !== null && _a !== void 0 ? _a : '';
const net = (res === null || res === void 0 ? void 0 : res.isConnected) === false ? 'none' : normalizeNet(raw);
tryRun(() => cb({ net, raw }), undefined);
};
// 注册本身也兜底:个别端 onNetworkStatusChange 同步抛错不应冒泡到调用方(与解绑对称)。
tryRun(() => u.onNetworkStatusChange(wrapped), undefined);
return () => {
if (typeof u.offNetworkStatusChange === 'function') {
tryRun(() => u.offNetworkStatusChange(wrapped), undefined);
}
};
}
/**
* 公有版网络门闸(仅 `src/public` 使用,不与私有版 core 共用)。
*
* 能力:
* 1. `isNetworkOffline()`:基于 `uni.getNetworkType` 判断当前是否无网;
* 2. `onNetworkOnline(cb)`:基于 `uni.onNetworkStatusChange`,在恢复有网时回调。
*
* 判定:`net === 'none'` 为无网;`unknown` **不**当无网(避免误挂起)。
*/
/**
* 根据 NetResult 判断是否无网。
*/
function isOfflineNetResult(r) {
return r.net === 'none';
}
/**
* 先检查当前网络是否不可用。
*/
function isNetworkOffline() {
return __awaiter(this, void 0, void 0, function* () {
const r = yield getNet();
return isOfflineNetResult(r);
});
}
/**
* 监听网络恢复为可用。返回 unsubscribe。
*
* @param cb 变为有网时触发wifi/4g/...);无网变化不触发。
*/
function onNetworkOnline(cb) {
return onChange((r) => {
if (isOfflineNetResult(r))
return;
cb();
});
}
/**
* 事件入队 + 批量 flush修复私有版缺陷 #3。
*
* 私有版 `report.js#request` 的入队逻辑:
* ```
* uniStatData = dbGet(KEY) || {}
* uniStatData[lt].push(data)
* dbSet(KEY, uniStatData) // <-- 写
* ...
* const stat_data = handle_data(uniStatData)
* dbRemove(KEY) // <-- 删,但不是原子的
* sendRequest(...)
* ```
*
* 缺陷 #3在 `dbSet` 与 `dbRemove` 之间,若有并发的 `request()` 调用执行
* `dbGet → push → dbSet`,最后的 `dbRemove` 会**误删**这一批新数据。
*
* 公有版修复策略:
* - 入队全部走"内存桶 + 持久化镜像"双写;持久化只为冷启续传准备。
* - flush() 走一次"原子 swap":把当前桶交换给空对象,立刻清持久化镜像;
* 在 swap 之后插入的新事件落到新桶,绝不被 flush 误删。
* - flush() 仅返回快照,**不直接发送**:发送由 collector 负责,便于解耦
* 单测与运行时collector 不需要 mock 通道)。
*
* 数据形态:
* - bucket: `Record<lt, StatData[]>`,与私有版 `uniStatData` 兼容。
* - 持久化 key`UNI_STAT_DATA:<appid>:queue`。
*/
const STORAGE_KEY$1 = 'queue';
const DEFAULT_SINGLE_EVENT_MAX_BYTES = SINGLE_EVENT_MAX_BYTES;
const state = {
bucket: {},
lastFlushAt: 0,
};
let intervalSec = REPORT_INTERVAL_SEC;
let singleEventMaxBytes = DEFAULT_SINGLE_EVENT_MAX_BYTES;
let maxEvents = QUEUE_MAX_EVENTS;
let restored = false;
/** 容量超限 warn 节流:持续离线积压时仅首次告警,回落到上限内后复位。 */
let capacityWarned = false;
/**
* 配置上报间隔;运行时可在 runtime/StatApp 初始化时注入。
*/
function configure(opts) {
if (typeof opts.intervalSec === 'number' && opts.intervalSec >= 0) {
intervalSec = Math.floor(opts.intervalSec);
}
if (typeof opts.singleEventMaxBytes === 'number' &&
opts.singleEventMaxBytes > 0) {
singleEventMaxBytes = Math.floor(opts.singleEventMaxBytes);
}
if (typeof opts.maxEvents === 'number' && opts.maxEvents > 0) {
maxEvents = Math.floor(opts.maxEvents);
}
}
/**
* 强制把内存桶事件总数压到 `maxEvents` 以内FIFO 丢弃最旧)。
*
* 丢弃策略:每轮从**当前事件数最多的桶**头部移除一条(最旧),直到总数达标。
* 这样长期离线时疯涨的 lt=21/lt=31 会先被裁剪,体量通常很小的 lt=1会话锚点/
* lt=3后台闭合更可能被保留。仅在超限时打一次 warn避免刷屏。
*/
function enforceCapacity() {
let total = size();
if (total <= maxEvents) {
// 回落到上限内 → 复位告警节流,下次再超限时可再次提示。
capacityWarned = false;
return;
}
const dropped = total - maxEvents;
while (total > maxEvents) {
let largestLt = '';
let largestLen = 0;
for (const lt of Object.keys(state.bucket)) {
const len = state.bucket[lt].length;
if (len > largestLen) {
largestLen = len;
largestLt = lt;
}
}
if (!largestLt || largestLen === 0)
break;
state.bucket[largestLt].shift();
if (state.bucket[largestLt].length === 0)
delete state.bucket[largestLt];
total--;
}
// 节流:持续离线积压时每次 enqueue 都会触发裁剪,但仅首次告警,避免刷屏。
if (!capacityWarned) {
capacityWarned = true;
logger.warn('[uni统计 2.0] 上报队列超过容量上限,已丢弃最旧事件', 'dropped=' + dropped, 'limit=' + maxEvents);
}
}
/**
* 持久化当前内存桶。失败仅打日志,不影响主流程。
*/
function persistBucket() {
if (Object.keys(state.bucket).length === 0) {
storage.remove(STORAGE_KEY$1);
return;
}
try {
storage.set(STORAGE_KEY$1, state.bucket);
}
catch (e) {
logger.warn('[uni统计 2.0] queue persist failed', e);
}
}
/**
* 冷启时尝试从 storage 恢复上一次进程未上报的桶;只在第一次入队前执行一次。
*
* 若 storage 中存在合法的桶数据,与当前内存桶**合并**而不是覆盖(合并语义防止极端
* 边界场景下丢失冷启已入队的事件)。
*/
function restoreOnce() {
if (restored)
return;
restored = true;
const raw = storage.safeRead(STORAGE_KEY$1);
if (!raw.ok || !raw.value || typeof raw.value !== 'object')
return;
const persisted = raw.value;
for (const lt of Object.keys(persisted)) {
const arr = persisted[lt];
if (!Array.isArray(arr) || arr.length === 0)
continue;
if (!state.bucket[lt])
state.bucket[lt] = [];
state.bucket[lt].push(...arr);
}
}
/**
* 把一条事件入队到对应 lt 的桶。
*
* 不抛错data.lt 必填,缺失/类型异常时打日志丢弃。
*
* 单条体积保护:序列化后超过 `singleEventMaxBytes`(默认 2KB的事件直接丢弃 ——
* 进了桶最终一定打不出去(无论怎么切片都会顶满 GET URL还会污染 retry 队列。
* 典型源头:业务方在 `key/value` 里塞了 base64 图片 / 大段 JSON / 长 stack 等。
*/
function enqueue(data) {
var _a;
if (!data || typeof data !== 'object')
return;
const lt = String((_a = data.lt) !== null && _a !== void 0 ? _a : '');
if (!lt) {
logger.warn('[uni统计 2.0] enqueue dropped: missing lt', data);
return;
}
let serialized = '';
try {
serialized = JSON.stringify(data);
}
catch (e) {
logger.warn('[uni统计 2.0] enqueue dropped: stringify failed', e);
return;
}
if (serialized.length > singleEventMaxBytes) {
logger.warn('[uni统计 2.0] enqueue dropped: single event too large', 'lt=' + lt, 'bytes=' + serialized.length, 'limit=' + singleEventMaxBytes);
return;
}
restoreOnce();
if (!state.bucket[lt])
state.bucket[lt] = [];
state.bucket[lt].push(data);
enforceCapacity();
persistBucket();
}
/**
* 是否到达 flush 阈值。
*
* @param force 如为 true 直接返回 true用于 onAppHide / 错误兜底等场景)。
*/
function shouldFlush(force = false) {
if (force)
return true;
if (intervalSec <= 0)
return true;
const elapsedSec = (nowMs() - state.lastFlushAt) / 1000;
return elapsedSec >= intervalSec;
}
/**
* 原子取出当前桶并清空(修复缺陷 #3
*
* 调用时机:由 collector 决定(间隔触发 / app hide / 强制刷新)。
*
* @returns 取出的桶;若空桶返回 undefined调用方据此跳过本次发送
*/
function flush() {
restoreOnce();
const lts = Object.keys(state.bucket);
if (lts.length === 0)
return undefined;
const snapshot = state.bucket;
state.bucket = {};
state.lastFlushAt = nowMs();
storage.remove(STORAGE_KEY$1);
return snapshot;
}
/**
* 发送失败回滚:把 flush 取出的快照重新合并回当前桶,等待下一次 flush。
*
* 注意:合并时插入到桶的"前面",保留 FIFO 语义。
*/
function rollback(snapshot) {
if (!snapshot)
return;
for (const lt of Object.keys(snapshot)) {
const arr = snapshot[lt];
if (!Array.isArray(arr) || arr.length === 0)
continue;
if (!state.bucket[lt])
state.bucket[lt] = [];
state.bucket[lt] = arr.concat(state.bucket[lt]);
}
enforceCapacity();
persistBucket();
}
/**
* 当前桶内事件总数(按 lt 加总)。
*/
function size() {
let n = 0;
for (const lt of Object.keys(state.bucket)) {
n += state.bucket[lt].length;
}
return n;
}
/**
* 失败重试落盘队列。
*
* 设计动机:
* - 私有版仅在 1.0 通道内做了 3 次内存级重试;进程被杀(应用退出 / kill后所有
* 未上报数据**直接丢失**,且 2.0 通道根本没有重试。
* - 公有版引入"内存重试 + 失败落盘 + 下次冷启重放"双层兜底:
* 通道层:`channel.send` 内部已用 `withRetry` 做协议层重试。
* 本模块:协议层最终失败后调用 `persist(payload)` 写入 storage
* 冷启时调 `loadAll()` 取出,逐条尝试重放,成功后 `ack(_id)` 删除。
*
* 数据结构:
* `UNI_STAT_DATA:<appid>:retry:queue``Array<RetryItem>`,最多 `maxItems` 条;
* 超容时按 FIFO 丢弃最旧条目。每条带创建时间戳,超过 `maxAgeMs` 的过期清理。
*
* 与 retry 队列只存"已序列化的 ReportPayload"——不再依赖 collector / domain
* 由调用方负责重组业务字段(如重试时不需要再次重算 visit/session
*/
const STORAGE_KEY = 'retry:queue';
const DEFAULT_MAX_ITEMS = 50;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_MAX_ATTEMPTS = RETRY_MAX_ATTEMPTS;
const config = {
maxItems: DEFAULT_MAX_ITEMS,
maxAgeMs: DEFAULT_MAX_AGE_MS,
maxAttempts: DEFAULT_MAX_ATTEMPTS,
};
/**
* 读取队列。出现异常或非数组时返回空数组(不影响主流程)。
*/
function readQueue() {
const raw = storage.safeRead(STORAGE_KEY);
if (!raw.ok || !Array.isArray(raw.value))
return [];
return raw.value.filter((it) => it &&
typeof it.id === 'string' &&
it.payload &&
typeof it.payload === 'object');
}
/**
* 写回队列。空数组时直接 remove避免存储垃圾。
*/
function writeQueue(items) {
if (items.length === 0) {
storage.remove(STORAGE_KEY);
return;
}
storage.set(STORAGE_KEY, items);
}
/**
* 生成 retry item id。优先复用 payload._id来自 queue 出栈时分配的批次 id
*/
function genId(payload) {
if (payload._id)
return payload._id;
return ('r-' + nowMs().toString(36) + '-' + Math.random().toString(36).slice(2, 6));
}
/**
* 持久化一条失败 payload。
*
* @param payload 协议层最终失败的 payload。
* @returns 实际写入的 retry id若被丢弃返回 undefined。
*/
function persist(payload) {
if (!payload)
return undefined;
const id = genId(payload);
const items = readQueue();
if (items.some((it) => it.id === id)) {
return id;
}
const item = {
id,
payload: Object.assign({}, payload, { _id: id }),
createdAt: nowMs(),
attempts: 0,
};
items.push(item);
while (items.length > config.maxItems) {
const dropped = items.shift();
logger.warn('[uni统计 2.0] retry queue overflow, drop oldest', dropped === null || dropped === void 0 ? void 0 : dropped.id);
}
writeQueue(items);
return id;
}
/**
* 取出全部待重试条目(同时清理过期项),按入队顺序返回。
*
* 调用方应自行决定是否串行重放;本模块**不**自动触发任何网络。
*/
function loadAll() {
const items = readQueue();
if (items.length === 0)
return [];
const cutoff = nowMs() - config.maxAgeMs;
const alive = [];
for (const it of items) {
if (it.createdAt < cutoff) {
logger.warn('[uni统计 2.0] retry item expired, drop', it.id);
continue;
}
alive.push(it);
}
if (alive.length !== items.length)
writeQueue(alive);
return alive.map((it) => it.payload);
}
/**
* 重放成功后删除指定 idid 不存在视为 no-op。
*/
function ack(id) {
if (!id)
return;
const items = readQueue();
const next = items.filter((it) => it.id !== id);
if (next.length === items.length)
return;
writeQueue(next);
}
/**
* 标记一次重放失败:累加 `attempts`,超过 `config.maxAttempts` 自动死信清理。
*
* 死信清理动机:`recoverRetry` 串行重放,永久错误(脏 payload / 历史协议数据)若不
* 主动丢弃,会反复占据队列前部,把后续健康 payload 也拖到失败 —— 这是 image url too
* long 看似"重试无穷大"的次因。本兜底与"过期清理maxAgeMs+ 容量裁剪maxItems"
* 形成三道防线。
*/
function markAttempt(id) {
if (!id)
return;
const items = readQueue();
let nextItems = null;
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.id !== id)
continue;
it.attempts++;
if (it.attempts >= config.maxAttempts) {
logger.warn('[uni统计 2.0] retry item exceeded maxAttempts, drop as dead letter', id, 'attempts=' + it.attempts);
nextItems = items.slice(0, i).concat(items.slice(i + 1));
}
else {
nextItems = items;
}
break;
}
if (nextItems)
writeQueue(nextItems);
}
/**
* 公有版统计运行时门面(单例)。
*
* 职责:
* 1. `install(config?, overrides?)`:一次性装配 collector / channel / 拦截器;
* 启动时 `migrateLegacyData` → `loadVisitSnapshot` → `recoverRetry`。
* 重复 install 幂等。
* 2. `report(type, value)`:业务侧 `uni.report(type, value)` 的承接入口。
* 3. `reportError(e)`错误兜底事件lt=31
* 4. `getCollector()` / `getDeps()`:测试与 lifecycleHooks 复用。
*
* 设计原则:
* - 所有 adapter 调用都包了 `tryRun`,单端缺失 API 不影响 install。
* - 所有依赖通过 `defaults + overrides` 构造;测试可注入替换。
* - install 不抛错;任何子步骤失败都吞掉并 logger.warn。
* - 单例:`StatApp.getInstance()` 全局唯一;`__resetStatApp()` 仅供测试。
*/
let instance = null;
class StatApp {
constructor() {
/** install 幂等哨兵。 */
this.installed = false;
/** 已生效的协议版本('1' / '2' / 'image')。 */
this.statVersion = 'image';
}
static getInstance() {
if (!instance)
instance = new StatApp();
return instance;
}
/**
* 一次性装配。重复调用直接返回。
*
* @param config 业务配置;缺省值兼容私有版默认行为。
* @param overrides 测试钩子。
*/
install(config = {}, overrides = {}) {
var _a, _b, _c, _d, _e;
if (this.installed)
return;
const cfg = this.normalizeConfig(config);
this.config = cfg;
this.statVersion = cfg.version;
tryRun(() => configure$1({
backgroundTimeoutSec: cfg.backgroundTimeoutSec,
pageInactiveTimeoutSec: cfg.pageInactiveTimeoutSec,
}), undefined);
tryRun(() => configure({ intervalSec: cfg.reportIntervalSec }), undefined);
if (!overrides.skipMigration) {
tryRun(() => migrateLegacyData(), false);
}
tryRun(() => loadVisitSnapshot(), undefined);
this.httpChannel =
(_b = (_a = overrides.channels) === null || _a === void 0 ? void 0 : _a.http) !== null && _b !== void 0 ? _b : createHttpChannel({ ut: getPlatform(), maxRetries: HTTP_MAX_RETRIES });
// cloud仅在用户明确选择 channelVersion=2 或测试 override 时构造,
// 公有版默认路径不会创建 cloud避免触发"cloud channel unavailable"误降级警告。
if (overrides.channels && 'cloud' in overrides.channels) {
this.cloudChannel = (_c = overrides.channels.cloud) !== null && _c !== void 0 ? _c : undefined;
}
else if (this.statVersion === '2') {
this.cloudChannel = createCloudChannel({ maxRetries: CLOUD_MAX_RETRIES });
}
else {
this.cloudChannel = undefined;
}
// image公有版默认通道。host/projectId/topicId 来自 config.IMAGE_REPORT_DEFAULTS
// 由 SDK 维护者直接在源码中调整,**不暴露**到 manifest / runtime API
// 测试场景仍可通过 overrides.channels.image 注入伪通道做断言。
if (overrides.channels && 'image' in overrides.channels) {
this.imageChannel = (_d = overrides.channels.image) !== null && _d !== void 0 ? _d : undefined;
}
else if (this.statVersion === 'image') {
this.imageChannel = createImageChannel({
host: IMAGE_REPORT_DEFAULTS.host,
projectId: IMAGE_REPORT_DEFAULTS.projectId,
topicId: IMAGE_REPORT_DEFAULTS.topicId,
maxRetries: IMAGE_MAX_RETRIES,
ut: getPlatform(),
rawPlatform: getRawPlatform(),
});
}
else {
this.imageChannel = undefined;
}
this.collectorDeps = this.buildCollectorDeps(cfg, (_e = overrides.collectorDepsPatch) !== null && _e !== void 0 ? _e : {});
this.collector = createCollector(this.collectorDeps);
if (!overrides.skipInterceptors) {
const c = this.collector;
this.uninstallInterceptors = tryRun(() => installAllInterceptors({ report: (i) => c.report(i) }), undefined);
}
if (!overrides.skipRecoverRetry) {
void this.collector
.recoverRetry()
.catch((e) => logger.warn('[uni统计 2.0] recoverRetry failed', e));
}
// 公有版:监听网络恢复,立即续传 + 强制 flush与私有版 core 实现分离)
this.uninstallNetworkWatch = tryRun(() => onNetworkOnline(() => {
const c = this.collector;
if (!c)
return;
void c
.recoverRetry()
.catch((e) => logger.warn('[uni统计 2.0] recoverRetry on online failed', e));
void c
.flush(true)
.catch((e) => logger.warn('[uni统计 2.0] flush on online failed', e));
}), undefined);
// 仅在 collector 与拦截器等就绪后再标记,避免中途抛错导致「已 install 却无 collector」。
this.installed = true;
}
/**
* 业务侧 `uni.report(type, value)` 入口。
*
* 兼容私有版语义:
* - `type === 'title'` → 写 reportTitle不发事件下次 lt=11 / lt=3 携带 `ttc`。
* - 其他 type → 自定义事件 lt=21custom `{ e_n: type, e_v: value }`。
*/
report(type, value) {
if (!this.installed || !this.collector)
return;
if (type === 'title') {
setReportTitle(value);
return;
}
const ev = typeof value === 'object' && value !== null
? tryRun(() => JSON.stringify(value), '')
: value === undefined
? ''
: String(value);
this.collector.report({
lt: LT.Event,
custom: { e_n: type, e_v: ev },
});
}
/** 上报 onError 捕获的错误。 */
reportError(err) {
var _a;
if (!this.installed || !this.collector)
return;
const errMsg = err instanceof Error
? `${err.name}: ${err.message}\n${(_a = err.stack) !== null && _a !== void 0 ? _a : ''}`
: typeof err === 'string'
? err
: tryRun(() => JSON.stringify(err), '');
this.collector.report({ lt: LT.Error, errMsg });
}
/** 取 collector供 lifecycleHooks 调度生命周期事件。 */
getCollector() {
return this.collector;
}
/** 取 deps测试用。 */
getDeps() {
return this.collectorDeps;
}
/** 是否已 install。 */
isInstalled() {
return this.installed;
}
/** 当前协议版本。 */
getStatVersion() {
return this.statVersion;
}
/** 当前生效配置(含默认值合并),测试用。 */
getConfig() {
return this.config;
}
/**
* 卸载(测试 / hot reload
*
* 解绑全部拦截器、清空内部句柄。**不**清外部模块queue/visit/session状态
* 那些由各自的 `__reset*` 在测试 setup 中处理。
*/
uninstall() {
if (this.uninstallInterceptors) {
tryRun(() => this.uninstallInterceptors(), undefined);
}
this.uninstallInterceptors = undefined;
if (this.uninstallNetworkWatch) {
tryRun(() => this.uninstallNetworkWatch(), undefined);
}
this.uninstallNetworkWatch = undefined;
// 先释放 collector 内部定时器(取消延迟首 flush再丢弃引用避免幽灵 flush。
if (this.collector) {
tryRun(() => this.collector.destroy(), undefined);
}
this.collector = undefined;
this.collectorDeps = undefined;
this.httpChannel = undefined;
this.cloudChannel = undefined;
this.imageChannel = undefined;
this.config = undefined;
this.installed = false;
}
/**
* 解析上行渠道字段 `ch`。
*
* App 渠道包标识只能以原生运行时为准:`plus.runtime.channel`。
* `manifest.uniStatistics.ch` 是静态配置,不能区分同一项目打出的多渠道包。
* 非 App 端没有 `plus.runtime.channel` 语义,保留手动 install 传入 `ch` 的能力。
*/
resolveChannel(explicit) {
if (isApp()) {
return getAppChannel();
}
if (typeof explicit === 'string' && explicit.length > 0) {
return explicit;
}
return '';
}
resolveFirstFlushDeferMs() {
if (getRawPlatform() === 'mp-weixin' &&
MP_WEIXIN_USE_PRELOAD_ASSETS_REPORT) {
return MP_WEIXIN_PRELOAD_FIRST_FLUSH_DELAY_MS;
}
if (isApp() && !getAppChannel()) {
return APP_CHANNEL_FIRST_FLUSH_DELAY_MS;
}
return 0;
}
normalizeConfig(c) {
var _a, _b, _c, _d;
return {
ak: (_a = c.ak) !== null && _a !== void 0 ? _a : getAppId$1(),
v: c.v,
ch: this.resolveChannel(c.ch),
version: (_b = c.version) !== null && _b !== void 0 ? _b : 'image',
backgroundTimeoutSec: (_c = c.backgroundTimeoutSec) !== null && _c !== void 0 ? _c : 300,
pageInactiveTimeoutSec: (_d = c.pageInactiveTimeoutSec) !== null && _d !== void 0 ? _d : 1800,
reportIntervalSec: typeof c.reportIntervalSec === 'number'
? c.reportIntervalSec
: REPORT_INTERVAL_SEC,
// collectItems 默认值与私有版严格对齐push 默认关闭、页面日志默认开启
enablePush: c.enablePush === true,
enablePageLog: c.enablePageLog !== false,
};
}
/**
* 构建 collector 依赖。所有 adapter 调用都包了 `tryRun`,避免单端缺失 API 导致
* install 失败。
*/
buildCollectorDeps(cfg, patch) {
const platformShort = getPlatform();
const builder = createStatDataBuilder({
config: {
ak: cfg.ak,
usv: STAT_VERSION_PUBLIC,
v: cfg.v,
get ch() {
return isApp() ? getAppChannel() : cfg.ch;
},
},
platform: {
ut: platformShort,
},
system: tryRun(() => getSystemInfo(), {
brand: '',
md: '',
sv: '',
v: '',
ut: 'unknown',
appVersion: '',
appWgtVersion: '',
mpvHostVersion: '',
on: '',
sdkVersion: '',
statusBarHeight: 0,
osP: '',
}),
locale: tryRun(() => getLocaleAndScreen(), {
lang: '',
ww: 0,
wh: 0,
sw: 0,
sh: 0,
pr: 1,
}),
device: {
// 惰性解析:每次 build 时再调 getUuid(),避免 install 过早uni 运行时未就绪)冻结临时值。
get uuid() {
return tryRun(() => getUuid(), '');
},
},
net: { net: 'unknown', raw: '' },
location: { lat: '', lng: '', ok: false },
pkg: tryRun(() => getPackageInfo(), {
mpn: '',
tdaid: '',
pkn: '',
an: '',
}),
web: tryRun(() => getWebInfo(), { domain: '' }),
});
const base = {
builder,
queue: {
enqueue: enqueue,
flush: flush,
rollback: rollback,
shouldFlush: shouldFlush,
},
serializer: { handleData },
selectChannel: () => selectChannel({
version: this.statVersion,
http: this.httpChannel,
cloud: this.cloudChannel,
image: this.imageChannel,
}),
retry: {
persist: persist,
loadAll: loadAll,
ack: ack,
markAttempt: markAttempt,
},
visit: {
commitVisitOnAck: commitVisitOnAck,
rollbackPendingVisit: rollbackPendingVisit,
},
session: {
getSnapshot: getSnapshot,
nextSeq: nextSeq,
touch: touch,
},
config: { usv: STAT_VERSION_PUBLIC },
resolveUploadFields: () => {
const ch = getAppChannel();
return ch ? { ch } : {};
},
nowMs,
nowSec,
firstFlushDeferMs: this.resolveFirstFlushDeferMs(),
isNetworkOffline,
};
return Object.assign(base, patch);
}
}
/**
* 便捷 API获取或创建当前应用 collector供拦截器 / lifecycleHooks 使用。
*
* 上层若希望直接拿 lt 入参,可先 `getStatApp().install(cfg)`,再
* `getStatApp().getCollector()?.report({ lt, ... })`。
*/
function getStatApp() {
return StatApp.getInstance();
}
/** 仅供测试:销毁全局单例与 install 状态(不会重置 queue/visit/session。 */
function __resetStatApp() {
if (instance) {
instance.uninstall();
instance = null;
}
}
/**
* 公有版统计运行时安装入口。
*
* 与私有版 `src/index.js#load_stat` 等价:
* - VUE3 走 `uni.onCreateVueApp(app => app.mixin(lifecycle))`。
* - VUE2 走 `Vue.mixin(lifecycle)`require('vue'))。
* - 同时把 `uni.report = (type, value) => StatApp.report(type, value)` 暴露给业务。
*
* 与私有版差异:
* - 模块加载即调 `installPublicStat()`,但内部用 install 哨兵保证幂等;
* 测试可调 `__resetStatApp()` 重置。
* - `is_debug / NODE_ENV === 'development'` 的开关由调用方在 build 阶段做(
* `plugin/index.ts` 已注入),运行时不再分支。
*
* 暴露:
* - `installPublicStat(config?, opts?)`:手动触发;幂等。
* - `getMixin()`:返回 vue mixin 对象,供宿主自行 `app.mixin(...)`。
*/
/**
* 从 `process.env.UNI_STATISTICS_CONFIG`plugin 注入的 manifest.uniStatistics 序列化串)
* 读取业务配置,把已知行为字段映射为 StatApp.install 的 partial config。
*
* ## 字段命名严格对齐私有版
*
* uni-app 私有版(`src/utils/pageInfo.js`)历史已对外暴露的 manifest 字段:
*
* | manifest 字段 | 类型 | 默认值 | 私有版语义 |
* | -------------------------------------- | -------- | ------ | -------------------------------- |
* | `enable` | Boolean | false | 总开关,由 plugin 处理 |
* | `version` | String | "1" | "1" / "2" / "3"(公有版新增) |
* | `debug` | Boolean | false | logger.debug 开关 |
* | `reportInterval` | Number | 10 | 上报间隔秒数0 = 立即上报 |
* | `collectItems.uniPushClientID` | Boolean | false | 是否采集 push ClientIDlt=101 |
* | `collectItems.uniStatPageLog` | Boolean | true | 是否上报页面日志lt=11 |
*
* 公有版**新增**字段(私有版不支持):
*
* | manifest 字段 | 类型 | 默认值 | 说明 |
* | ------------------------- | ------- | ------ | ------------------------------------------------------ |
* | `backgroundTimeout` | Number | 300 | 后台返回前台超过此秒数视为新会话cst=2 |
* | `pageInactiveTimeout` | Number | 1800 | 前台连续无操作超过此秒数视为新会话cst=3 |
* | `channelVersion` | String | image | 内部调试:`image` / `1` / `2`,业务方一般不需要设置 |
*
* ## 别名兼容
*
* 公有版早期内部测试用了带 `Sec` 后缀的命名(`reportIntervalSec / backgroundTimeoutSec /
* pageInactiveTimeoutSec`),未对外发布但已在示例中出现过;本函数同时接受这两套写法,
* **优先取私有版命名**(无后缀),别名仅作向后兼容。
*
* ## 上行身份字段不从 manifest 读取
*
* `ak / v / ch` 均有更可信的数据源:`ak` 取构建期应用 AppID`v` 取运行时版本,
* App 端 `ch` 取 `plus.runtime.channel`。这些字段不应由
* `manifest.uniStatistics` 静态覆盖,尤其多渠道包会因此被固定到默认渠道。
*
* ## 内部接入参数不可自定义
*
* image 通道的 `host / projectId / topicId` 是 SDK 内部接入参数,由维护者直接在
* `public/config.ts#IMAGE_REPORT_DEFAULTS` 中维护,**不**通过 manifest 暴露给业务方。
*
* 任意 JSON 解析 / 字段类型异常都吞掉,回到默认值;此处**不能**抛错,否则会阻塞自动 install。
*
* ## 必须直接写 `process.env.UNI_STATISTICS_CONFIG`
*
* `uni:stat` 插件通过 Vite `define` 在**构建阶段**把字面量 `process.env.UNI_STATISTICS_CONFIG`
* 替换为 JSON 字符串。若写成 `const env = process.env; env.UNI_STATISTICS_CONFIG`
* 打包器无法静态替换,小程序/H5 运行时读到的一直是 `undefined`manifest 超时等字段全部丢失。
*
* ## 禁止 `typeof process !== 'undefined' ? process.env.XXX : …`
*
* 微信小程序等运行时**往往没有全局 `process`**。替换后源码等价于
* `typeof process !== 'undefined' ? "{\"enable\":…}" : undefined`,条件为假时会**整段丢弃**
* 已内联的 JSON 字符串,表现为 `UNI_STATISTICS_CONFIG_len=0`、会话阈值永远默认。
* 因此必须**直接**书写 `process.env.UNI_STATISTICS_CONFIG`(无任何 `typeof process` 包裹)。
*/
/**
* 解析构建期注入的 `UNI_STATISTICS_CONFIG`。
* 正常为 JSON 字符串;少数打包配置会误注入为对象字面量,此处一并兼容。
*/
function parseInjectedUniStatistics() {
const raw = process.env.UNI_STATISTICS_CONFIG;
if (raw == null)
return undefined;
if (typeof raw === 'object' && !Array.isArray(raw)) {
return raw;
}
if (typeof raw !== 'string')
return undefined;
const trimmed = raw.trim();
if (!trimmed || trimmed === 'undefined')
return undefined;
try {
const obj = JSON.parse(trimmed);
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return undefined;
return obj;
}
catch (_e) {
return undefined;
}
}
function readManifestStatConfig() {
try {
const obj = parseInjectedUniStatistics();
if (!obj)
return undefined;
const cfg = {};
if (obj.channelVersion != null) {
const v = String(obj.channelVersion);
if (v === '1' || v === '2' || v === 'image')
cfg.version = v;
}
// === 公有版扩展backgroundTimeout / pageInactiveTimeout私有版无此字段===
// 同时兼容早期内部用的带 Sec 后缀别名;优先无后缀(与官方风格一致)。
const bg = pickPositiveNumber(obj.backgroundTimeout, obj.backgroundTimeoutSec);
if (bg !== undefined)
cfg.backgroundTimeoutSec = bg;
const pi = pickPositiveNumber(obj.pageInactiveTimeout, obj.pageInactiveTimeoutSec);
if (pi !== undefined)
cfg.pageInactiveTimeoutSec = pi;
// === 私有版同名字段reportInterval私有版默认 10===
// 兼容旧公有版别名 reportIntervalSec允许 0私有版语义"立即上报")。
const ri = pickNonNegativeNumber(obj.reportInterval, obj.reportIntervalSec);
if (ri !== undefined)
cfg.reportIntervalSec = ri;
// === 私有版同名字段collectItems.{uniPushClientID, uniStatPageLog} ===
if (obj.collectItems && typeof obj.collectItems === 'object') {
const items = obj.collectItems;
if (typeof items.uniPushClientID === 'boolean') {
cfg.enablePush = items.uniPushClientID;
}
if (typeof items.uniStatPageLog === 'boolean') {
cfg.enablePageLog = items.uniStatPageLog;
}
}
return Object.keys(cfg).length > 0 ? cfg : undefined;
}
catch (e) {
logger.warn('[uni统计 2.0] readManifestStatConfig failed', e);
return undefined;
}
}
/**
* 将 manifest / JSON 中的数值候选标准化为正数(> 0
* 兼容部分工具或手工编辑 manifest 时写成**字符串数字**(如 `"60"`)的情况。
*/
function normalizePositiveNumber(value) {
if (typeof value === 'number') {
return value > 0 ? value : undefined;
}
if (typeof value === 'string') {
const t = value.trim();
if (t === '')
return undefined;
const n = Number(t);
if (Number.isFinite(n) && n > 0)
return n;
}
return undefined;
}
/**
* 将候选标准化为非负数(>= 0用于 `reportInterval`。
*/
function normalizeNonNegativeNumber(value) {
if (typeof value === 'number') {
return value >= 0 ? value : undefined;
}
if (typeof value === 'string') {
const t = value.trim();
if (t === '')
return undefined;
const n = Number(t);
if (Number.isFinite(n) && n >= 0)
return n;
}
return undefined;
}
/**
* 在多个候选值中按顺序取**第一个有效的正数**> 0其余忽略。
* 用于 manifest 字段的"主名 / 别名"二选一解析(如 `backgroundTimeout` / `backgroundTimeoutSec`)。
*
* 注意:私有版历史允许 `0` 表示"立即上报",但仅 `reportInterval` 一项有此语义;
* timeout 类字段 0 表示"立即超时",不合理,本函数统一过滤为 undefined。
*/
function pickPositiveNumber(...candidates) {
for (const c of candidates) {
const n = normalizePositiveNumber(c);
if (n !== undefined)
return n;
}
return undefined;
}
/**
* 同 `pickPositiveNumber`,但允许 `0`(用于 `reportInterval` 表达"立即上报"语义)。
*/
function pickNonNegativeNumber(...candidates) {
for (const c of candidates) {
const n = normalizeNonNegativeNumber(c);
if (n !== undefined)
return n;
}
return undefined;
}
function getUni() {
const u = resolveUniRuntime();
return u != null && typeof u === 'object' ? u : undefined;
}
/** Vue3 下晚就绪 API 重试次数50ms 间隔,约 1s。 */
const UNI_HOOK_RETRY_MAX = 20;
const UNI_HOOK_RETRY_MS = 50;
/** 已排队或已执行的 vue mixin 注入,避免重复。 */
let vueMixinMounted = false;
let vueMixinRetryTimer;
/** install 是否已经触发过(不论成功失败)。 */
let bootstrapped = false;
/** 已注册到全局的 unbind便于 __reset。 */
let lastUnbind;
/** 晚就绪时重试 `uni.onAppShow` 的定时器。 */
let uniHookRetryTimer;
/**
* 入口装配。重复调用时立即返回。
*
* 失败任意子步骤都吞掉日志,不抛回。
*/
function installPublicStat(opts = {}) {
if (bootstrapped)
return;
bootstrapped = true;
// 优先级opts.config手动覆盖 > manifest.uniStatisticsplugin 注入) > 默认值。
// 这样业务/灰度同学既能在 manifest 里改超时阈值(生产路径),
// 也能用 installPublicStat({ config: {...} }) 在测试环境强行覆盖(接入调试)。
// 注意manifest 仅解析行为配置,不解析 ak/v/chApp 渠道始终由 plus.runtime.channel 决定。
const fromManifest = readManifestStatConfig();
const finalConfig = Object.assign({}, fromManifest, opts.config);
const app = getStatApp();
tryRun(() => app.install(finalConfig, opts.overrides), undefined);
// 启动摘要:与生命周期解耦,保证 StatApp.install 完成后立刻可打印(不依赖 uni 是否已挂载)。
tryRun(() => {
var _a, _b, _c;
const cfgBoot = app.getConfig();
const appName = process.env.UNI_APP_NAME || '';
const injected = parseInjectedUniStatistics();
const bootBase = {
channel: (_a = cfgBoot === null || cfgBoot === void 0 ? void 0 : cfgBoot.version) !== null && _a !== void 0 ? _a : 'image',
reportIntervalSec: (_b = cfgBoot === null || cfgBoot === void 0 ? void 0 : cfgBoot.reportIntervalSec) !== null && _b !== void 0 ? _b : 0,
ak: (_c = cfgBoot === null || cfgBoot === void 0 ? void 0 : cfgBoot.ak) !== null && _c !== void 0 ? _c : '',
appName,
debugFromManifest: process.env.UNI_STAT_DEBUG === 'true' ||
process.env.UNI_STAT_DEBUG === true,
};
// 仅当 manifest 显式配置了超时项时才在 debug 启动摘要中展示(默认值 300/1800 不刷屏)。
if (injected != null) {
if (injected.backgroundTimeout != null ||
injected.backgroundTimeoutSec != null) {
bootBase.backgroundTimeoutSec = cfgBoot === null || cfgBoot === void 0 ? void 0 : cfgBoot.backgroundTimeoutSec;
}
if (injected.pageInactiveTimeout != null ||
injected.pageInactiveTimeoutSec != null) {
bootBase.pageInactiveTimeoutSec = cfgBoot === null || cfgBoot === void 0 ? void 0 : cfgBoot.pageInactiveTimeoutSec;
}
}
// #ifndef VUE3
logBoot(Object.assign({}, bootBase, { vueMode: 'Vue2' }));
// #endif
// #ifdef VUE3
logBoot(Object.assign({}, bootBase, { vueMode: 'Vue3' }));
// #endif
}, undefined);
/**
* 装配 vue mixin 与 uni 生命周期;与 logBoot 解耦,便于在 uni 晚就绪时延后执行。
*/
const finishLifecycleInstall = () => {
var _a, _b;
// 把 collectItems 的开关透传给 lifecycleHooks
// - uniPushClientID → enablePush决定是否抓取 push CID 上报 lt=101
// - uniStatPageLog → enablePageLog决定是否上报 lt=11 页面切换事件)
// 调用方通过 opts.lifecycle 显式传入的值优先级最高,未指定时用 manifest 默认。
const cfg = app.getConfig();
const lifecycleOpts = Object.assign({}, {
enablePush: (_a = cfg === null || cfg === void 0 ? void 0 : cfg.enablePush) !== null && _a !== void 0 ? _a : false,
enablePageLog: (_b = cfg === null || cfg === void 0 ? void 0 : cfg.enablePageLog) !== null && _b !== void 0 ? _b : true,
}, opts.lifecycle);
const { mixin, unbind } = bindLifecycle(app, lifecycleOpts);
lastUnbind = unbind;
// 与私有版 load_stat 一致:同步注入 mixin不等待 uni.onAppShowVue2 根本不注册该项)。
if (!opts.skipVueMixin) {
tryRun(() => mountVueMixin(mixin), undefined);
}
if (!opts.skipUniReport) {
tryRun(() => mountUniReport(app), undefined);
}
// 私有版:仅 Vue3 且非 H5/nvue 注册 uni 应用前后台。
if (shouldBindUniAppLifecycle() &&
!tryBindUniAppLifecycle(app, lifecycleOpts)) {
scheduleUniAppHookRetry(() => tryBindUniAppLifecycle(app, lifecycleOpts));
}
};
finishLifecycleInstall();
}
/**
* 仅 Vue3 小程序等重试 `uni.onAppShow` / `onAppHide`Vue2 不调用。
*/
function scheduleUniAppHookRetry(tryBind) {
if (uniHookRetryTimer) {
clearTimeout(uniHookRetryTimer);
uniHookRetryTimer = undefined;
}
let attempts = 0;
const tick = () => {
if (tryBind())
return;
if (++attempts >= UNI_HOOK_RETRY_MAX) {
logger.warn('[uni统计 2.0] Vue3 小程序uni.onAppShow 暂不可用,应用前后台统计可能缺失');
return;
}
uniHookRetryTimer = setTimeout(tick, UNI_HOOK_RETRY_MS);
};
uniHookRetryTimer = setTimeout(tick, UNI_HOOK_RETRY_MS);
}
/**
* 把 mixin 装到 vue 实例上。
*
* 与私有版 `src/index.js#load_stat` 一致,必须用条件编译区分:
* - VUE3`uni.onCreateVueApp` → `app.mixin`(与私有版相同,不引 `@dcloudio/uni-shared`)。
* - VUE2`require('vue').mixin`(由应用打包器静态解析,勿用 `globalThis.require`)。
*
* `#ifdef` 保留到 dist由宿主 `uni:pre` 在打包阶段剔除分支(同私有版 dist
*/
// #ifdef VUE3
/**
* 注册 `onCreateVueApp` 以注入页面 mixin。
*
* **必须**写字面量 `uni.onCreateVueApp(...)`(与私有版 `index.js#load_stat` 完全一致),
* 供 H5 发行 inject 插件静态替换为 `@dcloudio/uni-h5` 真实 API。
* 动态 `u.onCreateVueApp` 无法被 inject 识别,会导致 build 后 mixin 未注入。
* 第二路回退 resolveUniRuntimedev 全量 window.uni、单测 mock
*/
function tryRegisterVueAppMixin(mixin) {
try {
;
uni.onCreateVueApp((vueApp) => {
tryRun(() => vueApp.mixin(mixin), undefined);
});
return true;
}
catch (_e) {
// uni 未声明且未经 inject 替换(单测等)
}
const u = getUni();
if (u && typeof u.onCreateVueApp === 'function') {
u.onCreateVueApp((vueApp) => {
tryRun(() => vueApp.mixin(mixin), undefined);
});
return true;
}
return false;
}
// #endif
function mountVueMixin(mixin) {
if (vueMixinMounted)
return;
// #ifndef VUE3
if (mountVue2GlobalMixin(mixin)) {
vueMixinMounted = true;
}
// #endif
// #ifdef VUE3
if (tryRegisterVueAppMixin(mixin)) {
vueMixinMounted = true;
return;
}
scheduleVueAppMixinRetry(mixin);
// #endif
}
// #ifdef VUE3
/**
* Vue3`onCreateVueApp` 晚就绪时短重试(对齐私有版仅注册一次 hook 的语义)。
*/
function scheduleVueAppMixinRetry(mixin) {
if (vueMixinMounted)
return;
if (vueMixinRetryTimer)
return;
let attempts = 0;
const tick = () => {
vueMixinRetryTimer = undefined;
if (vueMixinMounted)
return;
if (tryRegisterVueAppMixin(mixin)) {
vueMixinMounted = true;
return;
}
if (++attempts >= UNI_HOOK_RETRY_MAX) {
if (!vueMixinMounted) {
logger.warn('[uni统计 2.0] Vue3: onCreateVueApp 在重试后仍不可用,页面级 mixin 未注入');
}
return;
}
vueMixinRetryTimer = setTimeout(tick, UNI_HOOK_RETRY_MS);
};
vueMixinRetryTimer = setTimeout(tick, UNI_HOOK_RETRY_MS);
}
// #endif
/**
* Vue2 全局 mixin与私有版 `index.js` 中 `require('vue').mixin` 一致)。
*
* @returns 是否注入成功
*/
function mountVue2GlobalMixin(mixin) {
var _a;
// eslint-disable-next-line no-restricted-globals
const Vue = require('vue');
const target = (_a = Vue.default) !== null && _a !== void 0 ? _a : Vue;
if (target && typeof target.mixin === 'function') {
tryRun(() => target.mixin(mixin), undefined);
return true;
}
logger.warn('[uni统计 2.0] Vue2: vue.mixin 不可用,请检查是否已安装 vue 依赖');
return false;
}
/**
* 把 `uni.report` 桥到 StatApp.report。
*
* H5 发行摇树时 `resolveUniRuntime` 会跳过 `{}` 空桩,但业务仍可能通过
* `window.uni.report` 调用;故在可用 runtime 缺失时回退 `getGlobalObject().uni`。
*/
function mountUniReport(app) {
var _a;
const g = getGlobalObject();
const u = ((_a = getUni()) !== null && _a !== void 0 ? _a : g.uni);
if (!u || typeof u !== 'object')
return;
u.report = (type, value) => {
app.report(type, value);
};
}
/** 仅供测试:重置 install 哨兵;调用方应同时调 `__resetStatApp()`。 */
function __resetInstall() {
if (uniHookRetryTimer) {
clearTimeout(uniHookRetryTimer);
uniHookRetryTimer = undefined;
}
if (vueMixinRetryTimer) {
clearTimeout(vueMixinRetryTimer);
vueMixinRetryTimer = undefined;
}
vueMixinMounted = false;
if (lastUnbind)
tryRun(() => lastUnbind(), undefined);
lastUnbind = undefined;
bootstrapped = false;
}
/**
* 公有版统计入口。
*
* 与私有版 `src/index.js#main()` 等价:模块加载即触发安装。宿主无需手动调用,
* 只需 `import '@dcloudio/uni-stat-public'`(或对应 dist 路径)即可。
*
* 也对外导出 `installPublicStat / getStatApp` 以便调试或自定义场景手动重装。
*/
// 自动安装:与私有版行为一致,加载即触发。
installPublicStat();
exports.__resetInstall = __resetInstall;
exports.__resetStatApp = __resetStatApp;
exports.getStatApp = getStatApp;
exports.installPublicStat = installPublicStat;