@rk911/tracker-sdk 1.6.4

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/core/queue.js ADDED
@@ -0,0 +1,461 @@
1
+ export class ReportQueue {
2
+ constructor(adapter, config, session) {
3
+ this.adapter = adapter
4
+ this.config = config
5
+ this.session = session
6
+ this.queue = []
7
+ this.flushTimer = null
8
+ this.isFlushing = false
9
+ this._hooks = []
10
+ this._flushedEventIds = new Set()
11
+ this._legacyRetryQueue = []
12
+ this._currentPage = null
13
+ this._clickFlushTimer = null
14
+ this._refreshUserId = null
15
+
16
+ this._loadOfflineCache()
17
+ this._loadLegacyCache()
18
+ this._startPageFlushTimer()
19
+ this._setupPageCloseHandler()
20
+ }
21
+
22
+ setRefreshUserId(fn) {
23
+ this._refreshUserId = fn
24
+ }
25
+
26
+ // ---- 自动采集事件入队(页面维度批量上报)----
27
+
28
+ push(event) {
29
+ // 未登录时不入队(allowAnonymousTrack 开启时跳过此检查)
30
+ if (!this.config.allowAnonymousTrack && !this.session.getUserId()) {
31
+ return
32
+ }
33
+
34
+ var eventPage = event.pageRoute || null
35
+
36
+ // 页面边界检测:新事件所属页面与队列中的不同 → 先 flush 旧页面的事件
37
+ if (eventPage && this._currentPage && eventPage !== this._currentPage && this.queue.length > 0) {
38
+ this.flush()
39
+ }
40
+ if (eventPage && eventPage !== '/') {
41
+ this._currentPage = eventPage
42
+ }
43
+
44
+ this.queue.push(event)
45
+ this._notifyHooks([event])
46
+
47
+ // 达到批量阈值(默认 10 条)→ 立即上报
48
+ if (this.queue.length >= this.config.queue.maxBatchSize) {
49
+ this.flush()
50
+ } else if (event.eventType === 'click') {
51
+ // 点击行为 → 延迟上报(等后续接口请求到齐后一起发)
52
+ this._scheduleClickFlush()
53
+ } else {
54
+ // 重置页面停留定时器(60s 无新事件也 flush)
55
+ this._resetPageFlushTimer()
56
+ }
57
+ }
58
+
59
+ flush() {
60
+ if (this.isFlushing || this.queue.length === 0) return
61
+
62
+ if (this._refreshUserId) this._refreshUserId()
63
+
64
+ // [PROJECT-ADAPT] 认证判断逻辑(allowAnonymousTrack 开关可跳过)
65
+ if (!this.config.allowAnonymousTrack) {
66
+ // 检查 1: 没有 userId 不上报
67
+ if (!this.session.getUserId()) {
68
+ if (this.config.debug) {
69
+ console.log('[TrackerSDK] flush skipped: no userId')
70
+ }
71
+ return
72
+ }
73
+ // 检查 2: 有 getAuthHeaders 时还要验证 token
74
+ if (typeof this.config.getAuthHeaders === 'function') {
75
+ var authHeaders = this.config.getAuthHeaders() || {}
76
+ if (!authHeaders.Authorization && !authHeaders.token && !authHeaders['rsb-token']) {
77
+ if (this.config.debug) {
78
+ console.log('[TrackerSDK] flush skipped: no auth token')
79
+ }
80
+ return
81
+ }
82
+ }
83
+ }
84
+
85
+ this.isFlushing = true
86
+ var batch = this.queue.splice(0, this.config.queue.maxBatchSize)
87
+
88
+ var self = this
89
+ batch.forEach(function (evt) {
90
+ if (evt.eventId) self._flushedEventIds.add(evt.eventId)
91
+ })
92
+ if (this._flushedEventIds.size > 1000) {
93
+ var toDelete = this._flushedEventIds.size - 500
94
+ var iter = this._flushedEventIds.values()
95
+ for (var i = 0; i < toDelete; i++) {
96
+ this._flushedEventIds.delete(iter.next().value)
97
+ }
98
+ }
99
+
100
+ var payload = {
101
+ appId: this.config.appId,
102
+ deviceId: this.session.getDeviceId(),
103
+ sessionId: this.session.getSessionId(),
104
+ userId: this.session.getUserId(),
105
+ platform: this.config.pflag !== '' ? this.config.pflag : (this.config.platform || this.adapter.getPlatformName()),
106
+ events: batch,
107
+ }
108
+
109
+ var url = this.config.dadianUrl + this.config.batchReportUrl
110
+ var payloadStr = JSON.stringify(payload)
111
+
112
+ // 分层发送策略:sendBeacon → fetch+keepalive → adapter.request
113
+ var sent = this._sendViaBestChannel(url, payloadStr, batch)
114
+
115
+ if (sent) {
116
+ // sendBeacon / fetch+keepalive 已经发出,非阻塞完成
117
+ this.isFlushing = false
118
+ if (this.queue.length >= this.config.queue.maxBatchSize) {
119
+ this.flush()
120
+ }
121
+ }
122
+
123
+ // 顺便重试 legacy 离线缓存
124
+ this._retryLegacyCache()
125
+ }
126
+
127
+ /**
128
+ * 分层发送策略(普通 flush 用)
129
+ * Layer 1: fetch + keepalive(有 .catch 失败回调 → 可回灌离线缓存,不丢事件)
130
+ * Layer 2: navigator.sendBeacon(非阻塞、页面关闭可靠,但发出即"成功"、无失败回调)
131
+ * Layer 3: adapter.request(传统方式,小程序降级到这里,有 .catch)
132
+ *
133
+ * 顺序说明:普通 flush 优先 fetch —— sendBeacon 无法感知网络失败,
134
+ * 一旦静默丢包这批事件就永久丢失(表现为序号缺口);fetch 的 .catch
135
+ * 能把失败批次存入离线缓存下次回灌。页面关闭场景走 flushSync(beacon 优先)。
136
+ *
137
+ * 注意:sendBeacon 不支持自定义请求头,依赖服务端从 payload 中提取 token/appId/deviceId
138
+ *
139
+ * @returns {boolean} true=已通过非阻塞通道发送 false=走了 adapter.request 异步流程
140
+ */
141
+ _sendViaBestChannel(url, payloadStr, batch) {
142
+ var self = this
143
+
144
+ // Layer 1: fetch + keepalive(失败可回灌,普通 flush 首选)
145
+ if (typeof fetch !== 'undefined') {
146
+ try {
147
+ var headers = {
148
+ 'Content-Type': 'application/json',
149
+ token: this.adapter.getToken() || '',
150
+ appId: String(this.config.appId),
151
+ deviceId: this.session.getDeviceId() || '',
152
+ }
153
+ fetch(url, {
154
+ method: 'POST',
155
+ body: payloadStr,
156
+ headers: headers,
157
+ keepalive: true,
158
+ }).then(function (res) {
159
+ // HTTP 层失败(5xx/4xx)也视为未送达,回灌离线缓存
160
+ if (res && res.ok === false) {
161
+ self._saveToOfflineCache(batch)
162
+ return
163
+ }
164
+ if (self.config.debug) {
165
+ console.log('[TrackerSDK] batch sent via fetch+keepalive:', batch.length)
166
+ }
167
+ }).catch(function () {
168
+ self._saveToOfflineCache(batch)
169
+ })
170
+ return true
171
+ } catch (e) {
172
+ // fetch 不可用或 keepalive 超限(>64KB),降级
173
+ }
174
+ }
175
+
176
+ // Layer 2: sendBeacon(fetch 不可用时的非阻塞兜底)
177
+ // 限制:无法设置自定义请求头 + 无失败回调;payload 超 64KB 可能静默失败,限制 60KB 留余量
178
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon && payloadStr.length < 61440) {
179
+ try {
180
+ var blob = new Blob([payloadStr], { type: 'application/json' })
181
+ var sent = navigator.sendBeacon(url, blob)
182
+ if (sent) {
183
+ if (this.config.debug) {
184
+ console.log('[TrackerSDK] batch sent via sendBeacon:', batch.length)
185
+ }
186
+ return true
187
+ }
188
+ // sendBeacon 返回 false(通常是数据超 64KB),降级到 Layer 3
189
+ } catch (e) {
190
+ // 某些环境 sendBeacon 抛异常,降级
191
+ }
192
+ }
193
+
194
+ // Layer 3: adapter.request(小程序 / 极老浏览器降级)
195
+ var header = {
196
+ 'content-type': 'application/json',
197
+ token: this.adapter.getToken() || '',
198
+ appId: String(this.config.appId),
199
+ deviceId: this.session.getDeviceId() || '',
200
+ }
201
+
202
+ this.adapter
203
+ .request({ method: 'POST', url: url, data: JSON.parse(payloadStr), header: header })
204
+ .then(function () {
205
+ if (self.config.debug) {
206
+ console.log('[TrackerSDK] batch sent via adapter.request:', batch.length)
207
+ }
208
+ })
209
+ .catch(function () {
210
+ self._saveToOfflineCache(batch)
211
+ })
212
+ .finally(function () {
213
+ self.isFlushing = false
214
+ if (self.queue.length >= self.config.queue.maxBatchSize) {
215
+ self.flush()
216
+ }
217
+ })
218
+
219
+ return false // adapter.request 是异步的,isFlushing 由 finally 处理
220
+ }
221
+
222
+ // ---- 手动上报(走旧接口)----
223
+
224
+ pushLegacy(url, data, header) {
225
+ var self = this
226
+ this.adapter
227
+ .request({ method: 'POST', url: url, data: data, header: header })
228
+ .then(function (res) {
229
+ if (self.config.debug) {
230
+ console.log('[TrackerSDK] legacy report:', data.frontUrl)
231
+ }
232
+ })
233
+ .catch(function () {
234
+ self._legacyRetryQueue.push({ url: url, data: data, header: header })
235
+ self._saveLegacyCache()
236
+ if (self.config.debug) {
237
+ console.warn('[TrackerSDK] legacy report failed, cached for retry:', data.frontUrl)
238
+ }
239
+ })
240
+ }
241
+
242
+ // ---- Legacy 离线缓存与重试 ----
243
+
244
+ _saveLegacyCache() {
245
+ try {
246
+ var items = this._legacyRetryQueue.slice(-50)
247
+ this.adapter.setStorage('_tracker_legacy_cache', items)
248
+ } catch (e) {}
249
+ }
250
+
251
+ _loadLegacyCache() {
252
+ try {
253
+ var cached = this.adapter.getStorage('_tracker_legacy_cache')
254
+ if (Array.isArray(cached) && cached.length > 0) {
255
+ this._legacyRetryQueue = cached
256
+ this.adapter.removeStorage('_tracker_legacy_cache')
257
+ }
258
+ } catch (e) {}
259
+ }
260
+
261
+ _retryLegacyCache() {
262
+ if (this._legacyRetryQueue.length === 0) return
263
+ var items = this._legacyRetryQueue.splice(0)
264
+ var self = this
265
+ items.forEach(function (item) {
266
+ self.adapter
267
+ .request({ method: 'POST', url: item.url, data: item.data, header: item.header })
268
+ .then(function () {
269
+ if (self.config.debug) {
270
+ console.log('[TrackerSDK] legacy retry success:', item.data.frontUrl)
271
+ }
272
+ })
273
+ .catch(function () {
274
+ self._legacyRetryQueue.push(item)
275
+ })
276
+ })
277
+ this._saveLegacyCache()
278
+ }
279
+
280
+ // ---- Hook(供验证脚本等外部消费)----
281
+
282
+ onReport(fn) {
283
+ if (typeof fn === 'function') {
284
+ this._hooks.push(fn)
285
+ }
286
+ }
287
+
288
+ _notifyHooks(events) {
289
+ this._hooks.forEach(function (fn) {
290
+ try {
291
+ fn(events)
292
+ } catch (e) {}
293
+ })
294
+ }
295
+
296
+ // ---- 页面关闭前强制上报 ----
297
+
298
+ flushSync() {
299
+ if (this.queue.length === 0) return
300
+
301
+ if (this._refreshUserId) this._refreshUserId()
302
+
303
+ // 与 flush() 相同的身份校验:无 userId 时不上报
304
+ if (!this.config.allowAnonymousTrack && !this.session.getUserId()) {
305
+ this.queue = []
306
+ return
307
+ }
308
+
309
+ var self = this
310
+ var remaining = this.queue.filter(function (evt) {
311
+ return !self._flushedEventIds.has(evt.eventId)
312
+ })
313
+ this.queue = []
314
+
315
+ if (remaining.length === 0) return
316
+
317
+ remaining.forEach(function (evt) {
318
+ if (evt.eventId) self._flushedEventIds.add(evt.eventId)
319
+ })
320
+
321
+ var payload = {
322
+ appId: this.config.appId,
323
+ deviceId: this.session.getDeviceId(),
324
+ sessionId: this.session.getSessionId(),
325
+ userId: this.session.getUserId(),
326
+ platform: this.config.pflag !== '' ? this.config.pflag : (this.config.platform || this.adapter.getPlatformName()),
327
+ events: remaining,
328
+ }
329
+
330
+ var url = this.config.dadianUrl + this.config.batchReportUrl
331
+ var payloadStr = JSON.stringify(payload)
332
+
333
+ // 先备份到离线缓存:sendBeacon 无失败回调,网络失败时事件会永久丢失(序号缺口)。
334
+ // 备份后即使 sendBeacon 静默失败,下次启动时离线缓存可回灌。
335
+ // 后端按 eventId 幂等去重,不会产生重复。
336
+ this._saveToOfflineCache(remaining)
337
+
338
+ // 页面关闭时优先 sendBeacon(保证送达),超 60KB 跳过避免静默失败
339
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon && payloadStr.length < 61440) {
340
+ try {
341
+ var blob = new Blob([payloadStr], { type: 'application/json' })
342
+ var sent = navigator.sendBeacon(url, blob)
343
+ if (sent) {
344
+ // sendBeacon 成功排队,清除刚写入的离线备份(大概率会送达)
345
+ this._removeOfflineEvents(remaining)
346
+ return
347
+ }
348
+ } catch (e) {}
349
+ }
350
+
351
+ // sendBeacon 失败降级为 adapter.sendBeacon(平台适配层实现)
352
+ this.adapter.sendBeacon(url, payload)
353
+ }
354
+
355
+ // ---- 调度策略:页面维度定时器(停留超时 flush)----
356
+
357
+ _startPageFlushTimer() {
358
+ this._resetPageFlushTimer()
359
+ }
360
+
361
+ _resetPageFlushTimer() {
362
+ var self = this
363
+ if (this.flushTimer) {
364
+ clearTimeout(this.flushTimer)
365
+ }
366
+ this.flushTimer = setTimeout(function () {
367
+ self.flush()
368
+ // flush 后继续下一轮计时
369
+ self._resetPageFlushTimer()
370
+ }, this.config.queue.flushInterval)
371
+ }
372
+
373
+ // 点击后延迟 flush:等后续接口请求到齐后一起上报
374
+ _scheduleClickFlush() {
375
+ if (this._clickFlushTimer) return
376
+ var self = this
377
+ this._clickFlushTimer = setTimeout(function () {
378
+ self._clickFlushTimer = null
379
+ self.flush()
380
+ }, this.config.queue.clickFlushDelay)
381
+ }
382
+
383
+ _stopAutoFlush() {
384
+ if (this.flushTimer) {
385
+ clearTimeout(this.flushTimer)
386
+ this.flushTimer = null
387
+ }
388
+ if (this._clickFlushTimer) {
389
+ clearTimeout(this._clickFlushTimer)
390
+ this._clickFlushTimer = null
391
+ }
392
+ }
393
+
394
+ // ---- 页面关闭处理 ----
395
+
396
+ _setupPageCloseHandler() {
397
+ var self = this
398
+ this.adapter.onPageHide(function () {
399
+ self.flushSync()
400
+ })
401
+ this.adapter.onPageClose(function () {
402
+ self.flushSync()
403
+ })
404
+ }
405
+
406
+ // ---- 离线缓存 ----
407
+
408
+ _saveToOfflineCache(events) {
409
+ try {
410
+ var key = this.config.queue.offlineCacheKey
411
+ var cached = this.adapter.getStorage(key)
412
+ var existing = Array.isArray(cached) ? cached : []
413
+ var maxOffline = 500
414
+ var merged = existing.concat(events)
415
+ if (merged.length > maxOffline) {
416
+ merged = merged.slice(merged.length - maxOffline)
417
+ }
418
+ this.adapter.setStorage(key, merged)
419
+ } catch (e) {
420
+ // quota 超限时清空离线缓存,保证 localStorage 可用
421
+ try {
422
+ this.adapter.removeStorage(this.config.queue.offlineCacheKey)
423
+ } catch (ignore) {}
424
+ }
425
+ }
426
+
427
+ _loadOfflineCache() {
428
+ try {
429
+ var key = this.config.queue.offlineCacheKey
430
+ var cached = this.adapter.getStorage(key)
431
+ if (Array.isArray(cached) && cached.length > 0) {
432
+ this.queue = cached.concat(this.queue)
433
+ this.adapter.removeStorage(key)
434
+ }
435
+ } catch (e) {}
436
+ }
437
+
438
+ _removeOfflineEvents(events) {
439
+ try {
440
+ var key = this.config.queue.offlineCacheKey
441
+ var cached = this.adapter.getStorage(key)
442
+ if (!Array.isArray(cached) || cached.length === 0) return
443
+ var ids = new Set()
444
+ events.forEach(function (evt) { if (evt.eventId) ids.add(evt.eventId) })
445
+ var filtered = cached.filter(function (evt) { return !ids.has(evt.eventId) })
446
+ if (filtered.length === 0) {
447
+ this.adapter.removeStorage(key)
448
+ } else {
449
+ this.adapter.setStorage(key, filtered)
450
+ }
451
+ } catch (e) {}
452
+ }
453
+
454
+ // ---- 清理 ----
455
+
456
+ destroy() {
457
+ this._stopAutoFlush()
458
+ this.flushSync()
459
+ this._hooks = []
460
+ }
461
+ }
@@ -0,0 +1,128 @@
1
+ var DEVICE_ID_KEY = '_tracker_device_id'
2
+
3
+ export class SessionManager {
4
+ constructor(adapter, config) {
5
+ this.adapter = adapter
6
+ this.config = config
7
+ this.sessionId = null
8
+ this.startTime = 0
9
+ this.lastActivityTime = 0
10
+ this.sequence = 0
11
+ this.userId = null
12
+ this.deviceId = null
13
+ this._storageKey = config.session.storageKey || '_tracker_session'
14
+ this._renewListeners = []
15
+
16
+ this._initDeviceId()
17
+ this._restore()
18
+ }
19
+
20
+ _initDeviceId() {
21
+ var stored = this.adapter.getStorage(DEVICE_ID_KEY)
22
+ || this.adapter.getStorage('deviceId')
23
+ if (stored) {
24
+ this.deviceId = stored
25
+ this.adapter.setStorage(DEVICE_ID_KEY, stored)
26
+ } else {
27
+ this.deviceId = this.adapter.generateId('dev')
28
+ this.adapter.setStorage(DEVICE_ID_KEY, this.deviceId)
29
+ }
30
+ }
31
+
32
+ getSessionId() {
33
+ if (!this.sessionId || this._isExpired()) {
34
+ var wasRenew = !!this.sessionId
35
+ this._createNew()
36
+ if (wasRenew) this._notifyRenew()
37
+ }
38
+ return this.sessionId
39
+ }
40
+
41
+ onRenew(fn) {
42
+ if (typeof fn === 'function') this._renewListeners.push(fn)
43
+ }
44
+
45
+ _notifyRenew() {
46
+ this._renewListeners.forEach(function (fn) { try { fn() } catch (e) {} })
47
+ }
48
+
49
+ nextSequence() {
50
+ this.sequence++
51
+ return this.sequence
52
+ }
53
+
54
+ touch() {
55
+ this.lastActivityTime = this.adapter.getTimestamp()
56
+ this._save()
57
+ }
58
+
59
+ setUser(userId) {
60
+ this.userId = userId
61
+ this._save()
62
+ }
63
+
64
+ setDeviceId(deviceId) {
65
+ this.deviceId = deviceId
66
+ this.adapter.setStorage(DEVICE_ID_KEY, deviceId)
67
+ this._save()
68
+ }
69
+
70
+ getUserId() {
71
+ return this.userId
72
+ }
73
+
74
+ getDeviceId() {
75
+ return this.deviceId
76
+ }
77
+
78
+ _isExpired() {
79
+ if (!this.lastActivityTime) return true
80
+ var now = this.adapter.getTimestamp()
81
+ return now - this.lastActivityTime > this.config.session.timeout
82
+ }
83
+
84
+ _createNew() {
85
+ this.sessionId = this.adapter.generateId('sess')
86
+ this.startTime = this.adapter.getTimestamp()
87
+ this.lastActivityTime = this.startTime
88
+ this.sequence = 0
89
+ this._save()
90
+ }
91
+
92
+ _save() {
93
+ var data = {
94
+ sessionId: this.sessionId,
95
+ startTime: this.startTime,
96
+ lastActivityTime: this.lastActivityTime,
97
+ sequence: this.sequence,
98
+ }
99
+ this.adapter.setSessionStorage(this._storageKey, data)
100
+ }
101
+
102
+ _restore() {
103
+ var data = this.adapter.getSessionStorage(this._storageKey)
104
+ if (data && data.sessionId) {
105
+ this.sessionId = data.sessionId
106
+ this.startTime = data.startTime
107
+ this.lastActivityTime = data.lastActivityTime
108
+ this.sequence = data.sequence || 0
109
+
110
+ if (this._isExpired()) {
111
+ this._createNew()
112
+ }
113
+ } else {
114
+ this._createNew()
115
+ }
116
+ }
117
+
118
+ resetSession() {
119
+ this.adapter.removeSessionStorage(this._storageKey)
120
+ this.userId = null
121
+ this._createNew()
122
+ }
123
+
124
+ destroy() {
125
+ this.adapter.removeSessionStorage(this._storageKey)
126
+ this.sessionId = null
127
+ }
128
+ }