@clipto/reporter 0.1.0 → 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/dist/index.cjs CHANGED
@@ -52,30 +52,7 @@ var QUEUE_KEY = "pending";
52
52
  var LS_KEY = "clipto_report_pending_queue";
53
53
  function buildPayload(events) {
54
54
  return {
55
- // attempts 为 SDK 内部字段,不上报;name 输出为 eventName(对齐旧 SDK)
56
- events: events.map(
57
- ({
58
- id,
59
- name,
60
- timestamp,
61
- sessionId,
62
- userId,
63
- deviceId,
64
- properties,
65
- category,
66
- context
67
- }) => ({
68
- id,
69
- eventName: name,
70
- timestamp,
71
- sessionId,
72
- userId,
73
- deviceId,
74
- properties,
75
- category,
76
- context
77
- })
78
- ),
55
+ events,
79
56
  metadata: {
80
57
  sdkVersion: SDK_VERSION,
81
58
  timestamp: Date.now(),
@@ -84,17 +61,26 @@ function buildPayload(events) {
84
61
  }
85
62
  };
86
63
  }
87
- function createBrowserTransport(endpoint, headers) {
64
+ async function signHeaders(payload, signer) {
65
+ if (!signer) return {};
66
+ return signer(JSON.stringify(payload));
67
+ }
68
+ function createBrowserTransport(endpoint, headers, signer) {
88
69
  const commonHeaders = {
89
70
  "Content-Type": "application/json",
90
71
  ...headers ?? {}
91
72
  };
92
73
  return {
93
74
  async send(events) {
75
+ const payload = buildPayload(events);
76
+ const requestHeaders = {
77
+ ...commonHeaders,
78
+ ...await signHeaders(payload, signer)
79
+ };
94
80
  const response = await fetch(endpoint, {
95
81
  method: "POST",
96
- headers: commonHeaders,
97
- body: JSON.stringify(buildPayload(events)),
82
+ headers: requestHeaders,
83
+ body: JSON.stringify(payload),
98
84
  keepalive: true
99
85
  });
100
86
  if (!response.ok) {
@@ -102,7 +88,7 @@ function createBrowserTransport(endpoint, headers) {
102
88
  }
103
89
  },
104
90
  sendSync(events) {
105
- if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") {
91
+ if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function" || signer) {
106
92
  return false;
107
93
  }
108
94
  try {
@@ -116,12 +102,16 @@ function createBrowserTransport(endpoint, headers) {
116
102
  }
117
103
  };
118
104
  }
119
- async function createNodeTransport(endpoint, headers) {
105
+ async function createNodeTransport(endpoint, headers, signer) {
120
106
  const { default: axios } = await import("axios");
121
107
  return {
122
108
  async send(events) {
123
- await axios.post(endpoint, buildPayload(events), {
124
- headers,
109
+ const payload = buildPayload(events);
110
+ await axios.post(endpoint, payload, {
111
+ headers: {
112
+ ...headers ?? {},
113
+ ...await signHeaders(payload, signer)
114
+ },
125
115
  timeout: 1e4
126
116
  });
127
117
  }
@@ -199,6 +189,167 @@ async function createNodeStorage(persistDir) {
199
189
  };
200
190
  }
201
191
 
192
+ // src/collect.ts
193
+ var import_ua_parser_js = require("ua-parser-js");
194
+ function parseUserAgent(ua) {
195
+ const parsed = new import_ua_parser_js.UAParser(ua);
196
+ const browser = parsed.getBrowser();
197
+ const os = parsed.getOS();
198
+ const info = {};
199
+ if (browser.name) {
200
+ info.browserName = browser.name === "Mobile Safari" ? "Safari" : browser.name === "Mobile Chrome" ? "Chrome" : browser.name;
201
+ }
202
+ if (browser.version) info.browserVersion = browser.version;
203
+ if (os.name) info.osName = os.name === "Mac OS" ? "macOS" : os.name;
204
+ if (os.version) info.osVersion = os.version;
205
+ return info;
206
+ }
207
+ function collectBrowserDevice() {
208
+ if (typeof navigator === "undefined") return {};
209
+ const { browserName, browserVersion, osName, osVersion } = parseUserAgent(
210
+ navigator.userAgent || ""
211
+ );
212
+ const device = {};
213
+ if (browserName) device.browserName = browserName;
214
+ if (browserVersion) device.browserVersion = browserVersion;
215
+ if (osName) device.osName = osName;
216
+ if (osVersion) device.osVersion = osVersion;
217
+ const uaData = navigator.userAgentData;
218
+ if (uaData?.platform) device.platform = uaData.platform;
219
+ else if (navigator.platform) device.platform = navigator.platform;
220
+ if (navigator.language) device.language = navigator.language;
221
+ return device;
222
+ }
223
+ async function collectNodeDevice() {
224
+ try {
225
+ const os = await import("os");
226
+ const device = {
227
+ platform: os.platform(),
228
+ osVersion: os.release(),
229
+ arch: os.arch(),
230
+ memSize: Math.round(os.totalmem() / 1024 ** 3)
231
+ // 统一 GB
232
+ };
233
+ const type = os.type();
234
+ device.osName = type === "Darwin" ? "macOS" : type === "Windows_NT" ? "Windows" : type;
235
+ const cpu = os.cpus()[0]?.model;
236
+ if (cpu) device.cpu = cpu;
237
+ return device;
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+ function collectPageSnapshot() {
243
+ if (typeof document === "undefined") return {};
244
+ const page = {};
245
+ if (location.href) page.url = location.href;
246
+ if (document.title) page.title = document.title;
247
+ if (document.referrer) page.referrer = document.referrer;
248
+ return page;
249
+ }
250
+
251
+ // src/page.ts
252
+ var SCROLL_GESTURE_GAP_MS = 500;
253
+ var IDLE_PAUSE_MS = 6e4;
254
+ var METRIC_TICK_MS = 1e3;
255
+ function createPageTracker(onActivity) {
256
+ let visit = null;
257
+ let interval = null;
258
+ const touch = () => {
259
+ if (!visit) return;
260
+ visit.lastActivityAt = Date.now();
261
+ onActivity();
262
+ };
263
+ const updateMaxDepth = () => {
264
+ if (!visit) return;
265
+ const doc = document.documentElement;
266
+ if (!doc) return;
267
+ const total = Math.max(doc.scrollHeight, 1);
268
+ const depth = Math.round(
269
+ (window.scrollY + window.innerHeight) / total * 100
270
+ );
271
+ visit.maxScrollDepthPct = Math.max(
272
+ visit.maxScrollDepthPct,
273
+ Math.min(depth, 100)
274
+ );
275
+ };
276
+ const onScroll = () => {
277
+ if (!visit) return;
278
+ const now = Date.now();
279
+ if (now - visit.lastScrollAt > SCROLL_GESTURE_GAP_MS) {
280
+ visit.scrollCount += 1;
281
+ }
282
+ visit.lastScrollAt = now;
283
+ touch();
284
+ updateMaxDepth();
285
+ };
286
+ const onInput = () => touch();
287
+ const tick = () => {
288
+ if (!visit) return;
289
+ if (document.visibilityState === "visible" && Date.now() - visit.lastActivityAt <= IDLE_PAUSE_MS) {
290
+ visit.activeMs += METRIC_TICK_MS;
291
+ }
292
+ updateMaxDepth();
293
+ };
294
+ const removeListeners = () => {
295
+ window.removeEventListener("scroll", onScroll);
296
+ window.removeEventListener("pointerdown", onInput, { capture: true });
297
+ window.removeEventListener("keydown", onInput, { capture: true });
298
+ if (interval) {
299
+ clearInterval(interval);
300
+ interval = null;
301
+ }
302
+ };
303
+ const startVisit = () => {
304
+ if (visit) return;
305
+ const now = Date.now();
306
+ visit = {
307
+ pageViewId: `pv_${generateEventId()}`,
308
+ enteredAt: now,
309
+ snapshot: collectPageSnapshot(),
310
+ activeMs: 0,
311
+ scrollCount: 0,
312
+ maxScrollDepthPct: 0,
313
+ lastActivityAt: now,
314
+ lastScrollAt: 0
315
+ };
316
+ window.addEventListener("scroll", onScroll, { passive: true });
317
+ window.addEventListener("pointerdown", onInput, {
318
+ capture: true,
319
+ passive: true
320
+ });
321
+ window.addEventListener("keydown", onInput, { capture: true, passive: true });
322
+ interval = setInterval(tick, METRIC_TICK_MS);
323
+ };
324
+ const endVisit = (reason) => {
325
+ if (!visit) return void 0;
326
+ removeListeners();
327
+ const finished = visit;
328
+ visit = null;
329
+ if (document.title) {
330
+ finished.snapshot.title = document.title;
331
+ }
332
+ return {
333
+ ...finished.snapshot,
334
+ pageViewId: finished.pageViewId,
335
+ durationMs: Date.now() - finished.enteredAt,
336
+ activeMs: finished.activeMs,
337
+ scrollCount: finished.scrollCount,
338
+ maxScrollDepthPct: finished.maxScrollDepthPct,
339
+ endReason: reason
340
+ };
341
+ };
342
+ return {
343
+ get active() {
344
+ return visit !== null;
345
+ },
346
+ startVisit,
347
+ endVisit,
348
+ pageViewId: () => visit?.pageViewId,
349
+ dispose: removeListeners
350
+ };
351
+ }
352
+
202
353
  // src/reporter.ts
203
354
  var DEFAULT_FLUSH_INTERVAL = 2e3;
204
355
  var DEFAULT_MAX_BATCH_SIZE = 10;
@@ -207,11 +358,33 @@ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
207
358
  var DEFAULT_MAX_ATTEMPTS = 3;
208
359
  var DEFAULT_BACKOFF_MS = 1e3;
209
360
  var STORAGE_WRITE_THROTTLE_MS = 500;
361
+ var VISIT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
362
+ var SESSION_SAVE_THROTTLE_MS = 1e4;
363
+ var ANONYMOUS_ID_KEY = "clipto_report_anonymous_id";
364
+ var CLIENT_ID_KEY = "clipto_report_client_id";
365
+ var SESSION_KEY = "clipto_report_session";
366
+ function normalizeContext(context) {
367
+ if (typeof context === "function") return context;
368
+ return () => context ?? {};
369
+ }
370
+ function stripAttempts(events) {
371
+ return events.map((event) => {
372
+ const { attempts: _attempts, ...rest } = event;
373
+ return rest;
374
+ });
375
+ }
210
376
  var Reporter = class {
211
377
  constructor(options) {
212
- /** 会话 / 设备 id 兜底:enrich 未提供时按实例生成(对齐旧 SDK 格式) */
213
- this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
214
- this.deviceId = `device_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
378
+ /** 会话落盘时间戳(节流用);声明在 session 之前:session 初始化会触发强制落盘 */
379
+ this.sessionSaveAt = 0;
380
+ /** 会话共享读时间戳(节流用):多 tab 共享会话,活动时定期重读 localStorage 最新值 */
381
+ this.sessionReadAt = 0;
382
+ /** Node 端设备快照(init 时采集一次,避免每次入队动态加载 os) */
383
+ this.nodeDevice = {};
384
+ /** 页面访问跟踪(browser + autoPageView 时启用) */
385
+ this.pageTracker = null;
386
+ /** history 补丁的原始引用(dispose 时还原) */
387
+ this.historyPatch = null;
215
388
  /** 待发队列(内存中的事实源,持久化是它的影子) */
216
389
  this.queue = [];
217
390
  this.flushTimer = null;
@@ -224,13 +397,31 @@ var Reporter = class {
224
397
  this.fire(this.dispose());
225
398
  };
226
399
  this.onPageHide = () => {
400
+ this.endPageVisit(this.session.visitId, "close");
227
401
  this.fire(this.dispose());
228
402
  };
403
+ this.onPageShow = () => {
404
+ if (document.visibilityState === "visible") {
405
+ this.pageTracker?.startVisit();
406
+ }
407
+ };
229
408
  this.onVisibilityChange = () => {
230
409
  if (document.visibilityState === "hidden") {
410
+ this.endPageVisit(this.session.visitId, "background");
231
411
  this.fire(this.flush());
232
- } else if (this.opts.autoPageView) {
233
- this.track("page_view");
412
+ } else {
413
+ this.pageTracker?.startVisit();
414
+ }
415
+ };
416
+ this.onPopState = () => {
417
+ this.endPageVisit(this.session.visitId, "back");
418
+ this.pageTracker?.startVisit();
419
+ };
420
+ this.onStorage = (event) => {
421
+ if (event.key !== SESSION_KEY || !event.newValue) return;
422
+ try {
423
+ this.adoptSharedSession(JSON.parse(event.newValue));
424
+ } catch {
234
425
  }
235
426
  };
236
427
  if (!options.endpoint) {
@@ -238,6 +429,7 @@ var Reporter = class {
238
429
  }
239
430
  this.opts = options;
240
431
  this.platform = options.platform ?? (typeof window !== "undefined" ? "browser" : "node");
432
+ this.contextFn = normalizeContext(options.context);
241
433
  this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
242
434
  this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
243
435
  this.maxBatchBytes = options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
@@ -246,16 +438,20 @@ var Reporter = class {
246
438
  this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
247
439
  this.backoffMs = options.backoffMs ?? DEFAULT_BACKOFF_MS;
248
440
  this.flushOnExit = options.flushOnExit ?? true;
441
+ this._anonymousId = this.loadOrCreateAnonymousId();
442
+ this._clientId = this.loadOrCreateClientId();
443
+ this.session = this.loadOrCreateSession();
249
444
  if (options.transport) {
250
445
  this.transportPromise = Promise.resolve(options.transport);
251
446
  } else if (this.platform === "browser") {
252
447
  this.transportPromise = Promise.resolve(
253
- createBrowserTransport(options.endpoint, options.headers)
448
+ createBrowserTransport(options.endpoint, options.headers, options.signer)
254
449
  );
255
450
  } else {
256
451
  this.transportPromise = createNodeTransport(
257
452
  options.endpoint,
258
- options.headers
453
+ options.headers,
454
+ options.signer
259
455
  );
260
456
  }
261
457
  if (options.storage) {
@@ -271,35 +467,20 @@ var Reporter = class {
271
467
  }
272
468
  this.fire(this.init());
273
469
  }
274
- track(name, properties, options) {
470
+ track(eventName, properties, options) {
275
471
  if (this.disposed) {
276
- this.log("dispose \u540E\u5FFD\u7565\u65B0\u4E8B\u4EF6:", name);
472
+ this.log("dispose \u540E\u5FFD\u7565\u65B0\u4E8B\u4EF6:", eventName);
277
473
  return "";
278
474
  }
279
- const enriched = this.opts.enrich?.() ?? {};
280
- const event = {
281
- id: generateEventId(),
282
- name,
283
- properties: {
284
- ...this.opts.baseProperties ?? {},
285
- ...this.opts.context?.() ?? {},
286
- ...properties ?? {}
287
- },
288
- timestamp: options?.timestamp ?? Date.now(),
289
- attempts: 0,
290
- sessionId: enriched.sessionId ?? this.sessionId,
291
- userId: enriched.userId ?? null,
292
- deviceId: enriched.deviceId ?? this.deviceId,
293
- category: options?.category ?? "custom",
294
- context: enriched.context ?? {}
295
- };
296
- if (typeof name !== "string" || name.length === 0) {
297
- this.opts.onDropped?.([event], "invalid");
475
+ if (typeof eventName !== "string" || eventName.length === 0) {
476
+ this.opts.onDropped?.([this.buildEvent("", properties)], "invalid");
298
477
  return "";
299
478
  }
479
+ this.touchSession();
480
+ const event = this.buildEvent(eventName, properties);
300
481
  if (options?.immediate) {
301
- this.fire(this.sendBatch([event]));
302
- return event.id;
482
+ this.fire(this.sendBatch([{ ...event, attempts: 0 }]));
483
+ return event.eventId;
303
484
  }
304
485
  this.enqueue(event);
305
486
  if (this.flushInterval <= 0) {
@@ -307,7 +488,7 @@ var Reporter = class {
307
488
  } else {
308
489
  this.scheduleFlush();
309
490
  }
310
- return event.id;
491
+ return event.eventId;
311
492
  }
312
493
  flush() {
313
494
  if (!this.flushPromise) {
@@ -324,6 +505,22 @@ var Reporter = class {
324
505
  get size() {
325
506
  return this.queue.length;
326
507
  }
508
+ /** 匿名 id(只读):首次生成后持久化不变;供外部转化跟踪等场景读取 */
509
+ get anonymousId() {
510
+ return this._anonymousId;
511
+ }
512
+ /** 设备 / 安装实例 id(只读):Web&PC 与 google client id 一致;供外部转化跟踪等场景读取 */
513
+ get clientId() {
514
+ return this._clientId;
515
+ }
516
+ /** 当前会话 id(只读):会话轮换后返回新值;供外部转化跟踪等场景读取 */
517
+ get visitId() {
518
+ return this.session.visitId;
519
+ }
520
+ /** 当前登录账号 id(只读):实时调用 userId provider,未提供恒为 null */
521
+ get userId() {
522
+ return this.opts.userId?.() ?? null;
523
+ }
327
524
  async dispose() {
328
525
  if (this.disposed) return;
329
526
  this.disposed = true;
@@ -341,7 +538,16 @@ var Reporter = class {
341
538
  }
342
539
  if (this.platform === "browser") {
343
540
  window.removeEventListener("pagehide", this.onPageHide);
541
+ window.removeEventListener("pageshow", this.onPageShow);
542
+ window.removeEventListener("storage", this.onStorage);
344
543
  document.removeEventListener("visibilitychange", this.onVisibilityChange);
544
+ this.restoreHistory();
545
+ if (this.pageTracker) {
546
+ this.endPageVisit(this.session.visitId, "unknown");
547
+ this.pageTracker.dispose();
548
+ this.pageTracker = null;
549
+ }
550
+ this.saveSession(true);
345
551
  } else {
346
552
  const exitProcess = this.getExitProcess();
347
553
  exitProcess.off("beforeExit", this.onExit);
@@ -357,17 +563,18 @@ var Reporter = class {
357
563
  }
358
564
  if (this.flushOnExit) {
359
565
  const pending = this.queue.splice(0);
360
- if (transport.sendSync?.(pending) ?? false) {
361
- this.opts.onFlushed?.(pending);
566
+ const wire = stripAttempts(pending);
567
+ if (transport.sendSync?.(wire) ?? false) {
568
+ this.opts.onFlushed?.(wire);
362
569
  await this.persistNow([]);
363
570
  return;
364
571
  }
365
572
  try {
366
- await transport.send(pending);
367
- this.opts.onFlushed?.(pending);
573
+ await transport.send(wire);
574
+ this.opts.onFlushed?.(wire);
368
575
  await this.persistNow([]);
369
576
  } catch (error) {
370
- this.opts.onFailed?.(pending, error);
577
+ this.opts.onFailed?.(wire, error);
371
578
  this.queue.push(...pending);
372
579
  await this.persistNow();
373
580
  }
@@ -377,9 +584,12 @@ var Reporter = class {
377
584
  await this.persistNow();
378
585
  }
379
586
  }
380
- /** 异步初始化:创建内置持久化、恢复上次会话队列、挂载退出兜底 */
587
+ /** 异步初始化:创建内置持久化、采集 Node 设备快照、恢复上次会话队列、挂载退出兜底 */
381
588
  async init() {
382
589
  this.storage = await this.storagePromise;
590
+ if (this.platform === "node") {
591
+ this.nodeDevice = await collectNodeDevice();
592
+ }
383
593
  if (this.disposed) return;
384
594
  if (this.storage) {
385
595
  try {
@@ -393,6 +603,7 @@ var Reporter = class {
393
603
  }
394
604
  if (this.disposed) return;
395
605
  if (this.platform === "browser") {
606
+ window.addEventListener("storage", this.onStorage);
396
607
  if (this.flushOnExit) {
397
608
  window.addEventListener("pagehide", this.onPageHide);
398
609
  }
@@ -400,7 +611,10 @@ var Reporter = class {
400
611
  document.addEventListener("visibilitychange", this.onVisibilityChange);
401
612
  }
402
613
  if (this.opts.autoPageView) {
403
- this.track("page_view");
614
+ this.pageTracker = createPageTracker(() => this.touchSession());
615
+ this.pageTracker.startVisit();
616
+ this.patchHistory();
617
+ window.addEventListener("pageshow", this.onPageShow);
404
618
  }
405
619
  } else if (this.flushOnExit) {
406
620
  const exitProcess = this.getExitProcess();
@@ -413,9 +627,118 @@ var Reporter = class {
413
627
  this.schedulePersist();
414
628
  }
415
629
  }
416
- /** 恢复队列入队:历史事件放队头保证先到先发,超限按溢出丢弃 */
630
+ /** 组装上报事件:身份字段由 SDK 填充,业务节点 = SDK 采集 + context 合并(接入方值优先) */
631
+ buildEvent(eventName, properties, overrides) {
632
+ const context = this.contextFn();
633
+ const device = {
634
+ ...this.nodeDevice,
635
+ ...collectBrowserDevice(),
636
+ ...context.device ?? {}
637
+ };
638
+ const page = {
639
+ ...collectPageSnapshot(),
640
+ ...this.pageSnapshot(),
641
+ ...context.page ?? {}
642
+ };
643
+ const mergedProperties = { ...context.properties ?? {}, ...properties ?? {} };
644
+ if (!context.app) {
645
+ this.log("context \u672A\u63D0\u4F9B app \u8282\u70B9,\u4EE5\u7A7A\u5BF9\u8C61\u4E0A\u62A5");
646
+ }
647
+ return {
648
+ eventId: generateEventId(),
649
+ eventName,
650
+ userId: this.opts.userId?.() ?? null,
651
+ anonymousId: this._anonymousId,
652
+ visitId: this.session.visitId,
653
+ clientId: this._clientId,
654
+ timestamp: Date.now(),
655
+ app: context.app ?? {},
656
+ ...Object.keys(device).length > 0 ? { device } : {},
657
+ ...Object.keys(page).length > 0 ? { page } : {},
658
+ ...context.user ? { user: context.user } : {},
659
+ ...Object.keys(mergedProperties).length > 0 ? { properties: mergedProperties } : {},
660
+ ...overrides
661
+ };
662
+ }
663
+ /** 进行中页面访问的 pageViewId(非访问期返回空) */
664
+ pageSnapshot() {
665
+ if (!this.pageTracker?.active) return {};
666
+ const pageViewId = this.pageTracker.pageViewId();
667
+ return pageViewId ? { pageViewId } : {};
668
+ }
669
+ /** 会话活跃:track 调用与页面交互均触发;超时轮换 visitId 并结束旧页面访问 */
670
+ touchSession() {
671
+ const now = Date.now();
672
+ if (now - this.sessionReadAt >= SESSION_SAVE_THROTTLE_MS) {
673
+ this.sessionReadAt = now;
674
+ this.refreshSessionFromShared();
675
+ }
676
+ if (now - this.session.lastActivityMs > VISIT_IDLE_TIMEOUT_MS) {
677
+ const oldVisitId = this.session.visitId;
678
+ this.endPageVisit(oldVisitId, "sessionTimeout");
679
+ this.session = { visitId: this.newId("visit"), lastActivityMs: now };
680
+ this.saveSession(true);
681
+ this.pageTracker?.startVisit();
682
+ } else {
683
+ this.session.lastActivityMs = now;
684
+ this.saveSession();
685
+ }
686
+ }
687
+ /** 结束当前页面访问并上报 pageView 汇总事件(复用进入页面时的 visitId) */
688
+ endPageVisit(visitId, reason) {
689
+ if (!this.pageTracker?.active) return;
690
+ const summary = this.pageTracker.endVisit(reason);
691
+ if (!summary) return;
692
+ const context = this.contextFn();
693
+ this.enqueue(
694
+ this.buildEvent("pageView", void 0, {
695
+ visitId,
696
+ page: { ...summary, ...context.page ?? {} }
697
+ })
698
+ );
699
+ this.scheduleFlush();
700
+ }
701
+ /** SPA 路由变化检测:pushState / replaceState 视为 routeChange,popstate 视为 back */
702
+ patchHistory() {
703
+ if (this.historyPatch) return;
704
+ const endVisit = (reason) => {
705
+ this.endPageVisit(this.session.visitId, reason);
706
+ this.pageTracker?.startVisit();
707
+ };
708
+ const pushState = history.pushState.bind(history);
709
+ const replaceState = history.replaceState.bind(history);
710
+ history.pushState = function(...args) {
711
+ const result = pushState(...args);
712
+ endVisit("routeChange");
713
+ return result;
714
+ };
715
+ history.replaceState = function(...args) {
716
+ const result = replaceState(...args);
717
+ endVisit("routeChange");
718
+ return result;
719
+ };
720
+ window.addEventListener("popstate", this.onPopState);
721
+ this.historyPatch = { pushState, replaceState };
722
+ }
723
+ restoreHistory() {
724
+ if (!this.historyPatch) return;
725
+ history.pushState = this.historyPatch.pushState;
726
+ history.replaceState = this.historyPatch.replaceState;
727
+ window.removeEventListener("popstate", this.onPopState);
728
+ this.historyPatch = null;
729
+ }
730
+ /** 恢复队列入队:历史事件放队头保证先到先发,超限按溢出丢弃;过滤旧格式(无 eventId)事件 */
417
731
  enqueueRestored(events) {
418
- let accepted = events;
732
+ let accepted = events.filter(
733
+ (event) => typeof event.eventId === "string" && typeof event.eventName === "string"
734
+ );
735
+ if (accepted.length < events.length) {
736
+ this.log(`\u8DF3\u8FC7 ${events.length - accepted.length} \u6761\u65E7\u683C\u5F0F\u6301\u4E45\u5316\u4E8B\u4EF6`);
737
+ }
738
+ accepted = accepted.map((event) => ({
739
+ ...event,
740
+ attempts: typeof event.attempts === "number" ? event.attempts : 0
741
+ }));
419
742
  if (accepted.length > this.maxQueueSize) {
420
743
  this.opts.onDropped?.(accepted.slice(this.maxQueueSize), "overflow");
421
744
  accepted = accepted.slice(0, this.maxQueueSize);
@@ -423,6 +746,7 @@ var Reporter = class {
423
746
  this.queue.unshift(...accepted);
424
747
  }
425
748
  enqueue(event) {
749
+ const queued = { ...event, attempts: 0 };
426
750
  if (this.queue.length >= this.maxQueueSize) {
427
751
  if (this.overflowPolicy === "drop-newest") {
428
752
  this.opts.onDropped?.([event], "overflow");
@@ -434,7 +758,7 @@ var Reporter = class {
434
758
  this.fire(this.flush());
435
759
  }
436
760
  }
437
- this.queue.push(event);
761
+ this.queue.push(queued);
438
762
  this.schedulePersist();
439
763
  }
440
764
  /** 攒批定时:延迟上报的实现,窗口内事件合并为一批 */
@@ -467,12 +791,13 @@ var Reporter = class {
467
791
  }
468
792
  async sendBatch(batch) {
469
793
  const transport = await this.transportPromise;
794
+ const wire = stripAttempts(batch);
470
795
  try {
471
- await transport.send(batch);
472
- this.opts.onFlushed?.(batch);
796
+ await transport.send(wire);
797
+ this.opts.onFlushed?.(wire);
473
798
  this.schedulePersist();
474
799
  } catch (error) {
475
- this.opts.onFailed?.(batch, error);
800
+ this.opts.onFailed?.(wire, error);
476
801
  this.log("send batch failed:", error);
477
802
  const updated = batch.map((event) => ({
478
803
  ...event,
@@ -526,6 +851,99 @@ var Reporter = class {
526
851
  this.log("persist queue failed:", error);
527
852
  }
528
853
  }
854
+ // ============================================================
855
+ // 身份与会话持久化(browser: localStorage;Node: 进程内)。
856
+ // 会话存 localStorage 而非 sessionStorage:同源多 tab 共享一个会话,
857
+ // "连续 30 分钟无活动"是用户级语义,不应按 tab 拆分;跨 tab 同步靠
858
+ // storage 事件(实时)+ 活动时重读(兜底)。
859
+ // ============================================================
860
+ /** 匿名 id:localStorage 持久化,首次匿名访问生成后保留;Node 无持久化介质,进程内生成 */
861
+ loadOrCreateAnonymousId() {
862
+ const stored = this.readPersistent(ANONYMOUS_ID_KEY);
863
+ if (stored) return stored;
864
+ const fresh = this.newId("anonymous");
865
+ this.writePersistent(ANONYMOUS_ID_KEY, fresh);
866
+ return fresh;
867
+ }
868
+ /** clientId:Web&PC 与 google client id(_ga cookie)一致;否则本地生成并持久化 */
869
+ loadOrCreateClientId() {
870
+ if (typeof document !== "undefined") {
871
+ const ga = document.cookie.match(/(?:^|;\s*)_ga=GA1\.\d\.([^;]+)/);
872
+ if (ga?.[1]) return ga[1];
873
+ const stored = this.readPersistent(CLIENT_ID_KEY);
874
+ if (stored) return stored;
875
+ }
876
+ const fresh = this.newId("client");
877
+ this.writePersistent(CLIENT_ID_KEY, fresh);
878
+ return fresh;
879
+ }
880
+ /** 会话恢复:localStorage(同源多 tab 共享)中 30 分钟窗口内的会话复用,否则新建 */
881
+ loadOrCreateSession() {
882
+ const now = Date.now();
883
+ try {
884
+ const text = this.readPersistent(SESSION_KEY);
885
+ if (text) {
886
+ const stored = JSON.parse(text);
887
+ if (stored.visitId && now - stored.lastActivityMs <= VISIT_IDLE_TIMEOUT_MS) {
888
+ return stored;
889
+ }
890
+ }
891
+ } catch {
892
+ }
893
+ const fresh = { visitId: this.newId("visit"), lastActivityMs: now };
894
+ this.session = fresh;
895
+ this.saveSession(true);
896
+ return fresh;
897
+ }
898
+ /** 会话落盘(localStorage):节流 10s,避免滚动等高频活动频繁写;force 用于轮换 / 退出兜底 */
899
+ saveSession(force = false) {
900
+ const now = Date.now();
901
+ if (!force && this.sessionSaveAt > 0 && now - this.sessionSaveAt < SESSION_SAVE_THROTTLE_MS) {
902
+ return;
903
+ }
904
+ this.sessionSaveAt = now;
905
+ this.writePersistent(SESSION_KEY, JSON.stringify(this.session));
906
+ }
907
+ /** 采纳共享会话:visitId 变化视为其他 tab 轮换了会话,结束本 tab 旧页面访问并开启新访问 */
908
+ adoptSharedSession(stored) {
909
+ if (!stored.visitId || stored.visitId === this.session.visitId) {
910
+ if (stored.visitId) this.session = stored;
911
+ return;
912
+ }
913
+ const oldVisitId = this.session.visitId;
914
+ this.endPageVisit(oldVisitId, "sessionTimeout");
915
+ this.pageTracker?.startVisit();
916
+ this.session = stored;
917
+ }
918
+ /** 活动时重读共享会话(兜底 storage 事件丢失):storage 事件只在其他 tab 触发 */
919
+ refreshSessionFromShared() {
920
+ try {
921
+ const text = this.readPersistent(SESSION_KEY);
922
+ if (text) {
923
+ this.adoptSharedSession(JSON.parse(text));
924
+ }
925
+ } catch {
926
+ }
927
+ }
928
+ readPersistent(key) {
929
+ if (this.platform !== "browser") return null;
930
+ try {
931
+ return localStorage.getItem(key);
932
+ } catch {
933
+ return null;
934
+ }
935
+ }
936
+ writePersistent(key, value) {
937
+ if (this.platform !== "browser") return;
938
+ try {
939
+ localStorage.setItem(key, value);
940
+ } catch {
941
+ }
942
+ }
943
+ /** 生成带前缀的实例 id(格式对齐旧 SDK session_ / device_) */
944
+ newId(prefix) {
945
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
946
+ }
529
947
  log(...args) {
530
948
  if (this.opts.debug) {
531
949
  console.log("[reporter]", ...args);