@yunzhimao/telemetry 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/cjs/browser.d.ts +12 -0
- package/cjs/browser.js +99 -0
- package/cjs/extension.d.ts +8 -0
- package/cjs/extension.js +13 -0
- package/cjs/index.d.ts +14 -0
- package/cjs/index.js +254 -0
- package/cjs/package.json +1 -0
- package/cjs/types.d.ts +69 -0
- package/cjs/types.js +2 -0
- package/dist/browser.d.ts +12 -0
- package/dist/browser.js +94 -0
- package/dist/extension.d.ts +8 -0
- package/dist/extension.js +8 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +249 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @yunzhimao/telemetry
|
|
2
|
+
|
|
3
|
+
通用行为采集 SDK,当前 `0.2.0` 是待联调版本,尚未发布到 npm。只采集显式行为,不自动捕获 DOM、URL、请求内容或异常。
|
|
4
|
+
|
|
5
|
+
## 浏览器
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createBrowserTracker } from '@yunzhimao/telemetry/browser';
|
|
9
|
+
|
|
10
|
+
// 宿主使用自己的登录态访问自己的后端;返回 TokenSession + endpoint。
|
|
11
|
+
const session = await getHostTelemetrySession();
|
|
12
|
+
const tracker = createBrowserTracker({
|
|
13
|
+
identity: session.identity,
|
|
14
|
+
endpoint: session.endpoint,
|
|
15
|
+
appVersion: 'example-1.0.0',
|
|
16
|
+
getToken: () => getHostTelemetrySession(),
|
|
17
|
+
});
|
|
18
|
+
void tracker.active(); // 仅在已登录用户实际可见访问时调用。
|
|
19
|
+
void tracker.track('listing.collect.opened', { });
|
|
20
|
+
void tracker.track('listing.collect.submitted');
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`identity` 必须来自宿主后端登录上下文。收到不同身份的 Token 时 SDK 清空队列并暂停,不能用新账号补发旧事件。退出、账号/主体/实例切换先 `await tracker.reset()` 再 `await tracker.destroy()`,之后创建新实例;宿主应在网络结果回来后再次核对登录身份,避免异步初始化串号。
|
|
24
|
+
|
|
25
|
+
## 插件 / 无 DOM 环境
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createTracker, extensionStorage } from '@yunzhimao/telemetry/extension';
|
|
29
|
+
const tracker = createTracker({
|
|
30
|
+
identity: session.identity, endpoint: session.endpoint, appVersion: 'example-1.0.0',
|
|
31
|
+
getToken: getHostTelemetrySession,
|
|
32
|
+
storage: extensionStorage(chrome.storage.local),
|
|
33
|
+
flushIntervalMs: 0,
|
|
34
|
+
});
|
|
35
|
+
await tracker.ready;
|
|
36
|
+
await tracker.track('listing.collect.submitted', { });
|
|
37
|
+
void tracker.flush();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
只有 background 获取 Token、创建 SDK 和发送。content script / popup 通过受控内部消息提交固定 eventName和基础设备信息,不能得到 Token;后台验证 sender 和字段。新事件或唤醒时恢复队列并 flush,不能靠 `setInterval` 保证 MV3 常驻。安装标识可由宿主持久化后通过 `anonymousId` 注入。业务流程不要等待 `flush()`;内部消息在 `track()` 持久化完成后可确认接收。
|
|
41
|
+
|
|
42
|
+
## 行为与边界
|
|
43
|
+
|
|
44
|
+
- `eventName` 是唯一点位 key,例如 `listing.collect.opened` / `listing.collect.submitted`;保留 `app.active`。业务目录由宿主与 Collector 维护,SDK 不携带 ERP 字典。属性只能发送该点位目录声明的字段,不传用户对象或任意业务数据。
|
|
45
|
+
- 默认 20 条 / 10 秒,最多 500 条 / 1 MiB / 24 小时;溢出丢最旧项。只收到 `200` 且 `accepted` 等于批次条数后出队。
|
|
46
|
+
- 401 最多立即续取一次,429 遵守 Retry-After,网络/5xx 有界退避;413 拆批,422 按索引丢弃坏事件。
|
|
47
|
+
- Token 只在内存中,不进入离线队列;事件重试保持 eventId 和原始时间。安装标识不是设备指纹,行为不是可信业务结算依据。
|
|
48
|
+
- `track()` 不等待网络且不向业务抛采集错误;存储失败退回内存。`onDiagnostic(code, count)` 不含原始事件或 Token。
|
|
49
|
+
- `destroy()` 卸载监听/定时器并保留队列;退出登录必须先 `reset()` 清队列。关页发送仅尽力而为,关闭标签页的未发送快照不承诺由其他标签页补发,过期后清理。
|
|
50
|
+
- 默认入口仅依赖现代运行时的 fetch、TextEncoder、crypto.randomUUID;浏览器子路径要求 DOM 和 IndexedDB,插件 storage 由调用方注入;无 Vue/Pinia/CRM 商业依赖。
|
|
51
|
+
|
|
52
|
+
## 验证与发布
|
|
53
|
+
|
|
54
|
+
在仓库根目录运行 `pnpm --filter @yunzhimao/telemetry test`。`pnpm --filter @yunzhimao/telemetry pack --pack-destination .tmp` 只生成本地包,不发布。包内容限定为 ESM dist、CommonJS cjs、类型声明和本说明。
|
|
55
|
+
|
|
56
|
+
发布前确认 npm scope 所属、公开/私有可见性,三个宿主安装同一 tarball 验证后再使用固定版本号。不要把私钥、环境配置、内部服务端包或其他仓库打入包。
|
|
57
|
+
|
|
58
|
+
## ERP 后端 / Node
|
|
59
|
+
|
|
60
|
+
默认入口同时提供 ESM import 和 CommonJS require,要求 Node 22.12+,使用同一个 `createTracker`;按可信登录账号隔离队列,禁止跨账号共享 tracker。后端只报告目录中的处理结果,不调用 `active()`、不带用户设备,不把服务器出口当用户地域。ERP 适配层最多 100 个账号队列,每账号 50 条 / 64 KiB,闲置 5 分钟销毁;无磁盘队列,不保证零丢失。
|
package/cjs/browser.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DeviceInfo, QueueStorage, TrackerOptions } from './types.js';
|
|
2
|
+
/** 只采集基础设备属性;不读取 URL、DOM 内容、Token 或指纹。 */
|
|
3
|
+
export declare function browserDevice(): DeviceInfo;
|
|
4
|
+
export declare function indexedDbStorage(namespace?: string): QueueStorage;
|
|
5
|
+
export declare function createBrowserTracker(options: TrackerOptions): {
|
|
6
|
+
destroy(): Promise<void>;
|
|
7
|
+
ready: Promise<void>;
|
|
8
|
+
track: (eventName: import("./types.js").EventName, input?: import("./types.js").TrackInput) => Promise<void>;
|
|
9
|
+
flush: () => Promise<void>;
|
|
10
|
+
active(): Promise<void>;
|
|
11
|
+
reset(identity?: import("./types.js").TelemetryIdentity): Promise<void>;
|
|
12
|
+
};
|
package/cjs/browser.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.browserDevice = browserDevice;
|
|
4
|
+
exports.indexedDbStorage = indexedDbStorage;
|
|
5
|
+
exports.createBrowserTracker = createBrowserTracker;
|
|
6
|
+
const index_js_1 = require("./index.js");
|
|
7
|
+
/** 只采集基础设备属性;不读取 URL、DOM 内容、Token 或指纹。 */
|
|
8
|
+
function browserDevice() {
|
|
9
|
+
if (typeof navigator === 'undefined')
|
|
10
|
+
return {};
|
|
11
|
+
const ua = navigator.userAgent;
|
|
12
|
+
const browser = /Edg\/([\d.]+)/.exec(ua) ?? /Firefox\/([\d.]+)/.exec(ua) ?? /Chrome\/([\d.]+)/.exec(ua) ?? /Version\/([\d.]+).*Safari/.exec(ua);
|
|
13
|
+
const os = /Windows NT ([\d.]+)/.exec(ua) ?? /Android ([\d.]+)/.exec(ua) ?? /(?:iPhone OS|CPU OS) ([\d_]+)/.exec(ua) ?? /Mac OS X ([\d_]+)/.exec(ua);
|
|
14
|
+
return {
|
|
15
|
+
type: /iPad|Tablet/i.test(ua) ? 'tablet' : /Mobile|iPhone/i.test(ua) ? 'mobile' : 'desktop',
|
|
16
|
+
browser: /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'unknown',
|
|
17
|
+
browserVersion: browser?.[1] ?? '',
|
|
18
|
+
os: /Windows/.test(ua) ? 'Windows' : /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iOS' : /Mac OS/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : 'unknown',
|
|
19
|
+
osVersion: os?.[1]?.replaceAll('_', '.') ?? '', language: navigator.language,
|
|
20
|
+
...(typeof screen === 'undefined' ? {} : { screenWidth: screen.width, screenHeight: screen.height }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function indexedDbStorage(namespace = 'yzm-telemetry') {
|
|
24
|
+
// 每个标签页独立队列,避免多个页面读改写同一个快照导致相互覆盖。
|
|
25
|
+
let tab = globalThis.crypto.randomUUID();
|
|
26
|
+
try {
|
|
27
|
+
const tabKey = `${namespace}:tab`;
|
|
28
|
+
tab = sessionStorage.getItem(tabKey) ?? tab;
|
|
29
|
+
sessionStorage.setItem(tabKey, tab);
|
|
30
|
+
}
|
|
31
|
+
catch { /* 隐私模式由存储失败诊断和内存队列降级。 */ }
|
|
32
|
+
const open = () => new Promise((resolve, reject) => {
|
|
33
|
+
const request = indexedDB.open(namespace, 1);
|
|
34
|
+
request.onupgradeneeded = () => request.result.createObjectStore('queues');
|
|
35
|
+
request.onsuccess = () => resolve(request.result);
|
|
36
|
+
request.onerror = () => reject(new Error('storage_failed'));
|
|
37
|
+
});
|
|
38
|
+
async function run(mode, action) {
|
|
39
|
+
const db = await open();
|
|
40
|
+
try {
|
|
41
|
+
return await new Promise((resolve, reject) => {
|
|
42
|
+
const tx = db.transaction('queues', mode);
|
|
43
|
+
const request = action(tx.objectStore('queues'));
|
|
44
|
+
tx.oncomplete = () => resolve(request.result);
|
|
45
|
+
tx.onabort = tx.onerror = () => reject(new Error('storage_failed'));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
db.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
load: async () => {
|
|
54
|
+
// 清除关闭标签页遗留的过期快照,避免长期使用积累无界的离线记录。
|
|
55
|
+
await run('readwrite', (store) => {
|
|
56
|
+
const cursor = store.openCursor();
|
|
57
|
+
cursor.onsuccess = () => {
|
|
58
|
+
const row = cursor.result;
|
|
59
|
+
if (!row)
|
|
60
|
+
return;
|
|
61
|
+
const saved = row.value;
|
|
62
|
+
if (!saved.events?.some((event) => Date.parse(event.occurredAt) >= Date.now() - 86400000))
|
|
63
|
+
row.delete();
|
|
64
|
+
row.continue();
|
|
65
|
+
};
|
|
66
|
+
return cursor;
|
|
67
|
+
});
|
|
68
|
+
return (await run('readonly', (store) => store.get(tab)));
|
|
69
|
+
},
|
|
70
|
+
save: async (value) => {
|
|
71
|
+
if (value.events.length)
|
|
72
|
+
await run('readwrite', (store) => store.put(value, tab));
|
|
73
|
+
else
|
|
74
|
+
await run('readwrite', (store) => store.delete(tab));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function createBrowserTracker(options) {
|
|
79
|
+
let anonymousId = options.anonymousId;
|
|
80
|
+
try {
|
|
81
|
+
anonymousId ??= localStorage.getItem('yzm-telemetry:installation') ?? globalThis.crypto.randomUUID();
|
|
82
|
+
localStorage.setItem('yzm-telemetry:installation', anonymousId);
|
|
83
|
+
}
|
|
84
|
+
catch { /* 不支持持久化时使用本次会话随机标识。 */ }
|
|
85
|
+
const tracker = (0, index_js_1.createTracker)({ ...options, anonymousId, device: options.device ?? browserDevice(), storage: options.storage ?? indexedDbStorage() });
|
|
86
|
+
const flush = () => { void tracker.flush(); };
|
|
87
|
+
document.addEventListener('visibilitychange', flush);
|
|
88
|
+
window.addEventListener('pagehide', flush);
|
|
89
|
+
window.addEventListener('online', flush);
|
|
90
|
+
return {
|
|
91
|
+
...tracker,
|
|
92
|
+
destroy() {
|
|
93
|
+
document.removeEventListener('visibilitychange', flush);
|
|
94
|
+
window.removeEventListener('pagehide', flush);
|
|
95
|
+
window.removeEventListener('online', flush);
|
|
96
|
+
return tracker.destroy();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { QueueStorage } from './types.js';
|
|
2
|
+
export { createTracker } from './index.js';
|
|
3
|
+
export type * from './types.js';
|
|
4
|
+
/** 在插件 background 注入 chrome.storage.local,不在公共入口绑定 Chrome 全局对象。 */
|
|
5
|
+
export declare function extensionStorage(storage: {
|
|
6
|
+
get(key: string): Promise<Record<string, unknown>>;
|
|
7
|
+
set(items: Record<string, unknown>): Promise<void>;
|
|
8
|
+
}, namespace?: string): QueueStorage;
|
package/cjs/extension.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTracker = void 0;
|
|
4
|
+
exports.extensionStorage = extensionStorage;
|
|
5
|
+
var index_js_1 = require("./index.js");
|
|
6
|
+
Object.defineProperty(exports, "createTracker", { enumerable: true, get: function () { return index_js_1.createTracker; } });
|
|
7
|
+
/** 在插件 background 注入 chrome.storage.local,不在公共入口绑定 Chrome 全局对象。 */
|
|
8
|
+
function extensionStorage(storage, namespace = 'yzm-telemetry-queue') {
|
|
9
|
+
return {
|
|
10
|
+
load: async () => (await storage.get(namespace))[namespace],
|
|
11
|
+
save: async (queue) => { await storage.set({ [namespace]: queue }); },
|
|
12
|
+
};
|
|
13
|
+
}
|
package/cjs/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EventName, TelemetryIdentity, TrackInput, TrackerOptions } from './types.js';
|
|
2
|
+
export type { BehaviorEvent, DeviceInfo, EventName, QueueStorage, SourceApp, StoredQueue, TelemetryIdentity, TokenSession, TrackInput, TrackerOptions } from './types.js';
|
|
3
|
+
export declare const SDK_VERSION = "0.2.0";
|
|
4
|
+
export declare const identityKey: (identity: TelemetryIdentity) => string;
|
|
5
|
+
/** 与宿主登录解耦;先绑定身份,异步请求只负责采集授权,不能改变队列归属。 */
|
|
6
|
+
export declare function createTracker(options: TrackerOptions): {
|
|
7
|
+
ready: Promise<void>;
|
|
8
|
+
track: (eventName: EventName, input?: TrackInput) => Promise<void>;
|
|
9
|
+
flush: () => Promise<void>;
|
|
10
|
+
/** 只由宿主真实可见访问/交互调用,不自动通过心跳制造日活。 */
|
|
11
|
+
active(): Promise<void>;
|
|
12
|
+
reset(identity?: TelemetryIdentity): Promise<void>;
|
|
13
|
+
destroy(): Promise<void>;
|
|
14
|
+
};
|
package/cjs/index.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.identityKey = exports.SDK_VERSION = void 0;
|
|
4
|
+
exports.createTracker = createTracker;
|
|
5
|
+
exports.SDK_VERSION = '0.2.0';
|
|
6
|
+
/** 至少“领域.行为.动作”三段;活跃为协议保留名,不再接受旧通用事件名。 */
|
|
7
|
+
const validEventName = (name) => typeof name === 'string' &&
|
|
8
|
+
name.length <= 100 && (name === 'app.active' || /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2,}$/.test(name));
|
|
9
|
+
const identityKey = (identity) => JSON.stringify([
|
|
10
|
+
identity.applicationId, identity.instanceId, identity.sourceApp, identity.actorId, identity.externalOrganizationId,
|
|
11
|
+
]);
|
|
12
|
+
exports.identityKey = identityKey;
|
|
13
|
+
/** 与宿主登录解耦;先绑定身份,异步请求只负责采集授权,不能改变队列归属。 */
|
|
14
|
+
function createTracker(options) {
|
|
15
|
+
const now = options.now ?? Date.now;
|
|
16
|
+
const randomId = options.randomId ?? (() => globalThis.crypto.randomUUID());
|
|
17
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
18
|
+
const anonymousId = options.anonymousId ?? randomId();
|
|
19
|
+
const sessionId = randomId();
|
|
20
|
+
let key = (0, exports.identityKey)(options.identity);
|
|
21
|
+
let events = [];
|
|
22
|
+
let session;
|
|
23
|
+
let generation = 0;
|
|
24
|
+
let destroyed = false;
|
|
25
|
+
let blocked = false;
|
|
26
|
+
let pending;
|
|
27
|
+
let storageWrites = Promise.resolve();
|
|
28
|
+
let abort;
|
|
29
|
+
let failures = 0;
|
|
30
|
+
let nextAttemptAt = 0;
|
|
31
|
+
let activeDay = '';
|
|
32
|
+
let batchSize = Math.max(1, Math.min(options.batchSize ?? 20, 100));
|
|
33
|
+
const maxAge = Math.min(options.maxAgeMs ?? 86_400_000, 86_400_000);
|
|
34
|
+
const diagnose = (code, count = 1) => {
|
|
35
|
+
try {
|
|
36
|
+
options.onDiagnostic?.(code, count);
|
|
37
|
+
}
|
|
38
|
+
catch { /* 诊断回调不能干扰宿主业务。 */ }
|
|
39
|
+
};
|
|
40
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
41
|
+
function trim() {
|
|
42
|
+
const before = events.length;
|
|
43
|
+
events = events.filter((event) => Date.parse(event.occurredAt) >= now() - maxAge);
|
|
44
|
+
const limit = Math.max(1, Math.min(options.maxQueueEvents ?? 500, 500));
|
|
45
|
+
const byteLimit = Math.max(8192, Math.min(options.maxQueueBytes ?? 1048576, 1048576));
|
|
46
|
+
while (events.length > limit || (events.length && bytes(events) > byteLimit))
|
|
47
|
+
events.shift();
|
|
48
|
+
if (before > events.length)
|
|
49
|
+
diagnose('queue_dropped', before - events.length);
|
|
50
|
+
}
|
|
51
|
+
function persist() {
|
|
52
|
+
const snapshot = JSON.parse(JSON.stringify({ identityKey: key, events }));
|
|
53
|
+
// 串行落盘,防止旧快照晚于 reset 完成而把旧账号队列恢复回来。
|
|
54
|
+
storageWrites = storageWrites.then(() => options.storage?.save(snapshot)).catch(() => diagnose('storage_failed'));
|
|
55
|
+
return storageWrites;
|
|
56
|
+
}
|
|
57
|
+
const ready = (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const saved = await options.storage?.load();
|
|
60
|
+
if (destroyed || generation !== 0)
|
|
61
|
+
return;
|
|
62
|
+
if (saved?.identityKey === key && Array.isArray(saved.events)) {
|
|
63
|
+
events = saved.events.filter((item) => item && typeof item.eventId === 'string' && typeof item.occurredAt === 'string' && validEventName(item.eventName));
|
|
64
|
+
trim();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
diagnose('storage_failed');
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
async function token(force, version) {
|
|
72
|
+
if (force || !session || Date.parse(session.expiresAt) <= now() + 10_000) {
|
|
73
|
+
const fresh = await options.getToken(force);
|
|
74
|
+
if (version !== generation || destroyed)
|
|
75
|
+
return null;
|
|
76
|
+
if ((0, exports.identityKey)(fresh.identity) !== key) {
|
|
77
|
+
blocked = true;
|
|
78
|
+
events = [];
|
|
79
|
+
await persist();
|
|
80
|
+
diagnose('identity_changed');
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!fresh.token || !Number.isFinite(Date.parse(fresh.expiresAt)) || Date.parse(fresh.expiresAt) <= now()) {
|
|
84
|
+
throw new Error('invalid_session');
|
|
85
|
+
}
|
|
86
|
+
session = fresh;
|
|
87
|
+
}
|
|
88
|
+
return session.token;
|
|
89
|
+
}
|
|
90
|
+
function backoff(retryAfter = 0) {
|
|
91
|
+
failures++;
|
|
92
|
+
nextAttemptAt = now() + Math.max(retryAfter, Math.min(60_000, 1000 * 2 ** Math.min(failures, 6)) * (0.8 + Math.random() * 0.4));
|
|
93
|
+
}
|
|
94
|
+
async function sendLoop() {
|
|
95
|
+
await ready;
|
|
96
|
+
const version = generation;
|
|
97
|
+
if (destroyed || blocked || now() < nextAttemptAt)
|
|
98
|
+
return;
|
|
99
|
+
trim();
|
|
100
|
+
await persist();
|
|
101
|
+
while (events.length && version === generation && !destroyed && !blocked) {
|
|
102
|
+
const batch = events.slice(0, batchSize);
|
|
103
|
+
const ids = new Set(batch.map((event) => event.eventId));
|
|
104
|
+
try {
|
|
105
|
+
let bearer = await token(false, version);
|
|
106
|
+
if (!bearer)
|
|
107
|
+
return;
|
|
108
|
+
let response;
|
|
109
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
110
|
+
if (version !== generation || destroyed)
|
|
111
|
+
return;
|
|
112
|
+
abort = new AbortController();
|
|
113
|
+
const timeout = setTimeout(() => abort?.abort(), 20_000);
|
|
114
|
+
try {
|
|
115
|
+
response = await fetcher(options.endpoint, {
|
|
116
|
+
method: 'POST', credentials: 'omit',
|
|
117
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
|
|
118
|
+
body: JSON.stringify({ events: batch }), signal: abort.signal,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
}
|
|
124
|
+
if (response.status !== 401 || attempt === 1)
|
|
125
|
+
break;
|
|
126
|
+
bearer = await token(true, version);
|
|
127
|
+
if (!bearer)
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (version !== generation || destroyed || !response)
|
|
131
|
+
return;
|
|
132
|
+
if (response.status === 200) {
|
|
133
|
+
const result = await response.json();
|
|
134
|
+
if (version !== generation || destroyed)
|
|
135
|
+
return;
|
|
136
|
+
if (result.accepted !== batch.length)
|
|
137
|
+
throw new Error('invalid_ack');
|
|
138
|
+
events = events.filter((event) => !ids.has(event.eventId));
|
|
139
|
+
failures = 0;
|
|
140
|
+
nextAttemptAt = 0;
|
|
141
|
+
}
|
|
142
|
+
else if (response.status === 413 && batch.length > 1) {
|
|
143
|
+
batchSize = Math.max(1, Math.floor(batch.length / 2));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
else if ([400, 413, 422].includes(response.status)) {
|
|
147
|
+
const result = await response.json().catch(() => ({}));
|
|
148
|
+
if (version !== generation || destroyed)
|
|
149
|
+
return;
|
|
150
|
+
const indices = Array.isArray(result.invalidIndices)
|
|
151
|
+
? result.invalidIndices.filter((i) => Number.isInteger(i) && i >= 0 && i < batch.length) : [];
|
|
152
|
+
const invalid = indices.length ? new Set(indices.map((i) => batch[i].eventId)) : ids;
|
|
153
|
+
events = events.filter((event) => !invalid.has(event.eventId));
|
|
154
|
+
diagnose('invalid_event', invalid.size);
|
|
155
|
+
}
|
|
156
|
+
else if (response.status === 403) {
|
|
157
|
+
blocked = true;
|
|
158
|
+
diagnose('forbidden');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const retryHeader = response.headers.get('Retry-After');
|
|
163
|
+
const retryAfter = retryHeader && /^\d+$/.test(retryHeader)
|
|
164
|
+
? Number(retryHeader) * 1000 : Math.max(0, Date.parse(retryHeader ?? '') - now()) || 0;
|
|
165
|
+
backoff(retryAfter);
|
|
166
|
+
diagnose(`http_${response.status}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await persist();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
if (version === generation && !destroyed) {
|
|
173
|
+
backoff();
|
|
174
|
+
diagnose('send_failed');
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function flush() {
|
|
181
|
+
if (!pending)
|
|
182
|
+
pending = sendLoop().catch(() => diagnose('send_failed')).finally(() => { pending = undefined; });
|
|
183
|
+
return pending;
|
|
184
|
+
}
|
|
185
|
+
async function track(eventName, input = {}) {
|
|
186
|
+
const version = generation;
|
|
187
|
+
try {
|
|
188
|
+
await ready;
|
|
189
|
+
if (destroyed || blocked || version !== generation)
|
|
190
|
+
return;
|
|
191
|
+
if (!validEventName(eventName)) {
|
|
192
|
+
diagnose('invalid_event_name');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const event = {
|
|
196
|
+
eventId: randomId(), recordType: 'event', eventName, eventVersion: 1,
|
|
197
|
+
occurredAt: new Date(now()).toISOString(), anonymousId, sessionId,
|
|
198
|
+
appVersion: options.appVersion, sdkVersion: exports.SDK_VERSION,
|
|
199
|
+
...(input.device ?? options.device ? { device: input.device ?? options.device } : {}),
|
|
200
|
+
...(input.properties ? { properties: input.properties } : {}),
|
|
201
|
+
};
|
|
202
|
+
// 拷贝调用方对象,避免业务代码在入队后修改已产生事件。
|
|
203
|
+
if (bytes(event) > 8192) {
|
|
204
|
+
diagnose('event_too_large');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
events.push(JSON.parse(JSON.stringify(event)));
|
|
208
|
+
trim();
|
|
209
|
+
await persist();
|
|
210
|
+
if (events.length >= batchSize)
|
|
211
|
+
void flush();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
diagnose('track_failed');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const interval = options.flushIntervalMs === 0 ? undefined : setInterval(() => { void flush(); }, Math.max(100, options.flushIntervalMs ?? 10000));
|
|
218
|
+
return {
|
|
219
|
+
ready, track, flush,
|
|
220
|
+
/** 只由宿主真实可见访问/交互调用,不自动通过心跳制造日活。 */
|
|
221
|
+
active() {
|
|
222
|
+
if (options.identity.sourceApp === 'erp_backend')
|
|
223
|
+
return Promise.resolve();
|
|
224
|
+
const day = new Date(now() + 8 * 3600_000).toISOString().slice(0, 10);
|
|
225
|
+
if (day === activeDay)
|
|
226
|
+
return Promise.resolve();
|
|
227
|
+
activeDay = day;
|
|
228
|
+
return track('app.active');
|
|
229
|
+
},
|
|
230
|
+
async reset(identity) {
|
|
231
|
+
generation++;
|
|
232
|
+
abort?.abort();
|
|
233
|
+
session = undefined;
|
|
234
|
+
events = [];
|
|
235
|
+
activeDay = '';
|
|
236
|
+
blocked = false;
|
|
237
|
+
failures = 0;
|
|
238
|
+
nextAttemptAt = 0;
|
|
239
|
+
if (identity)
|
|
240
|
+
key = (0, exports.identityKey)(identity);
|
|
241
|
+
await ready;
|
|
242
|
+
await persist();
|
|
243
|
+
},
|
|
244
|
+
async destroy() {
|
|
245
|
+
destroyed = true;
|
|
246
|
+
generation++;
|
|
247
|
+
abort?.abort();
|
|
248
|
+
if (interval !== undefined)
|
|
249
|
+
clearInterval(interval);
|
|
250
|
+
await ready;
|
|
251
|
+
await persist();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
package/cjs/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "commonjs" }
|
package/cjs/types.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type SourceApp = 'erp_web' | 'erp_backend' | 'plugin';
|
|
2
|
+
/** 事件名就是唯一点位 key;具体业务目录由宿主与 Collector 维护。 */
|
|
3
|
+
export type EventName = string;
|
|
4
|
+
export interface TelemetryIdentity {
|
|
5
|
+
applicationId: string;
|
|
6
|
+
instanceId: string;
|
|
7
|
+
sourceApp: SourceApp;
|
|
8
|
+
actorId: string;
|
|
9
|
+
externalOrganizationId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DeviceInfo {
|
|
12
|
+
type?: string;
|
|
13
|
+
os?: string;
|
|
14
|
+
osVersion?: string;
|
|
15
|
+
browser?: string;
|
|
16
|
+
browserVersion?: string;
|
|
17
|
+
language?: string;
|
|
18
|
+
screenWidth?: number;
|
|
19
|
+
screenHeight?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface BehaviorEvent {
|
|
22
|
+
eventId: string;
|
|
23
|
+
recordType: 'event';
|
|
24
|
+
eventName: EventName;
|
|
25
|
+
eventVersion: 1;
|
|
26
|
+
occurredAt: string;
|
|
27
|
+
anonymousId: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
appVersion: string;
|
|
30
|
+
sdkVersion: string;
|
|
31
|
+
device?: DeviceInfo;
|
|
32
|
+
properties?: Record<string, string | number | boolean>;
|
|
33
|
+
}
|
|
34
|
+
export interface TokenSession {
|
|
35
|
+
token: string;
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
identity: TelemetryIdentity;
|
|
38
|
+
}
|
|
39
|
+
export interface StoredQueue {
|
|
40
|
+
identityKey: string;
|
|
41
|
+
events: BehaviorEvent[];
|
|
42
|
+
}
|
|
43
|
+
/** 存储只保存事件和非敏感身份键,不允许保存采集 Token。 */
|
|
44
|
+
export interface QueueStorage {
|
|
45
|
+
load(): Promise<StoredQueue | null>;
|
|
46
|
+
save(queue: StoredQueue): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface TrackInput {
|
|
49
|
+
properties?: BehaviorEvent['properties'];
|
|
50
|
+
device?: DeviceInfo;
|
|
51
|
+
}
|
|
52
|
+
export interface TrackerOptions {
|
|
53
|
+
endpoint: string;
|
|
54
|
+
identity: TelemetryIdentity;
|
|
55
|
+
getToken: (forceRefresh: boolean) => Promise<TokenSession>;
|
|
56
|
+
storage?: QueueStorage;
|
|
57
|
+
appVersion: string;
|
|
58
|
+
anonymousId?: string;
|
|
59
|
+
device?: DeviceInfo;
|
|
60
|
+
batchSize?: number;
|
|
61
|
+
flushIntervalMs?: number;
|
|
62
|
+
maxQueueEvents?: number;
|
|
63
|
+
maxQueueBytes?: number;
|
|
64
|
+
maxAgeMs?: number;
|
|
65
|
+
fetch?: typeof globalThis.fetch;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
randomId?: () => string;
|
|
68
|
+
onDiagnostic?: (code: string, count: number) => void;
|
|
69
|
+
}
|
package/cjs/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DeviceInfo, QueueStorage, TrackerOptions } from './types.js';
|
|
2
|
+
/** 只采集基础设备属性;不读取 URL、DOM 内容、Token 或指纹。 */
|
|
3
|
+
export declare function browserDevice(): DeviceInfo;
|
|
4
|
+
export declare function indexedDbStorage(namespace?: string): QueueStorage;
|
|
5
|
+
export declare function createBrowserTracker(options: TrackerOptions): {
|
|
6
|
+
destroy(): Promise<void>;
|
|
7
|
+
ready: Promise<void>;
|
|
8
|
+
track: (eventName: import("./types.js").EventName, input?: import("./types.js").TrackInput) => Promise<void>;
|
|
9
|
+
flush: () => Promise<void>;
|
|
10
|
+
active(): Promise<void>;
|
|
11
|
+
reset(identity?: import("./types.js").TelemetryIdentity): Promise<void>;
|
|
12
|
+
};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createTracker } from './index.js';
|
|
2
|
+
/** 只采集基础设备属性;不读取 URL、DOM 内容、Token 或指纹。 */
|
|
3
|
+
export function browserDevice() {
|
|
4
|
+
if (typeof navigator === 'undefined')
|
|
5
|
+
return {};
|
|
6
|
+
const ua = navigator.userAgent;
|
|
7
|
+
const browser = /Edg\/([\d.]+)/.exec(ua) ?? /Firefox\/([\d.]+)/.exec(ua) ?? /Chrome\/([\d.]+)/.exec(ua) ?? /Version\/([\d.]+).*Safari/.exec(ua);
|
|
8
|
+
const os = /Windows NT ([\d.]+)/.exec(ua) ?? /Android ([\d.]+)/.exec(ua) ?? /(?:iPhone OS|CPU OS) ([\d_]+)/.exec(ua) ?? /Mac OS X ([\d_]+)/.exec(ua);
|
|
9
|
+
return {
|
|
10
|
+
type: /iPad|Tablet/i.test(ua) ? 'tablet' : /Mobile|iPhone/i.test(ua) ? 'mobile' : 'desktop',
|
|
11
|
+
browser: /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'unknown',
|
|
12
|
+
browserVersion: browser?.[1] ?? '',
|
|
13
|
+
os: /Windows/.test(ua) ? 'Windows' : /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iOS' : /Mac OS/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : 'unknown',
|
|
14
|
+
osVersion: os?.[1]?.replaceAll('_', '.') ?? '', language: navigator.language,
|
|
15
|
+
...(typeof screen === 'undefined' ? {} : { screenWidth: screen.width, screenHeight: screen.height }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function indexedDbStorage(namespace = 'yzm-telemetry') {
|
|
19
|
+
// 每个标签页独立队列,避免多个页面读改写同一个快照导致相互覆盖。
|
|
20
|
+
let tab = globalThis.crypto.randomUUID();
|
|
21
|
+
try {
|
|
22
|
+
const tabKey = `${namespace}:tab`;
|
|
23
|
+
tab = sessionStorage.getItem(tabKey) ?? tab;
|
|
24
|
+
sessionStorage.setItem(tabKey, tab);
|
|
25
|
+
}
|
|
26
|
+
catch { /* 隐私模式由存储失败诊断和内存队列降级。 */ }
|
|
27
|
+
const open = () => new Promise((resolve, reject) => {
|
|
28
|
+
const request = indexedDB.open(namespace, 1);
|
|
29
|
+
request.onupgradeneeded = () => request.result.createObjectStore('queues');
|
|
30
|
+
request.onsuccess = () => resolve(request.result);
|
|
31
|
+
request.onerror = () => reject(new Error('storage_failed'));
|
|
32
|
+
});
|
|
33
|
+
async function run(mode, action) {
|
|
34
|
+
const db = await open();
|
|
35
|
+
try {
|
|
36
|
+
return await new Promise((resolve, reject) => {
|
|
37
|
+
const tx = db.transaction('queues', mode);
|
|
38
|
+
const request = action(tx.objectStore('queues'));
|
|
39
|
+
tx.oncomplete = () => resolve(request.result);
|
|
40
|
+
tx.onabort = tx.onerror = () => reject(new Error('storage_failed'));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
db.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
load: async () => {
|
|
49
|
+
// 清除关闭标签页遗留的过期快照,避免长期使用积累无界的离线记录。
|
|
50
|
+
await run('readwrite', (store) => {
|
|
51
|
+
const cursor = store.openCursor();
|
|
52
|
+
cursor.onsuccess = () => {
|
|
53
|
+
const row = cursor.result;
|
|
54
|
+
if (!row)
|
|
55
|
+
return;
|
|
56
|
+
const saved = row.value;
|
|
57
|
+
if (!saved.events?.some((event) => Date.parse(event.occurredAt) >= Date.now() - 86400000))
|
|
58
|
+
row.delete();
|
|
59
|
+
row.continue();
|
|
60
|
+
};
|
|
61
|
+
return cursor;
|
|
62
|
+
});
|
|
63
|
+
return (await run('readonly', (store) => store.get(tab)));
|
|
64
|
+
},
|
|
65
|
+
save: async (value) => {
|
|
66
|
+
if (value.events.length)
|
|
67
|
+
await run('readwrite', (store) => store.put(value, tab));
|
|
68
|
+
else
|
|
69
|
+
await run('readwrite', (store) => store.delete(tab));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createBrowserTracker(options) {
|
|
74
|
+
let anonymousId = options.anonymousId;
|
|
75
|
+
try {
|
|
76
|
+
anonymousId ??= localStorage.getItem('yzm-telemetry:installation') ?? globalThis.crypto.randomUUID();
|
|
77
|
+
localStorage.setItem('yzm-telemetry:installation', anonymousId);
|
|
78
|
+
}
|
|
79
|
+
catch { /* 不支持持久化时使用本次会话随机标识。 */ }
|
|
80
|
+
const tracker = createTracker({ ...options, anonymousId, device: options.device ?? browserDevice(), storage: options.storage ?? indexedDbStorage() });
|
|
81
|
+
const flush = () => { void tracker.flush(); };
|
|
82
|
+
document.addEventListener('visibilitychange', flush);
|
|
83
|
+
window.addEventListener('pagehide', flush);
|
|
84
|
+
window.addEventListener('online', flush);
|
|
85
|
+
return {
|
|
86
|
+
...tracker,
|
|
87
|
+
destroy() {
|
|
88
|
+
document.removeEventListener('visibilitychange', flush);
|
|
89
|
+
window.removeEventListener('pagehide', flush);
|
|
90
|
+
window.removeEventListener('online', flush);
|
|
91
|
+
return tracker.destroy();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { QueueStorage } from './types.js';
|
|
2
|
+
export { createTracker } from './index.js';
|
|
3
|
+
export type * from './types.js';
|
|
4
|
+
/** 在插件 background 注入 chrome.storage.local,不在公共入口绑定 Chrome 全局对象。 */
|
|
5
|
+
export declare function extensionStorage(storage: {
|
|
6
|
+
get(key: string): Promise<Record<string, unknown>>;
|
|
7
|
+
set(items: Record<string, unknown>): Promise<void>;
|
|
8
|
+
}, namespace?: string): QueueStorage;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createTracker } from './index.js';
|
|
2
|
+
/** 在插件 background 注入 chrome.storage.local,不在公共入口绑定 Chrome 全局对象。 */
|
|
3
|
+
export function extensionStorage(storage, namespace = 'yzm-telemetry-queue') {
|
|
4
|
+
return {
|
|
5
|
+
load: async () => (await storage.get(namespace))[namespace],
|
|
6
|
+
save: async (queue) => { await storage.set({ [namespace]: queue }); },
|
|
7
|
+
};
|
|
8
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EventName, TelemetryIdentity, TrackInput, TrackerOptions } from './types.js';
|
|
2
|
+
export type { BehaviorEvent, DeviceInfo, EventName, QueueStorage, SourceApp, StoredQueue, TelemetryIdentity, TokenSession, TrackInput, TrackerOptions } from './types.js';
|
|
3
|
+
export declare const SDK_VERSION = "0.2.0";
|
|
4
|
+
export declare const identityKey: (identity: TelemetryIdentity) => string;
|
|
5
|
+
/** 与宿主登录解耦;先绑定身份,异步请求只负责采集授权,不能改变队列归属。 */
|
|
6
|
+
export declare function createTracker(options: TrackerOptions): {
|
|
7
|
+
ready: Promise<void>;
|
|
8
|
+
track: (eventName: EventName, input?: TrackInput) => Promise<void>;
|
|
9
|
+
flush: () => Promise<void>;
|
|
10
|
+
/** 只由宿主真实可见访问/交互调用,不自动通过心跳制造日活。 */
|
|
11
|
+
active(): Promise<void>;
|
|
12
|
+
reset(identity?: TelemetryIdentity): Promise<void>;
|
|
13
|
+
destroy(): Promise<void>;
|
|
14
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
export const SDK_VERSION = '0.2.0';
|
|
2
|
+
/** 至少“领域.行为.动作”三段;活跃为协议保留名,不再接受旧通用事件名。 */
|
|
3
|
+
const validEventName = (name) => typeof name === 'string' &&
|
|
4
|
+
name.length <= 100 && (name === 'app.active' || /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2,}$/.test(name));
|
|
5
|
+
export const identityKey = (identity) => JSON.stringify([
|
|
6
|
+
identity.applicationId, identity.instanceId, identity.sourceApp, identity.actorId, identity.externalOrganizationId,
|
|
7
|
+
]);
|
|
8
|
+
/** 与宿主登录解耦;先绑定身份,异步请求只负责采集授权,不能改变队列归属。 */
|
|
9
|
+
export function createTracker(options) {
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
const randomId = options.randomId ?? (() => globalThis.crypto.randomUUID());
|
|
12
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
13
|
+
const anonymousId = options.anonymousId ?? randomId();
|
|
14
|
+
const sessionId = randomId();
|
|
15
|
+
let key = identityKey(options.identity);
|
|
16
|
+
let events = [];
|
|
17
|
+
let session;
|
|
18
|
+
let generation = 0;
|
|
19
|
+
let destroyed = false;
|
|
20
|
+
let blocked = false;
|
|
21
|
+
let pending;
|
|
22
|
+
let storageWrites = Promise.resolve();
|
|
23
|
+
let abort;
|
|
24
|
+
let failures = 0;
|
|
25
|
+
let nextAttemptAt = 0;
|
|
26
|
+
let activeDay = '';
|
|
27
|
+
let batchSize = Math.max(1, Math.min(options.batchSize ?? 20, 100));
|
|
28
|
+
const maxAge = Math.min(options.maxAgeMs ?? 86_400_000, 86_400_000);
|
|
29
|
+
const diagnose = (code, count = 1) => {
|
|
30
|
+
try {
|
|
31
|
+
options.onDiagnostic?.(code, count);
|
|
32
|
+
}
|
|
33
|
+
catch { /* 诊断回调不能干扰宿主业务。 */ }
|
|
34
|
+
};
|
|
35
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
36
|
+
function trim() {
|
|
37
|
+
const before = events.length;
|
|
38
|
+
events = events.filter((event) => Date.parse(event.occurredAt) >= now() - maxAge);
|
|
39
|
+
const limit = Math.max(1, Math.min(options.maxQueueEvents ?? 500, 500));
|
|
40
|
+
const byteLimit = Math.max(8192, Math.min(options.maxQueueBytes ?? 1048576, 1048576));
|
|
41
|
+
while (events.length > limit || (events.length && bytes(events) > byteLimit))
|
|
42
|
+
events.shift();
|
|
43
|
+
if (before > events.length)
|
|
44
|
+
diagnose('queue_dropped', before - events.length);
|
|
45
|
+
}
|
|
46
|
+
function persist() {
|
|
47
|
+
const snapshot = JSON.parse(JSON.stringify({ identityKey: key, events }));
|
|
48
|
+
// 串行落盘,防止旧快照晚于 reset 完成而把旧账号队列恢复回来。
|
|
49
|
+
storageWrites = storageWrites.then(() => options.storage?.save(snapshot)).catch(() => diagnose('storage_failed'));
|
|
50
|
+
return storageWrites;
|
|
51
|
+
}
|
|
52
|
+
const ready = (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const saved = await options.storage?.load();
|
|
55
|
+
if (destroyed || generation !== 0)
|
|
56
|
+
return;
|
|
57
|
+
if (saved?.identityKey === key && Array.isArray(saved.events)) {
|
|
58
|
+
events = saved.events.filter((item) => item && typeof item.eventId === 'string' && typeof item.occurredAt === 'string' && validEventName(item.eventName));
|
|
59
|
+
trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
diagnose('storage_failed');
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
async function token(force, version) {
|
|
67
|
+
if (force || !session || Date.parse(session.expiresAt) <= now() + 10_000) {
|
|
68
|
+
const fresh = await options.getToken(force);
|
|
69
|
+
if (version !== generation || destroyed)
|
|
70
|
+
return null;
|
|
71
|
+
if (identityKey(fresh.identity) !== key) {
|
|
72
|
+
blocked = true;
|
|
73
|
+
events = [];
|
|
74
|
+
await persist();
|
|
75
|
+
diagnose('identity_changed');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!fresh.token || !Number.isFinite(Date.parse(fresh.expiresAt)) || Date.parse(fresh.expiresAt) <= now()) {
|
|
79
|
+
throw new Error('invalid_session');
|
|
80
|
+
}
|
|
81
|
+
session = fresh;
|
|
82
|
+
}
|
|
83
|
+
return session.token;
|
|
84
|
+
}
|
|
85
|
+
function backoff(retryAfter = 0) {
|
|
86
|
+
failures++;
|
|
87
|
+
nextAttemptAt = now() + Math.max(retryAfter, Math.min(60_000, 1000 * 2 ** Math.min(failures, 6)) * (0.8 + Math.random() * 0.4));
|
|
88
|
+
}
|
|
89
|
+
async function sendLoop() {
|
|
90
|
+
await ready;
|
|
91
|
+
const version = generation;
|
|
92
|
+
if (destroyed || blocked || now() < nextAttemptAt)
|
|
93
|
+
return;
|
|
94
|
+
trim();
|
|
95
|
+
await persist();
|
|
96
|
+
while (events.length && version === generation && !destroyed && !blocked) {
|
|
97
|
+
const batch = events.slice(0, batchSize);
|
|
98
|
+
const ids = new Set(batch.map((event) => event.eventId));
|
|
99
|
+
try {
|
|
100
|
+
let bearer = await token(false, version);
|
|
101
|
+
if (!bearer)
|
|
102
|
+
return;
|
|
103
|
+
let response;
|
|
104
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
105
|
+
if (version !== generation || destroyed)
|
|
106
|
+
return;
|
|
107
|
+
abort = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => abort?.abort(), 20_000);
|
|
109
|
+
try {
|
|
110
|
+
response = await fetcher(options.endpoint, {
|
|
111
|
+
method: 'POST', credentials: 'omit',
|
|
112
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
|
|
113
|
+
body: JSON.stringify({ events: batch }), signal: abort.signal,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
}
|
|
119
|
+
if (response.status !== 401 || attempt === 1)
|
|
120
|
+
break;
|
|
121
|
+
bearer = await token(true, version);
|
|
122
|
+
if (!bearer)
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (version !== generation || destroyed || !response)
|
|
126
|
+
return;
|
|
127
|
+
if (response.status === 200) {
|
|
128
|
+
const result = await response.json();
|
|
129
|
+
if (version !== generation || destroyed)
|
|
130
|
+
return;
|
|
131
|
+
if (result.accepted !== batch.length)
|
|
132
|
+
throw new Error('invalid_ack');
|
|
133
|
+
events = events.filter((event) => !ids.has(event.eventId));
|
|
134
|
+
failures = 0;
|
|
135
|
+
nextAttemptAt = 0;
|
|
136
|
+
}
|
|
137
|
+
else if (response.status === 413 && batch.length > 1) {
|
|
138
|
+
batchSize = Math.max(1, Math.floor(batch.length / 2));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
else if ([400, 413, 422].includes(response.status)) {
|
|
142
|
+
const result = await response.json().catch(() => ({}));
|
|
143
|
+
if (version !== generation || destroyed)
|
|
144
|
+
return;
|
|
145
|
+
const indices = Array.isArray(result.invalidIndices)
|
|
146
|
+
? result.invalidIndices.filter((i) => Number.isInteger(i) && i >= 0 && i < batch.length) : [];
|
|
147
|
+
const invalid = indices.length ? new Set(indices.map((i) => batch[i].eventId)) : ids;
|
|
148
|
+
events = events.filter((event) => !invalid.has(event.eventId));
|
|
149
|
+
diagnose('invalid_event', invalid.size);
|
|
150
|
+
}
|
|
151
|
+
else if (response.status === 403) {
|
|
152
|
+
blocked = true;
|
|
153
|
+
diagnose('forbidden');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const retryHeader = response.headers.get('Retry-After');
|
|
158
|
+
const retryAfter = retryHeader && /^\d+$/.test(retryHeader)
|
|
159
|
+
? Number(retryHeader) * 1000 : Math.max(0, Date.parse(retryHeader ?? '') - now()) || 0;
|
|
160
|
+
backoff(retryAfter);
|
|
161
|
+
diagnose(`http_${response.status}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await persist();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
if (version === generation && !destroyed) {
|
|
168
|
+
backoff();
|
|
169
|
+
diagnose('send_failed');
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function flush() {
|
|
176
|
+
if (!pending)
|
|
177
|
+
pending = sendLoop().catch(() => diagnose('send_failed')).finally(() => { pending = undefined; });
|
|
178
|
+
return pending;
|
|
179
|
+
}
|
|
180
|
+
async function track(eventName, input = {}) {
|
|
181
|
+
const version = generation;
|
|
182
|
+
try {
|
|
183
|
+
await ready;
|
|
184
|
+
if (destroyed || blocked || version !== generation)
|
|
185
|
+
return;
|
|
186
|
+
if (!validEventName(eventName)) {
|
|
187
|
+
diagnose('invalid_event_name');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const event = {
|
|
191
|
+
eventId: randomId(), recordType: 'event', eventName, eventVersion: 1,
|
|
192
|
+
occurredAt: new Date(now()).toISOString(), anonymousId, sessionId,
|
|
193
|
+
appVersion: options.appVersion, sdkVersion: SDK_VERSION,
|
|
194
|
+
...(input.device ?? options.device ? { device: input.device ?? options.device } : {}),
|
|
195
|
+
...(input.properties ? { properties: input.properties } : {}),
|
|
196
|
+
};
|
|
197
|
+
// 拷贝调用方对象,避免业务代码在入队后修改已产生事件。
|
|
198
|
+
if (bytes(event) > 8192) {
|
|
199
|
+
diagnose('event_too_large');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
events.push(JSON.parse(JSON.stringify(event)));
|
|
203
|
+
trim();
|
|
204
|
+
await persist();
|
|
205
|
+
if (events.length >= batchSize)
|
|
206
|
+
void flush();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
diagnose('track_failed');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const interval = options.flushIntervalMs === 0 ? undefined : setInterval(() => { void flush(); }, Math.max(100, options.flushIntervalMs ?? 10000));
|
|
213
|
+
return {
|
|
214
|
+
ready, track, flush,
|
|
215
|
+
/** 只由宿主真实可见访问/交互调用,不自动通过心跳制造日活。 */
|
|
216
|
+
active() {
|
|
217
|
+
if (options.identity.sourceApp === 'erp_backend')
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
const day = new Date(now() + 8 * 3600_000).toISOString().slice(0, 10);
|
|
220
|
+
if (day === activeDay)
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
activeDay = day;
|
|
223
|
+
return track('app.active');
|
|
224
|
+
},
|
|
225
|
+
async reset(identity) {
|
|
226
|
+
generation++;
|
|
227
|
+
abort?.abort();
|
|
228
|
+
session = undefined;
|
|
229
|
+
events = [];
|
|
230
|
+
activeDay = '';
|
|
231
|
+
blocked = false;
|
|
232
|
+
failures = 0;
|
|
233
|
+
nextAttemptAt = 0;
|
|
234
|
+
if (identity)
|
|
235
|
+
key = identityKey(identity);
|
|
236
|
+
await ready;
|
|
237
|
+
await persist();
|
|
238
|
+
},
|
|
239
|
+
async destroy() {
|
|
240
|
+
destroyed = true;
|
|
241
|
+
generation++;
|
|
242
|
+
abort?.abort();
|
|
243
|
+
if (interval !== undefined)
|
|
244
|
+
clearInterval(interval);
|
|
245
|
+
await ready;
|
|
246
|
+
await persist();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type SourceApp = 'erp_web' | 'erp_backend' | 'plugin';
|
|
2
|
+
/** 事件名就是唯一点位 key;具体业务目录由宿主与 Collector 维护。 */
|
|
3
|
+
export type EventName = string;
|
|
4
|
+
export interface TelemetryIdentity {
|
|
5
|
+
applicationId: string;
|
|
6
|
+
instanceId: string;
|
|
7
|
+
sourceApp: SourceApp;
|
|
8
|
+
actorId: string;
|
|
9
|
+
externalOrganizationId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DeviceInfo {
|
|
12
|
+
type?: string;
|
|
13
|
+
os?: string;
|
|
14
|
+
osVersion?: string;
|
|
15
|
+
browser?: string;
|
|
16
|
+
browserVersion?: string;
|
|
17
|
+
language?: string;
|
|
18
|
+
screenWidth?: number;
|
|
19
|
+
screenHeight?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface BehaviorEvent {
|
|
22
|
+
eventId: string;
|
|
23
|
+
recordType: 'event';
|
|
24
|
+
eventName: EventName;
|
|
25
|
+
eventVersion: 1;
|
|
26
|
+
occurredAt: string;
|
|
27
|
+
anonymousId: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
appVersion: string;
|
|
30
|
+
sdkVersion: string;
|
|
31
|
+
device?: DeviceInfo;
|
|
32
|
+
properties?: Record<string, string | number | boolean>;
|
|
33
|
+
}
|
|
34
|
+
export interface TokenSession {
|
|
35
|
+
token: string;
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
identity: TelemetryIdentity;
|
|
38
|
+
}
|
|
39
|
+
export interface StoredQueue {
|
|
40
|
+
identityKey: string;
|
|
41
|
+
events: BehaviorEvent[];
|
|
42
|
+
}
|
|
43
|
+
/** 存储只保存事件和非敏感身份键,不允许保存采集 Token。 */
|
|
44
|
+
export interface QueueStorage {
|
|
45
|
+
load(): Promise<StoredQueue | null>;
|
|
46
|
+
save(queue: StoredQueue): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface TrackInput {
|
|
49
|
+
properties?: BehaviorEvent['properties'];
|
|
50
|
+
device?: DeviceInfo;
|
|
51
|
+
}
|
|
52
|
+
export interface TrackerOptions {
|
|
53
|
+
endpoint: string;
|
|
54
|
+
identity: TelemetryIdentity;
|
|
55
|
+
getToken: (forceRefresh: boolean) => Promise<TokenSession>;
|
|
56
|
+
storage?: QueueStorage;
|
|
57
|
+
appVersion: string;
|
|
58
|
+
anonymousId?: string;
|
|
59
|
+
device?: DeviceInfo;
|
|
60
|
+
batchSize?: number;
|
|
61
|
+
flushIntervalMs?: number;
|
|
62
|
+
maxQueueEvents?: number;
|
|
63
|
+
maxQueueBytes?: number;
|
|
64
|
+
maxAgeMs?: number;
|
|
65
|
+
fetch?: typeof globalThis.fetch;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
randomId?: () => string;
|
|
68
|
+
onDiagnostic?: (code: string, count: number) => void;
|
|
69
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yunzhimao/telemetry",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "云智猫跨项目行为采集 SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"README.md",
|
|
9
|
+
"cjs"
|
|
10
|
+
],
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./cjs/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./browser": {
|
|
19
|
+
"types": "./dist/browser.d.ts",
|
|
20
|
+
"import": "./dist/browser.js"
|
|
21
|
+
},
|
|
22
|
+
"./extension": {
|
|
23
|
+
"types": "./dist/extension.d.ts",
|
|
24
|
+
"import": "./dist/extension.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
32
|
+
"prepack": "pnpm build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|