@clipto/reporter 0.1.0 → 0.2.1

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/dist/index.js CHANGED
@@ -4,38 +4,16 @@ function generateEventId() {
4
4
  }
5
5
 
6
6
  // src/builtins.ts
7
- var SDK_VERSION = "1.0.0";
7
+ var SDK_VERSION = "1.0";
8
8
  var DB_NAME = "CliptoReportDB";
9
9
  var STORE_NAME = "clipto-reporting";
10
10
  var QUEUE_KEY = "pending";
11
11
  var LS_KEY = "clipto_report_pending_queue";
12
12
  function buildPayload(events) {
13
13
  return {
14
- // attempts 为 SDK 内部字段,不上报;name 输出为 eventName(对齐旧 SDK)
15
- events: events.map(
16
- ({
17
- id,
18
- name,
19
- timestamp,
20
- sessionId,
21
- userId,
22
- deviceId,
23
- properties,
24
- category,
25
- context
26
- }) => ({
27
- id,
28
- eventName: name,
29
- timestamp,
30
- sessionId,
31
- userId,
32
- deviceId,
33
- properties,
34
- category,
35
- context
36
- })
37
- ),
14
+ events,
38
15
  metadata: {
16
+ type: "tracker_event",
39
17
  sdkVersion: SDK_VERSION,
40
18
  timestamp: Date.now(),
41
19
  batchId: generateEventId()
@@ -43,17 +21,26 @@ function buildPayload(events) {
43
21
  }
44
22
  };
45
23
  }
46
- function createBrowserTransport(endpoint, headers) {
24
+ async function signHeaders(payload, signer) {
25
+ if (!signer) return {};
26
+ return signer(JSON.stringify(payload));
27
+ }
28
+ function createBrowserTransport(endpoint, headers, signer) {
47
29
  const commonHeaders = {
48
30
  "Content-Type": "application/json",
49
31
  ...headers ?? {}
50
32
  };
51
33
  return {
52
34
  async send(events) {
35
+ const payload = buildPayload(events);
36
+ const requestHeaders = {
37
+ ...commonHeaders,
38
+ ...await signHeaders(payload, signer)
39
+ };
53
40
  const response = await fetch(endpoint, {
54
41
  method: "POST",
55
- headers: commonHeaders,
56
- body: JSON.stringify(buildPayload(events)),
42
+ headers: requestHeaders,
43
+ body: JSON.stringify(payload),
57
44
  keepalive: true
58
45
  });
59
46
  if (!response.ok) {
@@ -61,7 +48,7 @@ function createBrowserTransport(endpoint, headers) {
61
48
  }
62
49
  },
63
50
  sendSync(events) {
64
- if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") {
51
+ if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function" || signer) {
65
52
  return false;
66
53
  }
67
54
  try {
@@ -75,12 +62,16 @@ function createBrowserTransport(endpoint, headers) {
75
62
  }
76
63
  };
77
64
  }
78
- async function createNodeTransport(endpoint, headers) {
65
+ async function createNodeTransport(endpoint, headers, signer) {
79
66
  const { default: axios } = await import("axios");
80
67
  return {
81
68
  async send(events) {
82
- await axios.post(endpoint, buildPayload(events), {
83
- headers,
69
+ const payload = buildPayload(events);
70
+ await axios.post(endpoint, payload, {
71
+ headers: {
72
+ ...headers ?? {},
73
+ ...await signHeaders(payload, signer)
74
+ },
84
75
  timeout: 1e4
85
76
  });
86
77
  }
@@ -158,6 +149,167 @@ async function createNodeStorage(persistDir) {
158
149
  };
159
150
  }
160
151
 
152
+ // src/collect.ts
153
+ import { UAParser } from "ua-parser-js";
154
+ function parseUserAgent(ua) {
155
+ const parsed = new UAParser(ua);
156
+ const browser = parsed.getBrowser();
157
+ const os = parsed.getOS();
158
+ const info = {};
159
+ if (browser.name) {
160
+ info.browserName = browser.name === "Mobile Safari" ? "Safari" : browser.name === "Mobile Chrome" ? "Chrome" : browser.name;
161
+ }
162
+ if (browser.version) info.browserVersion = browser.version;
163
+ if (os.name) info.osName = os.name === "Mac OS" ? "macOS" : os.name;
164
+ if (os.version) info.osVersion = os.version;
165
+ return info;
166
+ }
167
+ function collectBrowserDevice() {
168
+ if (typeof navigator === "undefined") return {};
169
+ const { browserName, browserVersion, osName, osVersion } = parseUserAgent(
170
+ navigator.userAgent || ""
171
+ );
172
+ const device = {};
173
+ if (browserName) device.browserName = browserName;
174
+ if (browserVersion) device.browserVersion = browserVersion;
175
+ if (osName) device.osName = osName;
176
+ if (osVersion) device.osVersion = osVersion;
177
+ const uaData = navigator.userAgentData;
178
+ if (uaData?.platform) device.platform = uaData.platform;
179
+ else if (navigator.platform) device.platform = navigator.platform;
180
+ if (navigator.language) device.language = navigator.language;
181
+ return device;
182
+ }
183
+ async function collectNodeDevice() {
184
+ try {
185
+ const os = await import("os");
186
+ const device = {
187
+ platform: os.platform(),
188
+ osVersion: os.release(),
189
+ arch: os.arch(),
190
+ memSize: Math.round(os.totalmem() / 1024 ** 3)
191
+ // 统一 GB
192
+ };
193
+ const type = os.type();
194
+ device.osName = type === "Darwin" ? "macOS" : type === "Windows_NT" ? "Windows" : type;
195
+ const cpu = os.cpus()[0]?.model;
196
+ if (cpu) device.cpu = cpu;
197
+ return device;
198
+ } catch {
199
+ return {};
200
+ }
201
+ }
202
+ function collectPageSnapshot() {
203
+ if (typeof document === "undefined") return {};
204
+ const page = {};
205
+ if (location.href) page.url = location.href;
206
+ if (document.title) page.title = document.title;
207
+ if (document.referrer) page.referrer = document.referrer;
208
+ return page;
209
+ }
210
+
211
+ // src/page.ts
212
+ var SCROLL_GESTURE_GAP_MS = 500;
213
+ var IDLE_PAUSE_MS = 6e4;
214
+ var METRIC_TICK_MS = 1e3;
215
+ function createPageTracker(onActivity) {
216
+ let visit = null;
217
+ let interval = null;
218
+ const touch = () => {
219
+ if (!visit) return;
220
+ visit.lastActivityAt = Date.now();
221
+ onActivity();
222
+ };
223
+ const updateMaxDepth = () => {
224
+ if (!visit) return;
225
+ const doc = document.documentElement;
226
+ if (!doc) return;
227
+ const total = Math.max(doc.scrollHeight, 1);
228
+ const depth = Math.round(
229
+ (window.scrollY + window.innerHeight) / total * 100
230
+ );
231
+ visit.maxScrollDepthPct = Math.max(
232
+ visit.maxScrollDepthPct,
233
+ Math.min(depth, 100)
234
+ );
235
+ };
236
+ const onScroll = () => {
237
+ if (!visit) return;
238
+ const now = Date.now();
239
+ if (now - visit.lastScrollAt > SCROLL_GESTURE_GAP_MS) {
240
+ visit.scrollCount += 1;
241
+ }
242
+ visit.lastScrollAt = now;
243
+ touch();
244
+ updateMaxDepth();
245
+ };
246
+ const onInput = () => touch();
247
+ const tick = () => {
248
+ if (!visit) return;
249
+ if (document.visibilityState === "visible" && Date.now() - visit.lastActivityAt <= IDLE_PAUSE_MS) {
250
+ visit.activeMs += METRIC_TICK_MS;
251
+ }
252
+ updateMaxDepth();
253
+ };
254
+ const removeListeners = () => {
255
+ window.removeEventListener("scroll", onScroll);
256
+ window.removeEventListener("pointerdown", onInput, { capture: true });
257
+ window.removeEventListener("keydown", onInput, { capture: true });
258
+ if (interval) {
259
+ clearInterval(interval);
260
+ interval = null;
261
+ }
262
+ };
263
+ const startVisit = () => {
264
+ if (visit) return;
265
+ const now = Date.now();
266
+ visit = {
267
+ pageViewId: `pv_${generateEventId()}`,
268
+ enteredAt: now,
269
+ snapshot: collectPageSnapshot(),
270
+ activeMs: 0,
271
+ scrollCount: 0,
272
+ maxScrollDepthPct: 0,
273
+ lastActivityAt: now,
274
+ lastScrollAt: 0
275
+ };
276
+ window.addEventListener("scroll", onScroll, { passive: true });
277
+ window.addEventListener("pointerdown", onInput, {
278
+ capture: true,
279
+ passive: true
280
+ });
281
+ window.addEventListener("keydown", onInput, { capture: true, passive: true });
282
+ interval = setInterval(tick, METRIC_TICK_MS);
283
+ };
284
+ const endVisit = (reason) => {
285
+ if (!visit) return void 0;
286
+ removeListeners();
287
+ const finished = visit;
288
+ visit = null;
289
+ if (document.title) {
290
+ finished.snapshot.title = document.title;
291
+ }
292
+ return {
293
+ ...finished.snapshot,
294
+ pageViewId: finished.pageViewId,
295
+ durationMs: Date.now() - finished.enteredAt,
296
+ activeMs: finished.activeMs,
297
+ scrollCount: finished.scrollCount,
298
+ maxScrollDepthPct: finished.maxScrollDepthPct,
299
+ endReason: reason
300
+ };
301
+ };
302
+ return {
303
+ get active() {
304
+ return visit !== null;
305
+ },
306
+ startVisit,
307
+ endVisit,
308
+ pageViewId: () => visit?.pageViewId,
309
+ dispose: removeListeners
310
+ };
311
+ }
312
+
161
313
  // src/reporter.ts
162
314
  var DEFAULT_FLUSH_INTERVAL = 2e3;
163
315
  var DEFAULT_MAX_BATCH_SIZE = 10;
@@ -166,11 +318,33 @@ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
166
318
  var DEFAULT_MAX_ATTEMPTS = 3;
167
319
  var DEFAULT_BACKOFF_MS = 1e3;
168
320
  var STORAGE_WRITE_THROTTLE_MS = 500;
321
+ var VISIT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
322
+ var SESSION_SAVE_THROTTLE_MS = 1e4;
323
+ var ANONYMOUS_ID_KEY = "clipto_report_anonymous_id";
324
+ var CLIENT_ID_KEY = "clipto_report_client_id";
325
+ var SESSION_KEY = "clipto_report_session";
326
+ function normalizeContext(context) {
327
+ if (typeof context === "function") return context;
328
+ return () => context ?? {};
329
+ }
330
+ function stripAttempts(events) {
331
+ return events.map((event) => {
332
+ const { attempts: _attempts, ...rest } = event;
333
+ return rest;
334
+ });
335
+ }
169
336
  var Reporter = class {
170
337
  constructor(options) {
171
- /** 会话 / 设备 id 兜底:enrich 未提供时按实例生成(对齐旧 SDK 格式) */
172
- this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
173
- this.deviceId = `device_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
338
+ /** 会话落盘时间戳(节流用);声明在 session 之前:session 初始化会触发强制落盘 */
339
+ this.sessionSaveAt = 0;
340
+ /** 会话共享读时间戳(节流用):多 tab 共享会话,活动时定期重读 localStorage 最新值 */
341
+ this.sessionReadAt = 0;
342
+ /** Node 端设备快照(init 时采集一次,避免每次入队动态加载 os) */
343
+ this.nodeDevice = {};
344
+ /** 页面访问跟踪(browser + autoPageView 时启用) */
345
+ this.pageTracker = null;
346
+ /** history 补丁的原始引用(dispose 时还原) */
347
+ this.historyPatch = null;
174
348
  /** 待发队列(内存中的事实源,持久化是它的影子) */
175
349
  this.queue = [];
176
350
  this.flushTimer = null;
@@ -183,13 +357,31 @@ var Reporter = class {
183
357
  this.fire(this.dispose());
184
358
  };
185
359
  this.onPageHide = () => {
360
+ this.endPageVisit(this.session.visitId, "close");
186
361
  this.fire(this.dispose());
187
362
  };
363
+ this.onPageShow = () => {
364
+ if (document.visibilityState === "visible") {
365
+ this.pageTracker?.startVisit();
366
+ }
367
+ };
188
368
  this.onVisibilityChange = () => {
189
369
  if (document.visibilityState === "hidden") {
370
+ this.endPageVisit(this.session.visitId, "background");
190
371
  this.fire(this.flush());
191
- } else if (this.opts.autoPageView) {
192
- this.track("page_view");
372
+ } else {
373
+ this.pageTracker?.startVisit();
374
+ }
375
+ };
376
+ this.onPopState = () => {
377
+ this.endPageVisit(this.session.visitId, "back");
378
+ this.pageTracker?.startVisit();
379
+ };
380
+ this.onStorage = (event) => {
381
+ if (event.key !== SESSION_KEY || !event.newValue) return;
382
+ try {
383
+ this.adoptSharedSession(JSON.parse(event.newValue));
384
+ } catch {
193
385
  }
194
386
  };
195
387
  if (!options.endpoint) {
@@ -197,6 +389,7 @@ var Reporter = class {
197
389
  }
198
390
  this.opts = options;
199
391
  this.platform = options.platform ?? (typeof window !== "undefined" ? "browser" : "node");
392
+ this.contextFn = normalizeContext(options.context);
200
393
  this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
201
394
  this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
202
395
  this.maxBatchBytes = options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
@@ -205,16 +398,20 @@ var Reporter = class {
205
398
  this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
206
399
  this.backoffMs = options.backoffMs ?? DEFAULT_BACKOFF_MS;
207
400
  this.flushOnExit = options.flushOnExit ?? true;
401
+ this._anonymousId = this.loadOrCreateAnonymousId();
402
+ this._clientId = this.loadOrCreateClientId();
403
+ this.session = this.loadOrCreateSession();
208
404
  if (options.transport) {
209
405
  this.transportPromise = Promise.resolve(options.transport);
210
406
  } else if (this.platform === "browser") {
211
407
  this.transportPromise = Promise.resolve(
212
- createBrowserTransport(options.endpoint, options.headers)
408
+ createBrowserTransport(options.endpoint, options.headers, options.signer)
213
409
  );
214
410
  } else {
215
411
  this.transportPromise = createNodeTransport(
216
412
  options.endpoint,
217
- options.headers
413
+ options.headers,
414
+ options.signer
218
415
  );
219
416
  }
220
417
  if (options.storage) {
@@ -230,35 +427,20 @@ var Reporter = class {
230
427
  }
231
428
  this.fire(this.init());
232
429
  }
233
- track(name, properties, options) {
430
+ track(eventName, properties, options) {
234
431
  if (this.disposed) {
235
- this.log("dispose \u540E\u5FFD\u7565\u65B0\u4E8B\u4EF6:", name);
432
+ this.log("dispose \u540E\u5FFD\u7565\u65B0\u4E8B\u4EF6:", eventName);
236
433
  return "";
237
434
  }
238
- const enriched = this.opts.enrich?.() ?? {};
239
- const event = {
240
- id: generateEventId(),
241
- name,
242
- properties: {
243
- ...this.opts.baseProperties ?? {},
244
- ...this.opts.context?.() ?? {},
245
- ...properties ?? {}
246
- },
247
- timestamp: options?.timestamp ?? Date.now(),
248
- attempts: 0,
249
- sessionId: enriched.sessionId ?? this.sessionId,
250
- userId: enriched.userId ?? null,
251
- deviceId: enriched.deviceId ?? this.deviceId,
252
- category: options?.category ?? "custom",
253
- context: enriched.context ?? {}
254
- };
255
- if (typeof name !== "string" || name.length === 0) {
256
- this.opts.onDropped?.([event], "invalid");
435
+ if (typeof eventName !== "string" || eventName.length === 0) {
436
+ this.opts.onDropped?.([this.buildEvent("", properties)], "invalid");
257
437
  return "";
258
438
  }
439
+ this.touchSession();
440
+ const event = this.buildEvent(eventName, properties);
259
441
  if (options?.immediate) {
260
- this.fire(this.sendBatch([event]));
261
- return event.id;
442
+ this.fire(this.sendBatch([{ ...event, attempts: 0 }]));
443
+ return event.eventId;
262
444
  }
263
445
  this.enqueue(event);
264
446
  if (this.flushInterval <= 0) {
@@ -266,7 +448,7 @@ var Reporter = class {
266
448
  } else {
267
449
  this.scheduleFlush();
268
450
  }
269
- return event.id;
451
+ return event.eventId;
270
452
  }
271
453
  flush() {
272
454
  if (!this.flushPromise) {
@@ -283,6 +465,22 @@ var Reporter = class {
283
465
  get size() {
284
466
  return this.queue.length;
285
467
  }
468
+ /** 匿名 id(只读):首次生成后持久化不变;供外部转化跟踪等场景读取 */
469
+ get anonymousId() {
470
+ return this._anonymousId;
471
+ }
472
+ /** 设备 / 安装实例 id(只读):Web&PC 与 google client id 一致;供外部转化跟踪等场景读取 */
473
+ get clientId() {
474
+ return this._clientId;
475
+ }
476
+ /** 当前会话 id(只读):会话轮换后返回新值;供外部转化跟踪等场景读取 */
477
+ get visitId() {
478
+ return this.session.visitId;
479
+ }
480
+ /** 当前登录账号 id(只读):实时调用 userId provider,未提供恒为 null */
481
+ get userId() {
482
+ return this.opts.userId?.() ?? null;
483
+ }
286
484
  async dispose() {
287
485
  if (this.disposed) return;
288
486
  this.disposed = true;
@@ -300,7 +498,16 @@ var Reporter = class {
300
498
  }
301
499
  if (this.platform === "browser") {
302
500
  window.removeEventListener("pagehide", this.onPageHide);
501
+ window.removeEventListener("pageshow", this.onPageShow);
502
+ window.removeEventListener("storage", this.onStorage);
303
503
  document.removeEventListener("visibilitychange", this.onVisibilityChange);
504
+ this.restoreHistory();
505
+ if (this.pageTracker) {
506
+ this.endPageVisit(this.session.visitId, "unknown");
507
+ this.pageTracker.dispose();
508
+ this.pageTracker = null;
509
+ }
510
+ this.saveSession(true);
304
511
  } else {
305
512
  const exitProcess = this.getExitProcess();
306
513
  exitProcess.off("beforeExit", this.onExit);
@@ -316,17 +523,18 @@ var Reporter = class {
316
523
  }
317
524
  if (this.flushOnExit) {
318
525
  const pending = this.queue.splice(0);
319
- if (transport.sendSync?.(pending) ?? false) {
320
- this.opts.onFlushed?.(pending);
526
+ const wire = stripAttempts(pending);
527
+ if (transport.sendSync?.(wire) ?? false) {
528
+ this.opts.onFlushed?.(wire);
321
529
  await this.persistNow([]);
322
530
  return;
323
531
  }
324
532
  try {
325
- await transport.send(pending);
326
- this.opts.onFlushed?.(pending);
533
+ await transport.send(wire);
534
+ this.opts.onFlushed?.(wire);
327
535
  await this.persistNow([]);
328
536
  } catch (error) {
329
- this.opts.onFailed?.(pending, error);
537
+ this.opts.onFailed?.(wire, error);
330
538
  this.queue.push(...pending);
331
539
  await this.persistNow();
332
540
  }
@@ -336,9 +544,12 @@ var Reporter = class {
336
544
  await this.persistNow();
337
545
  }
338
546
  }
339
- /** 异步初始化:创建内置持久化、恢复上次会话队列、挂载退出兜底 */
547
+ /** 异步初始化:创建内置持久化、采集 Node 设备快照、恢复上次会话队列、挂载退出兜底 */
340
548
  async init() {
341
549
  this.storage = await this.storagePromise;
550
+ if (this.platform === "node") {
551
+ this.nodeDevice = await collectNodeDevice();
552
+ }
342
553
  if (this.disposed) return;
343
554
  if (this.storage) {
344
555
  try {
@@ -352,6 +563,7 @@ var Reporter = class {
352
563
  }
353
564
  if (this.disposed) return;
354
565
  if (this.platform === "browser") {
566
+ window.addEventListener("storage", this.onStorage);
355
567
  if (this.flushOnExit) {
356
568
  window.addEventListener("pagehide", this.onPageHide);
357
569
  }
@@ -359,7 +571,10 @@ var Reporter = class {
359
571
  document.addEventListener("visibilitychange", this.onVisibilityChange);
360
572
  }
361
573
  if (this.opts.autoPageView) {
362
- this.track("page_view");
574
+ this.pageTracker = createPageTracker(() => this.touchSession());
575
+ this.pageTracker.startVisit();
576
+ this.patchHistory();
577
+ window.addEventListener("pageshow", this.onPageShow);
363
578
  }
364
579
  } else if (this.flushOnExit) {
365
580
  const exitProcess = this.getExitProcess();
@@ -372,9 +587,118 @@ var Reporter = class {
372
587
  this.schedulePersist();
373
588
  }
374
589
  }
375
- /** 恢复队列入队:历史事件放队头保证先到先发,超限按溢出丢弃 */
590
+ /** 组装上报事件:身份字段由 SDK 填充,业务节点 = SDK 采集 + context 合并(接入方值优先) */
591
+ buildEvent(eventName, properties, overrides) {
592
+ const context = this.contextFn();
593
+ const device = {
594
+ ...this.nodeDevice,
595
+ ...collectBrowserDevice(),
596
+ ...context.device ?? {}
597
+ };
598
+ const page = {
599
+ ...collectPageSnapshot(),
600
+ ...this.pageSnapshot(),
601
+ ...context.page ?? {}
602
+ };
603
+ const mergedProperties = { ...context.properties ?? {}, ...properties ?? {} };
604
+ if (!context.app) {
605
+ this.log("context \u672A\u63D0\u4F9B app \u8282\u70B9,\u4EE5\u7A7A\u5BF9\u8C61\u4E0A\u62A5");
606
+ }
607
+ return {
608
+ eventId: generateEventId(),
609
+ eventName,
610
+ userId: this.opts.userId?.() ?? null,
611
+ anonymousId: this._anonymousId,
612
+ visitId: this.session.visitId,
613
+ clientId: this._clientId,
614
+ timestamp: Date.now(),
615
+ app: context.app ?? {},
616
+ ...Object.keys(device).length > 0 ? { device } : {},
617
+ ...Object.keys(page).length > 0 ? { page } : {},
618
+ ...context.user ? { user: context.user } : {},
619
+ ...Object.keys(mergedProperties).length > 0 ? { properties: mergedProperties } : {},
620
+ ...overrides
621
+ };
622
+ }
623
+ /** 进行中页面访问的 pageViewId(非访问期返回空) */
624
+ pageSnapshot() {
625
+ if (!this.pageTracker?.active) return {};
626
+ const pageViewId = this.pageTracker.pageViewId();
627
+ return pageViewId ? { pageViewId } : {};
628
+ }
629
+ /** 会话活跃:track 调用与页面交互均触发;超时轮换 visitId 并结束旧页面访问 */
630
+ touchSession() {
631
+ const now = Date.now();
632
+ if (now - this.sessionReadAt >= SESSION_SAVE_THROTTLE_MS) {
633
+ this.sessionReadAt = now;
634
+ this.refreshSessionFromShared();
635
+ }
636
+ if (now - this.session.lastActivityMs > VISIT_IDLE_TIMEOUT_MS) {
637
+ const oldVisitId = this.session.visitId;
638
+ this.endPageVisit(oldVisitId, "sessionTimeout");
639
+ this.session = { visitId: this.newId("visit"), lastActivityMs: now };
640
+ this.saveSession(true);
641
+ this.pageTracker?.startVisit();
642
+ } else {
643
+ this.session.lastActivityMs = now;
644
+ this.saveSession();
645
+ }
646
+ }
647
+ /** 结束当前页面访问并上报 pageView 汇总事件(复用进入页面时的 visitId) */
648
+ endPageVisit(visitId, reason) {
649
+ if (!this.pageTracker?.active) return;
650
+ const summary = this.pageTracker.endVisit(reason);
651
+ if (!summary) return;
652
+ const context = this.contextFn();
653
+ this.enqueue(
654
+ this.buildEvent("pageView", void 0, {
655
+ visitId,
656
+ page: { ...summary, ...context.page ?? {} }
657
+ })
658
+ );
659
+ this.scheduleFlush();
660
+ }
661
+ /** SPA 路由变化检测:pushState / replaceState 视为 routeChange,popstate 视为 back */
662
+ patchHistory() {
663
+ if (this.historyPatch) return;
664
+ const endVisit = (reason) => {
665
+ this.endPageVisit(this.session.visitId, reason);
666
+ this.pageTracker?.startVisit();
667
+ };
668
+ const pushState = history.pushState.bind(history);
669
+ const replaceState = history.replaceState.bind(history);
670
+ history.pushState = function(...args) {
671
+ const result = pushState(...args);
672
+ endVisit("routeChange");
673
+ return result;
674
+ };
675
+ history.replaceState = function(...args) {
676
+ const result = replaceState(...args);
677
+ endVisit("routeChange");
678
+ return result;
679
+ };
680
+ window.addEventListener("popstate", this.onPopState);
681
+ this.historyPatch = { pushState, replaceState };
682
+ }
683
+ restoreHistory() {
684
+ if (!this.historyPatch) return;
685
+ history.pushState = this.historyPatch.pushState;
686
+ history.replaceState = this.historyPatch.replaceState;
687
+ window.removeEventListener("popstate", this.onPopState);
688
+ this.historyPatch = null;
689
+ }
690
+ /** 恢复队列入队:历史事件放队头保证先到先发,超限按溢出丢弃;过滤旧格式(无 eventId)事件 */
376
691
  enqueueRestored(events) {
377
- let accepted = events;
692
+ let accepted = events.filter(
693
+ (event) => typeof event.eventId === "string" && typeof event.eventName === "string"
694
+ );
695
+ if (accepted.length < events.length) {
696
+ this.log(`\u8DF3\u8FC7 ${events.length - accepted.length} \u6761\u65E7\u683C\u5F0F\u6301\u4E45\u5316\u4E8B\u4EF6`);
697
+ }
698
+ accepted = accepted.map((event) => ({
699
+ ...event,
700
+ attempts: typeof event.attempts === "number" ? event.attempts : 0
701
+ }));
378
702
  if (accepted.length > this.maxQueueSize) {
379
703
  this.opts.onDropped?.(accepted.slice(this.maxQueueSize), "overflow");
380
704
  accepted = accepted.slice(0, this.maxQueueSize);
@@ -382,6 +706,7 @@ var Reporter = class {
382
706
  this.queue.unshift(...accepted);
383
707
  }
384
708
  enqueue(event) {
709
+ const queued = { ...event, attempts: 0 };
385
710
  if (this.queue.length >= this.maxQueueSize) {
386
711
  if (this.overflowPolicy === "drop-newest") {
387
712
  this.opts.onDropped?.([event], "overflow");
@@ -393,7 +718,7 @@ var Reporter = class {
393
718
  this.fire(this.flush());
394
719
  }
395
720
  }
396
- this.queue.push(event);
721
+ this.queue.push(queued);
397
722
  this.schedulePersist();
398
723
  }
399
724
  /** 攒批定时:延迟上报的实现,窗口内事件合并为一批 */
@@ -426,12 +751,13 @@ var Reporter = class {
426
751
  }
427
752
  async sendBatch(batch) {
428
753
  const transport = await this.transportPromise;
754
+ const wire = stripAttempts(batch);
429
755
  try {
430
- await transport.send(batch);
431
- this.opts.onFlushed?.(batch);
756
+ await transport.send(wire);
757
+ this.opts.onFlushed?.(wire);
432
758
  this.schedulePersist();
433
759
  } catch (error) {
434
- this.opts.onFailed?.(batch, error);
760
+ this.opts.onFailed?.(wire, error);
435
761
  this.log("send batch failed:", error);
436
762
  const updated = batch.map((event) => ({
437
763
  ...event,
@@ -485,6 +811,99 @@ var Reporter = class {
485
811
  this.log("persist queue failed:", error);
486
812
  }
487
813
  }
814
+ // ============================================================
815
+ // 身份与会话持久化(browser: localStorage;Node: 进程内)。
816
+ // 会话存 localStorage 而非 sessionStorage:同源多 tab 共享一个会话,
817
+ // "连续 30 分钟无活动"是用户级语义,不应按 tab 拆分;跨 tab 同步靠
818
+ // storage 事件(实时)+ 活动时重读(兜底)。
819
+ // ============================================================
820
+ /** 匿名 id:localStorage 持久化,首次匿名访问生成后保留;Node 无持久化介质,进程内生成 */
821
+ loadOrCreateAnonymousId() {
822
+ const stored = this.readPersistent(ANONYMOUS_ID_KEY);
823
+ if (stored) return stored;
824
+ const fresh = this.newId("anonymous");
825
+ this.writePersistent(ANONYMOUS_ID_KEY, fresh);
826
+ return fresh;
827
+ }
828
+ /** clientId:Web&PC 与 google client id(_ga cookie)一致;否则本地生成并持久化 */
829
+ loadOrCreateClientId() {
830
+ if (typeof document !== "undefined") {
831
+ const ga = document.cookie.match(/(?:^|;\s*)_ga=GA1\.\d\.([^;]+)/);
832
+ if (ga?.[1]) return ga[1];
833
+ const stored = this.readPersistent(CLIENT_ID_KEY);
834
+ if (stored) return stored;
835
+ }
836
+ const fresh = this.newId("client");
837
+ this.writePersistent(CLIENT_ID_KEY, fresh);
838
+ return fresh;
839
+ }
840
+ /** 会话恢复:localStorage(同源多 tab 共享)中 30 分钟窗口内的会话复用,否则新建 */
841
+ loadOrCreateSession() {
842
+ const now = Date.now();
843
+ try {
844
+ const text = this.readPersistent(SESSION_KEY);
845
+ if (text) {
846
+ const stored = JSON.parse(text);
847
+ if (stored.visitId && now - stored.lastActivityMs <= VISIT_IDLE_TIMEOUT_MS) {
848
+ return stored;
849
+ }
850
+ }
851
+ } catch {
852
+ }
853
+ const fresh = { visitId: this.newId("visit"), lastActivityMs: now };
854
+ this.session = fresh;
855
+ this.saveSession(true);
856
+ return fresh;
857
+ }
858
+ /** 会话落盘(localStorage):节流 10s,避免滚动等高频活动频繁写;force 用于轮换 / 退出兜底 */
859
+ saveSession(force = false) {
860
+ const now = Date.now();
861
+ if (!force && this.sessionSaveAt > 0 && now - this.sessionSaveAt < SESSION_SAVE_THROTTLE_MS) {
862
+ return;
863
+ }
864
+ this.sessionSaveAt = now;
865
+ this.writePersistent(SESSION_KEY, JSON.stringify(this.session));
866
+ }
867
+ /** 采纳共享会话:visitId 变化视为其他 tab 轮换了会话,结束本 tab 旧页面访问并开启新访问 */
868
+ adoptSharedSession(stored) {
869
+ if (!stored.visitId || stored.visitId === this.session.visitId) {
870
+ if (stored.visitId) this.session = stored;
871
+ return;
872
+ }
873
+ const oldVisitId = this.session.visitId;
874
+ this.endPageVisit(oldVisitId, "sessionTimeout");
875
+ this.pageTracker?.startVisit();
876
+ this.session = stored;
877
+ }
878
+ /** 活动时重读共享会话(兜底 storage 事件丢失):storage 事件只在其他 tab 触发 */
879
+ refreshSessionFromShared() {
880
+ try {
881
+ const text = this.readPersistent(SESSION_KEY);
882
+ if (text) {
883
+ this.adoptSharedSession(JSON.parse(text));
884
+ }
885
+ } catch {
886
+ }
887
+ }
888
+ readPersistent(key) {
889
+ if (this.platform !== "browser") return null;
890
+ try {
891
+ return localStorage.getItem(key);
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+ writePersistent(key, value) {
897
+ if (this.platform !== "browser") return;
898
+ try {
899
+ localStorage.setItem(key, value);
900
+ } catch {
901
+ }
902
+ }
903
+ /** 生成带前缀的实例 id(格式对齐旧 SDK session_ / device_) */
904
+ newId(prefix) {
905
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
906
+ }
488
907
  log(...args) {
489
908
  if (this.opts.debug) {
490
909
  console.log("[reporter]", ...args);