@lujoai/lujo-mcp 0.7.1 → 0.7.2
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/browser-sdk/ai-debug.js +1911 -0
- package/package.json +6 -5
|
@@ -0,0 +1,1911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lujo-mcp Browser SDK v0.6.9
|
|
3
|
+
*
|
|
4
|
+
* 版本以 browser-sdk/package.json 的 version 为准(本注释仅为可读性,
|
|
5
|
+
* 升级时随版本 bump 一并更新,避免再次漂移)。
|
|
6
|
+
*
|
|
7
|
+
* V2:批量上报 + sendBeacon 降级 + 指数退避重试。
|
|
8
|
+
* 前端自动采集:全局异常捕获、网络请求记录、UI 事件上报、静默失败标记。
|
|
9
|
+
* 无需构建工具,直接 <script> 引入或 import 使用。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* <script src="ai-debug.js"></script>
|
|
13
|
+
* <script>
|
|
14
|
+
* AiDebug.init({ endpoint: "http://localhost:8000" });
|
|
15
|
+
* </script>
|
|
16
|
+
*
|
|
17
|
+
* 或 ES module:
|
|
18
|
+
* import { init, reportSilentFailure } from "./ai-debug.js";
|
|
19
|
+
* init({ endpoint: "http://localhost:8000" });
|
|
20
|
+
*
|
|
21
|
+
* ── 路线图 ──
|
|
22
|
+
* V3:网络错误自动标记静默失败
|
|
23
|
+
* - 对 5xx / 网络错误 / 超时自动生成 silent_failure 上报
|
|
24
|
+
* - 关联最近的 UI 事件链,减少手动 reportSilentFailure 调用
|
|
25
|
+
*
|
|
26
|
+
* V4:SDK 初始化追踪 + 请求关联
|
|
27
|
+
* - 初始化时生成 trace_id 并贯穿所有请求 header(X-Trace-Id)
|
|
28
|
+
* - 后端按 trace_id 关联 SDK 生命周期内的全部事件
|
|
29
|
+
*
|
|
30
|
+
* V5:增强 ingest 端点 + 传输优化
|
|
31
|
+
* - /ingest/batch 支持按事件类型分组批量入库
|
|
32
|
+
* - 压缩传输(gzip,payload > 4KB 时自动启用)
|
|
33
|
+
* - 节流控制(5秒内最多2批,防止高频上报)
|
|
34
|
+
* - 失败降级(超过重试次数后暂存 localStorage,下次启动重试)
|
|
35
|
+
* - 端点级 QoS(error 优先级 > network > ui)
|
|
36
|
+
*
|
|
37
|
+
* V6:自动检测 UI 静默失败
|
|
38
|
+
* - 基于用户行为序列(click → 无网络请求 → 无路由变更)自动推断
|
|
39
|
+
* - 配合 V3 网络错误标记,实现端到端静默失败自动发现
|
|
40
|
+
*/
|
|
41
|
+
(function (global) {
|
|
42
|
+
"use strict";
|
|
43
|
+
|
|
44
|
+
// ── 内置默认敏感键名列表(用户将 redactFields 设为空时回退使用) ──
|
|
45
|
+
var _DEFAULT_REDACT_FIELDS = [
|
|
46
|
+
"password", "token", "secret", "authorization",
|
|
47
|
+
"cookie", "access_token", "api_key", "apikey",
|
|
48
|
+
"passwd", "pwd", "private_key", "auth_token"
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// ── 配置 ──
|
|
52
|
+
var cfg = {
|
|
53
|
+
endpoint: "",
|
|
54
|
+
apiKey: "",
|
|
55
|
+
captureErrors: true,
|
|
56
|
+
captureNetwork: true,
|
|
57
|
+
captureUI: true,
|
|
58
|
+
captureConsole: true,
|
|
59
|
+
redactFields: ["password", "token", "secret", "authorization"],
|
|
60
|
+
sampleRate: 1.0,
|
|
61
|
+
networkSampleRate: 1.0,
|
|
62
|
+
networkThrottleMs: 0,
|
|
63
|
+
autoDetectNetworkErrors: true,
|
|
64
|
+
autoDetectUISilentFailures: true,
|
|
65
|
+
uiSilentFailureTimeoutMs: 1800,
|
|
66
|
+
uiSilentFailureObserveSelector: "body",
|
|
67
|
+
// reportSilentFailure 自动附加最近 N 条 network/UI 事件链
|
|
68
|
+
silentFailureContextSize: 20,
|
|
69
|
+
// V2 批量上报配置
|
|
70
|
+
batchSize: 20, // 队列满阈值,达到即 flush
|
|
71
|
+
batchInterval: 1000, // 定时 flush 间隔(ms)
|
|
72
|
+
maxRetries: 3, // XHR 失败最大重试次数
|
|
73
|
+
// V5 传输优化配置
|
|
74
|
+
enableCompression: true, // 是否启用 gzip 压缩
|
|
75
|
+
compressionThreshold: 4096, // 压缩阈值(字节),payload > 4KB 时自动压缩
|
|
76
|
+
throttleWindowMs: 5000, // 节流窗口(ms)
|
|
77
|
+
maxBatchesPerWindow: 2, // 每个节流窗口内最多发送批次数
|
|
78
|
+
enableLocalStorageFallback: true, // 超过重试次数后是否暂存 localStorage
|
|
79
|
+
// v0.5.1 Source Map 支持:发布标识,随错误 extra 透传(空 = 不发送,向后兼容)
|
|
80
|
+
release: "",
|
|
81
|
+
localStorageKey: "ai-debug-pending-batches", // localStorage 键名
|
|
82
|
+
maxPendingBatches: 10, // 最多暂存的批次数
|
|
83
|
+
// V6 Resilient backoff & storage hygiene
|
|
84
|
+
maxRetryDelay: 5000, // 重试最大退避上限(ms)
|
|
85
|
+
localStorageTTL: 86400000, // localStorage 暂存批次过期时间(24h,ms)
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
var _inited = false;
|
|
89
|
+
var _sessionId = "sdk-" + Math.random().toString(36).slice(2, 10);
|
|
90
|
+
var _traceId = "sdk-trace-" + Math.random().toString(36).slice(2, 10);
|
|
91
|
+
|
|
92
|
+
// ── 静默失败上下文环形缓冲 ──
|
|
93
|
+
// 仅存摘要(method/url/status/duration/timestamp/body 前 512 字符),完整 record 走实时上报
|
|
94
|
+
var _recentNetwork = [];
|
|
95
|
+
var _recentUI = [];
|
|
96
|
+
var _pendingUISilentFailure = null;
|
|
97
|
+
var _uiSilentFailureTimer = null;
|
|
98
|
+
var _uiMutationObserver = null;
|
|
99
|
+
var _lastDomMutationAt = 0;
|
|
100
|
+
var _lastRoutePath = "";
|
|
101
|
+
// body 预览长度上限(约束 3 选项 A)
|
|
102
|
+
var _NETWORK_BODY_PREVIEW = 512;
|
|
103
|
+
|
|
104
|
+
// ── V2 批量上报状态 ──
|
|
105
|
+
var _batchQueue = []; // 批量事件队列:[{ path, payload }, ...]
|
|
106
|
+
var _batchTimer = null; // 定时 flush 定时器
|
|
107
|
+
var _BEACON_SIZE_LIMIT = 65536; // sendBeacon 64KB 限制
|
|
108
|
+
// FIX: CR-3 服务端 /ingest/batch 单次最多接受 100 条事件(app/api/ingest.py
|
|
109
|
+
// _MAX_BATCH_EVENTS,超限 413)。SDK 单请求必须按此上限分片,否则
|
|
110
|
+
// 恢复暂存批次时合并 >100 条必然 413,且 413 曾被当作可重试错误整批重试、
|
|
111
|
+
// 整批回写 localStorage,形成"毒批"自增强循环(事件永远无法送达)。
|
|
112
|
+
var _MAX_BATCH_EVENTS = 100;
|
|
113
|
+
var _onSilentFailureReport = null;
|
|
114
|
+
|
|
115
|
+
// ── V5 传输优化状态 ──
|
|
116
|
+
var _batchTimestamps = []; // 记录每次发送的时间戳,用于节流控制
|
|
117
|
+
var _pendingBatches = []; // 待发送的批次(节流延迟时暂存)
|
|
118
|
+
// FIX(v0.7.0 Minor): 暂存队列上限——极端场景(长时间断网后恢复、节流窗口
|
|
119
|
+
// 持续打满)下无限堆积;超出丢最旧并告警,保护内存有界。
|
|
120
|
+
var _MAX_PENDING_BATCHES = 50;
|
|
121
|
+
var _pendingTimer = null; // 节流错开发送定时器(单实例,避免同一时刻齐发)
|
|
122
|
+
|
|
123
|
+
// ── FIX: G3 destroy/teardown 所需的模块级引用 ──
|
|
124
|
+
// 各钩子安装前的原始函数引用:destroy 时还原为真原始值,避免 HMR 重载时
|
|
125
|
+
// 「新实例把上一代包装器当原始值再包一层」的套娃问题。
|
|
126
|
+
var _origWindowOnerror = null; // 安装前 global.onerror
|
|
127
|
+
var _origFetch = null; // 安装前 global.fetch
|
|
128
|
+
var _origXhrOpen = null; // 安装前 XMLHttpRequest.prototype.open
|
|
129
|
+
var _origXhrSend = null; // 安装前 XMLHttpRequest.prototype.send
|
|
130
|
+
var _origConsoleError = null; // 安装前 console.error
|
|
131
|
+
var _origConsoleWarn = null; // 安装前 console.warn
|
|
132
|
+
// 具名监听器引用:destroy 时 removeEventListener 需要同一函数引用
|
|
133
|
+
var _onUnhandledRejection = null;
|
|
134
|
+
var _onVisibilityChange = null;
|
|
135
|
+
var _onPageHide = null;
|
|
136
|
+
var _uiHandlers = null; // { click:fn, input:fn, change:fn, submit:fn }(capture=true)
|
|
137
|
+
var _uiHookEvents = ["click", "input", "change", "submit"];
|
|
138
|
+
// UI 事件去重表(FIX: G3 —— 提升到模块级以便清理/销毁;键含动态 className,
|
|
139
|
+
// 此前只增不删导致长会话无限增长)
|
|
140
|
+
var _debounce = {};
|
|
141
|
+
var _DEBOUNCE_TTL_MS = 1000; // 去重窗口(与上报去重判断一致)
|
|
142
|
+
var _DEBOUNCE_MAX_KEYS = 1000; // 去重表尺寸上限,超限按时间戳淘汰最旧
|
|
143
|
+
// beacon 令牌续期心跳句柄(FIX: G3 —— 此前 setInterval 未保存句柄,无法停止)
|
|
144
|
+
var _tokenRefreshTimer = null;
|
|
145
|
+
// 销毁标志:重试定时器等异步回调触发时短路,避免 destroy 后继续上报
|
|
146
|
+
var _destroyed = false;
|
|
147
|
+
// 各钩子安装标志(FIX: G3 —— destroy 仅还原/摘除确实安装过的钩子,保证幂等)
|
|
148
|
+
var _errorHookInstalled = false;
|
|
149
|
+
var _networkHookInstalled = false;
|
|
150
|
+
var _xhrHookInstalled = false;
|
|
151
|
+
var _uiHookInstalled = false;
|
|
152
|
+
var _pageHideHookInstalled = false;
|
|
153
|
+
|
|
154
|
+
// FIX: G3 —— 去重表清理:删除过期键 + 超尺寸上限时按时间戳淘汰最旧。
|
|
155
|
+
// 键含动态 className(SPA/CSS-in-JS 场景持续产生新键),只增不删会无限增长。
|
|
156
|
+
function _cleanupDebounce(now) {
|
|
157
|
+
var keys = Object.keys(_debounce);
|
|
158
|
+
var i;
|
|
159
|
+
for (i = 0; i < keys.length; i++) {
|
|
160
|
+
if (now - _debounce[keys[i]] >= _DEBOUNCE_TTL_MS) {
|
|
161
|
+
delete _debounce[keys[i]];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
keys = Object.keys(_debounce);
|
|
165
|
+
if (keys.length > _DEBOUNCE_MAX_KEYS) {
|
|
166
|
+
keys.sort(function (a, b) { return _debounce[a] - _debounce[b]; });
|
|
167
|
+
var excess = keys.length - _DEBOUNCE_MAX_KEYS;
|
|
168
|
+
for (i = 0; i < excess; i++) {
|
|
169
|
+
delete _debounce[keys[i]];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _pushRecent(arr, item, maxSize) {
|
|
175
|
+
arr.push(item);
|
|
176
|
+
while (arr.length > maxSize) {
|
|
177
|
+
arr.shift();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _summarizeNetworkRecord(record) {
|
|
182
|
+
var body = record && record.request_body;
|
|
183
|
+
if (body === null || body === undefined) {
|
|
184
|
+
body = "";
|
|
185
|
+
} else if (typeof body !== "string") {
|
|
186
|
+
try { body = JSON.stringify(body); } catch (e) { body = String(body); }
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
method: record ? record.method : "",
|
|
190
|
+
url: record ? record.url : "",
|
|
191
|
+
status_code: record ? record.status_code : null,
|
|
192
|
+
duration_ms: record ? record.duration_ms : null,
|
|
193
|
+
timestamp: _nowSeconds(),
|
|
194
|
+
request_body_preview: body.slice(0, _NETWORK_BODY_PREVIEW),
|
|
195
|
+
error: !!(record && record.error),
|
|
196
|
+
error_message: record && record.error ? String(record.error) : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 工具函数 ──
|
|
201
|
+
function _shouldSample() {
|
|
202
|
+
return Math.random() < cfg.sampleRate;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _getRedactFields() {
|
|
206
|
+
return (cfg.redactFields && cfg.redactFields.length > 0) ? cfg.redactFields : _DEFAULT_REDACT_FIELDS;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _escapeRegex(str) {
|
|
210
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _nowSeconds() {
|
|
214
|
+
return Date.now() / 1000;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _redactString(value) {
|
|
218
|
+
if (typeof value !== "string" || !value) return value;
|
|
219
|
+
var text = value;
|
|
220
|
+
var fields = _getRedactFields();
|
|
221
|
+
for (var i = 0; i < fields.length; i++) {
|
|
222
|
+
var field = fields[i];
|
|
223
|
+
var escaped = _escapeRegex(field);
|
|
224
|
+
var queryPattern = new RegExp("([?&]" + escaped + "=)([^&#]+)", "ig");
|
|
225
|
+
var kvPattern = new RegExp("(" + escaped + "\\s*[:=]\\s*)([^\\s,;&]+)", "ig");
|
|
226
|
+
text = text.replace(queryPattern, "$1***REDACTED***");
|
|
227
|
+
text = text.replace(kvPattern, "$1***REDACTED***");
|
|
228
|
+
}
|
|
229
|
+
text = text.replace(/(authorization\s*[:=]\s*bearer\s+)([^\s,;]+)/ig, "$1***REDACTED***");
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _redact(obj, _seen) {
|
|
234
|
+
if (typeof obj === "string") return _redactString(obj);
|
|
235
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
236
|
+
// FIX(v0.7.0 Minor): 环引用保护——已访问对象标记,循环引用截断为 null。
|
|
237
|
+
// 此前 extra 里含环引用对象(如 a.self = a)时递归爆栈,reportError 直接
|
|
238
|
+
// 抛 RangeError 整条上报丢失;截断后输出仍可安全 JSON.stringify。
|
|
239
|
+
var seen = _seen || new WeakSet();
|
|
240
|
+
if (seen.has(obj)) return null;
|
|
241
|
+
seen.add(obj);
|
|
242
|
+
var out = Array.isArray(obj) ? [] : {};
|
|
243
|
+
var fields = _getRedactFields();
|
|
244
|
+
// Build lowercase lookup set for case-insensitive matching
|
|
245
|
+
var lowerFields = [];
|
|
246
|
+
for (var i = 0; i < fields.length; i++) {
|
|
247
|
+
lowerFields.push(fields[i].toLowerCase());
|
|
248
|
+
}
|
|
249
|
+
for (var k in obj) {
|
|
250
|
+
if (obj.hasOwnProperty(k)) {
|
|
251
|
+
if (lowerFields.indexOf(k.toLowerCase()) >= 0) {
|
|
252
|
+
out[k] = "***REDACTED***";
|
|
253
|
+
} else if (typeof obj[k] === "string") {
|
|
254
|
+
// Try to parse string as JSON for deep redaction
|
|
255
|
+
try {
|
|
256
|
+
var parsed = JSON.parse(obj[k]);
|
|
257
|
+
if (parsed && typeof parsed === "object") {
|
|
258
|
+
out[k] = JSON.stringify(_redact(parsed, seen));
|
|
259
|
+
} else {
|
|
260
|
+
out[k] = _redactString(obj[k]);
|
|
261
|
+
}
|
|
262
|
+
} catch (e) {
|
|
263
|
+
out[k] = _redactString(obj[k]);
|
|
264
|
+
}
|
|
265
|
+
} else if (typeof obj[k] === "object") {
|
|
266
|
+
out[k] = _redact(obj[k], seen);
|
|
267
|
+
} else {
|
|
268
|
+
out[k] = obj[k];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── V2 批量上报 ──
|
|
276
|
+
function _send(path, payload, force) {
|
|
277
|
+
// FIX: P1-G2 —— 错误类上报豁免采样:sampleRate 此前对所有事件统一门控,
|
|
278
|
+
// 手动 reportError / reportSilentFailure / reportNetworkError 与全局异常捕获
|
|
279
|
+
// (window.onerror / unhandledrejection)在 sampleRate=0.5 时有一半概率
|
|
280
|
+
// 被无提示丢弃——业界惯例错误类事件不参与采样(采样面向高频遥测)。
|
|
281
|
+
// force=true 绕过采样;其余遥测(network / ui-event / console)保持原有
|
|
282
|
+
// 采样行为不变。
|
|
283
|
+
if (!cfg.endpoint || (!force && !_shouldSample())) return;
|
|
284
|
+
var redacted = _redact(payload);
|
|
285
|
+
_batchQueue.push({ path: path, payload: redacted });
|
|
286
|
+
|
|
287
|
+
// 队列满 → 立即 flush
|
|
288
|
+
if (_batchQueue.length >= cfg.batchSize) {
|
|
289
|
+
_flushBatch(false);
|
|
290
|
+
} else if (!_batchTimer) {
|
|
291
|
+
// 启动定时 flush
|
|
292
|
+
_batchTimer = setTimeout(function () {
|
|
293
|
+
_flushBatch(false);
|
|
294
|
+
}, cfg.batchInterval);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function _hasSendBeacon() {
|
|
299
|
+
return typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Beacon 短时令牌(CODE_REVIEW S1)──
|
|
303
|
+
// sendBeacon/EventSource 无法设置自定义 header,历史实现把永久 API Key 放进
|
|
304
|
+
// ?api_key= 查询参数(会被代理/CDN/浏览器历史/Referer 明文记录)。
|
|
305
|
+
// 现改为:先用 header 换取短时令牌并缓存,URL 只带该令牌上报。
|
|
306
|
+
var _beaconToken = null;
|
|
307
|
+
var _beaconTokenExpiresAt = 0;
|
|
308
|
+
|
|
309
|
+
function _beaconTokenValid() {
|
|
310
|
+
return !!_beaconToken && _beaconTokenExpiresAt > Date.now() + 10000;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 主动续期:保证页面关闭(sendBeacon)时令牌已缓存且未过期
|
|
314
|
+
function _refreshBeaconToken() {
|
|
315
|
+
if (!cfg.apiKey || !cfg.endpoint || _beaconTokenValid()) return;
|
|
316
|
+
var xhr = new XMLHttpRequest();
|
|
317
|
+
var url = cfg.endpoint.replace(/\/+$/, "") + "/auth/beacon-token";
|
|
318
|
+
xhr.open("POST", url, true);
|
|
319
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
320
|
+
xhr.setRequestHeader("X-API-Key", cfg.apiKey);
|
|
321
|
+
xhr.onreadystatechange = function () {
|
|
322
|
+
if (xhr.readyState !== 4) return;
|
|
323
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
324
|
+
try {
|
|
325
|
+
var data = JSON.parse(xhr.responseText);
|
|
326
|
+
_beaconToken = data.token;
|
|
327
|
+
_beaconTokenExpiresAt = Date.now() + (data.expires_in || 60) * 1000;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
_beaconToken = null;
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
// 换取失败不致命:sendBeacon 场景会退回同步 XHR(带 header)
|
|
333
|
+
_beaconToken = null;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
xhr.onerror = function () { _beaconToken = null; };
|
|
337
|
+
xhr.send("{}");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function _flushBatch(useBeacon) {
|
|
341
|
+
if (!cfg.endpoint) return;
|
|
342
|
+
|
|
343
|
+
// FIX(v0.7.0 Minor): 空队列 flush 清理悬挂的定时 flush 句柄——防句柄悬挂
|
|
344
|
+
// 抑制下次调度(_addEvent 的 `else if (!_batchTimer)` 分支)。
|
|
345
|
+
// 注意:beacon 冲刷路径不受影响(G1 语义保持:beacon 不看队列是否为空)。
|
|
346
|
+
if (!useBeacon && _batchQueue.length === 0) {
|
|
347
|
+
if (_batchTimer) {
|
|
348
|
+
clearTimeout(_batchTimer);
|
|
349
|
+
_batchTimer = null;
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (_batchTimer) {
|
|
355
|
+
clearTimeout(_batchTimer);
|
|
356
|
+
_batchTimer = null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
var url = cfg.endpoint.replace(/\/+$/, "") + "/ingest/batch";
|
|
360
|
+
|
|
361
|
+
// FIX: R7-G1 —— beacon(pagehide/unload)冲刷不看事件队列是否为空:
|
|
362
|
+
// 节流暂存的 _pendingBatches 同样需要在卸载前同步冲刷,否则窗口满后
|
|
363
|
+
// 暂存的批次在页面关闭时静默丢失。
|
|
364
|
+
// 页面关闭/隐藏路径:必须同步冲刷,绝不能延迟到定时器(unload 后定时器不会触发)
|
|
365
|
+
if (useBeacon) {
|
|
366
|
+
if (_pendingTimer) {
|
|
367
|
+
clearTimeout(_pendingTimer);
|
|
368
|
+
_pendingTimer = null;
|
|
369
|
+
}
|
|
370
|
+
// 先排空此前被节流延迟暂存的批次,避免丢数据
|
|
371
|
+
while (_pendingBatches.length > 0) {
|
|
372
|
+
_sendBatchWithCompression(url, _pendingBatches.shift(), true);
|
|
373
|
+
}
|
|
374
|
+
// FIX: CR-3 同样按服务端上限分片,避免恢复/积压场景单次 beacon 超 100 条被 413
|
|
375
|
+
while (_batchQueue.length > 0) {
|
|
376
|
+
var beaconBatch = _batchQueue.splice(0, _MAX_BATCH_EVENTS);
|
|
377
|
+
_sendBatchWithCompression(url, JSON.stringify({ events: beaconBatch }), true);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// FIX: CR-3 按服务端 _MAX_BATCH_EVENTS(100) 分片发送:
|
|
383
|
+
// 每片独立走节流检查,超出节流限额的片进入 _pendingBatches 由
|
|
384
|
+
// _drainPendingBatches 定时器错开发送 —— 不丢数据也不触发 413。
|
|
385
|
+
while (_batchQueue.length > 0) {
|
|
386
|
+
var batch = _batchQueue.splice(0, _MAX_BATCH_EVENTS);
|
|
387
|
+
var body = JSON.stringify({ events: batch });
|
|
388
|
+
|
|
389
|
+
// V5 节流控制:检查是否在节流窗口内
|
|
390
|
+
var now = Date.now();
|
|
391
|
+
_batchTimestamps = _batchTimestamps.filter(function(ts) {
|
|
392
|
+
return now - ts < cfg.throttleWindowMs;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (_batchTimestamps.length >= cfg.maxBatchesPerWindow) {
|
|
396
|
+
// 超过节流限制:入暂存队列,由单一定时器按间隔逐条错开发送(避免齐发尖峰)
|
|
397
|
+
if (_pendingBatches.length >= _MAX_PENDING_BATCHES) {
|
|
398
|
+
// FIX(v0.7.0 Minor): 暂存队列满 → 丢最旧并告警(内存有界)
|
|
399
|
+
_pendingBatches.shift();
|
|
400
|
+
console.warn("[ai-debug] Pending batch queue full (" + _MAX_PENDING_BATCHES + "), dropping oldest");
|
|
401
|
+
}
|
|
402
|
+
_pendingBatches.push(body);
|
|
403
|
+
if (!_pendingTimer) {
|
|
404
|
+
var delay = cfg.throttleWindowMs - (now - _batchTimestamps[0]);
|
|
405
|
+
_pendingTimer = setTimeout(_drainPendingBatches, delay);
|
|
406
|
+
}
|
|
407
|
+
// 剩余分片一并交给暂存队列逐条错发:splice 已取出当前片,
|
|
408
|
+
// 后续循环继续取片入队,避免同窗口内齐发
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// FIX: R7-G1 —— 发送决策点同步登记时间戳。此前压缩路径的时间戳只在
|
|
413
|
+
// 异步压缩回调里登记,JS 单线程下同步 while 循环跑完全部分片后回调
|
|
414
|
+
// 才执行 → 所有分片看到过期时间戳,节流失效(maxBatchesPerWindow:2
|
|
415
|
+
// 时实测 4 个请求齐发)。无论是否压缩,登记都必须发生在本循环内。
|
|
416
|
+
_batchTimestamps.push(now);
|
|
417
|
+
_sendBatchWithCompression(url, body, false);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 节流暂存批次按固定间隔逐条发送(每次只发 1 条,发完再续期),避免同一时刻齐发
|
|
422
|
+
function _drainPendingBatches() {
|
|
423
|
+
_pendingTimer = null;
|
|
424
|
+
if (_pendingBatches.length === 0) return;
|
|
425
|
+
var url = cfg.endpoint.replace(/\/+$/, "") + "/ingest/batch";
|
|
426
|
+
// FIX: R7-G1 —— 错发送也同步登记(此前由 _sendBatchDirect 内部登记)
|
|
427
|
+
_batchTimestamps.push(Date.now());
|
|
428
|
+
_sendBatchWithCompression(url, _pendingBatches.shift(), false);
|
|
429
|
+
if (_pendingBatches.length > 0) {
|
|
430
|
+
_pendingTimer = setTimeout(_drainPendingBatches, _pendingSendInterval());
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 每条暂存批次的发送间隔 = 节流窗口 / 窗口内允许批次数(保证不超限且错开)
|
|
435
|
+
function _pendingSendInterval() {
|
|
436
|
+
var windowMs = cfg.throttleWindowMs > 0 ? cfg.throttleWindowMs : 1000;
|
|
437
|
+
var max = cfg.maxBatchesPerWindow > 0 ? cfg.maxBatchesPerWindow : 1;
|
|
438
|
+
return Math.ceil(windowMs / max);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// V5 压缩传输:根据 payload 大小决定是否压缩
|
|
442
|
+
function _sendBatchWithCompression(url, body, useBeacon) {
|
|
443
|
+
// FIX: P1-2 sendBeacon 无法设置 Content-Encoding: gzip,beacon 场景永不压缩
|
|
444
|
+
var shouldCompress = !useBeacon && cfg.enableCompression &&
|
|
445
|
+
body.length > cfg.compressionThreshold &&
|
|
446
|
+
typeof CompressionStream !== "undefined";
|
|
447
|
+
|
|
448
|
+
if (shouldCompress) {
|
|
449
|
+
_compressAndSend(url, body, useBeacon);
|
|
450
|
+
} else {
|
|
451
|
+
_sendBatchDirect(url, body, useBeacon);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// V5 gzip 压缩实现(使用 Compression Streams API)
|
|
456
|
+
// FIX(v0.7.0 Minor): 删除 beacon 分支死代码——本函数仅由 _sendBatchWithCompression
|
|
457
|
+
// 在 shouldCompress = !useBeacon && ... 成立时调用,useBeacon 恒为 false,
|
|
458
|
+
// 函数内的 useBeacon 分支与专用的 _sendBatchSyncCompressed 永不可达
|
|
459
|
+
// (beacon 场景在 _sendBatchDirect 走未压缩同步路径,见 P1-2 修复注释)。
|
|
460
|
+
function _compressAndSend(url, body, useBeacon) {
|
|
461
|
+
try {
|
|
462
|
+
var blob = new Blob([body]);
|
|
463
|
+
var cs = new CompressionStream("gzip");
|
|
464
|
+
var compressedStream = blob.stream().pipeThrough(cs);
|
|
465
|
+
|
|
466
|
+
new Response(compressedStream).blob().then(function(compressedBlob) {
|
|
467
|
+
var reader = new FileReader();
|
|
468
|
+
reader.onload = function() {
|
|
469
|
+
var compressedBody = reader.result;
|
|
470
|
+
// FIX: R7-G1 —— 此处不再登记节流时间戳:登记已前移到
|
|
471
|
+
// _flushBatch/_drainPendingBatches 的发送决策点(同步),
|
|
472
|
+
// 异步回调里登记会让节流检查恒看到过期时间戳
|
|
473
|
+
|
|
474
|
+
// 常规 flush:异步 XHR + 指数退避重试(透传原始明文供 gzip 回退)
|
|
475
|
+
_sendBatchXhrCompressed(url, compressedBody, body, 0);
|
|
476
|
+
};
|
|
477
|
+
reader.readAsArrayBuffer(compressedBlob);
|
|
478
|
+
}).catch(function(err) {
|
|
479
|
+
// 压缩失败,降级为未压缩发送
|
|
480
|
+
console.warn("[ai-debug] Compression failed, falling back to uncompressed:", err);
|
|
481
|
+
_sendBatchDirect(url, body, useBeacon);
|
|
482
|
+
});
|
|
483
|
+
} catch (e) {
|
|
484
|
+
// CompressionStream 不可用,降级为未压缩发送
|
|
485
|
+
_sendBatchDirect(url, body, useBeacon);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// FIX(v0.7.0 Minor): beacon 64KB 上限按 UTF-8 字节数判定——此前用字符数,
|
|
490
|
+
// 中文等多字节字符约 3 万字(≈9 万字节)被误判"可走 sendBeacon",
|
|
491
|
+
// 实际超 64KB 限制被服务端截断/丢弃。
|
|
492
|
+
function _utf8Length(str) {
|
|
493
|
+
if (typeof TextEncoder !== "undefined") {
|
|
494
|
+
return new TextEncoder().encode(str).length;
|
|
495
|
+
}
|
|
496
|
+
// 老浏览器兜底:unescape(encodeURIComponent(str)) 产生 UTF-8 字节串
|
|
497
|
+
try {
|
|
498
|
+
return unescape(encodeURIComponent(str)).length;
|
|
499
|
+
} catch (e) {
|
|
500
|
+
return str.length;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// 未压缩直接发送
|
|
505
|
+
function _sendBatchDirect(url, body, useBeacon) {
|
|
506
|
+
// FIX: R7-G1 —— 节流时间戳登记已前移到 _flushBatch/_drainPendingBatches
|
|
507
|
+
// 的发送决策点(同步),此处不再重复登记(否则压缩/未压缩两条路径
|
|
508
|
+
// 双重计数,窗口配额被提前耗尽)
|
|
509
|
+
|
|
510
|
+
// 页面关闭场景:必须同步发送(sendBeacon 或同步 XHR),异步 XHR 会在 unload 后被取消
|
|
511
|
+
if (useBeacon) {
|
|
512
|
+
if (_hasSendBeacon()) {
|
|
513
|
+
if (_utf8Length(body) <= _BEACON_SIZE_LIMIT) {
|
|
514
|
+
if (!_beaconTokenValid()) {
|
|
515
|
+
// 令牌不可用 → 退回同步 XHR(带 header),避免 URL 暴露永久 Key
|
|
516
|
+
_sendBatchSync(url, body);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
var beaconUrl = url + "?token=" + encodeURIComponent(_beaconToken);
|
|
520
|
+
var blob = new Blob([body], { type: "application/json" });
|
|
521
|
+
if (navigator.sendBeacon(beaconUrl, blob)) {
|
|
522
|
+
return; // sendBeacon 成功
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// 超限或 sendBeacon 失败 → 同步 XHR 降级
|
|
526
|
+
_sendBatchSync(url, body);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// 无 sendBeacon 能力 → 同步 XHR 兜底(不再落到异步 XHR 导致丢数据)
|
|
530
|
+
_sendBatchSync(url, body);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 常规 flush:异步 XHR + 指数退避重试
|
|
535
|
+
_sendBatchXhr(url, body, 0);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Non-retryable status codes (400/401/403: client / auth errors)
|
|
539
|
+
function _isNonRetryableStatus(status) {
|
|
540
|
+
return status === 400 || status === 401 || status === 403;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// FIX: CR-3 413 = 批次条数超过服务端上限(/ingest/batch 最多 100 条)。
|
|
544
|
+
// 整批重试毫无意义(重试同样大小的批次必然再次 413),且重试耗尽后把
|
|
545
|
+
// 整批回写 localStorage 会在下次启动时形成更大的"毒批"。
|
|
546
|
+
// 正确策略:对半拆分后作为两个独立请求重发,反复 413 时指数收敛到单条;
|
|
547
|
+
// 单条仍被拒(服务端上限被调低到 1 或事件本身异常)则丢弃,避免无限循环。
|
|
548
|
+
function _handleBatchTooLarge(url, body) {
|
|
549
|
+
var parsed;
|
|
550
|
+
try { parsed = JSON.parse(body); } catch (err) { return; }
|
|
551
|
+
var events = (parsed && Array.isArray(parsed.events)) ? parsed.events : [];
|
|
552
|
+
if (events.length <= 1) return; // 单条仍超限:丢弃(不无限重试)
|
|
553
|
+
var mid = Math.ceil(events.length / 2);
|
|
554
|
+
_sendBatchXhr(url, JSON.stringify({ events: events.slice(0, mid) }), 0);
|
|
555
|
+
_sendBatchXhr(url, JSON.stringify({ events: events.slice(mid) }), 0);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Parse Retry-After header (seconds)
|
|
559
|
+
function _parseRetryAfter(xhr) {
|
|
560
|
+
try {
|
|
561
|
+
if (xhr && typeof xhr.getResponseHeader === "function") {
|
|
562
|
+
var header = xhr.getResponseHeader("Retry-After");
|
|
563
|
+
if (header) {
|
|
564
|
+
var seconds = parseInt(header, 10);
|
|
565
|
+
if (!isNaN(seconds) && seconds > 0) {
|
|
566
|
+
return seconds * 1000;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} catch (err) {}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Compute retry delay with Full Jitter exponential backoff
|
|
575
|
+
function _computeRetryDelay(attempt, retryAfterMs) {
|
|
576
|
+
var maxDelay = cfg.maxRetryDelay || 5000;
|
|
577
|
+
if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
|
|
578
|
+
return Math.min(retryAfterMs, maxDelay);
|
|
579
|
+
}
|
|
580
|
+
var baseDelay = 500;
|
|
581
|
+
var expDelay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
|
|
582
|
+
var jitter = Math.floor(Math.random() * (expDelay - 50 + 1)) + 50;
|
|
583
|
+
return jitter;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function _sendBatchXhr(url, body, attempt) {
|
|
587
|
+
try {
|
|
588
|
+
var xhr = new XMLHttpRequest();
|
|
589
|
+
xhr.open("POST", url, true);
|
|
590
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
591
|
+
if (cfg.apiKey) xhr.setRequestHeader("X-API-Key", cfg.apiKey);
|
|
592
|
+
xhr.onreadystatechange = function () {
|
|
593
|
+
if (xhr.readyState !== 4) return;
|
|
594
|
+
if (xhr.status >= 200 && xhr.status < 300) return; // success
|
|
595
|
+
|
|
596
|
+
// FIX: CR-3 批次过大(413):拆分重发,不整批重试
|
|
597
|
+
if (xhr.status === 413) {
|
|
598
|
+
_handleBatchTooLarge(url, body);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Fast abort on non-retryable client error
|
|
603
|
+
if (_isNonRetryableStatus(xhr.status)) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Retry with backoff
|
|
608
|
+
if (attempt < cfg.maxRetries) {
|
|
609
|
+
var retryAfterMs = _parseRetryAfter(xhr);
|
|
610
|
+
var delay = _computeRetryDelay(attempt, retryAfterMs);
|
|
611
|
+
setTimeout(function () {
|
|
612
|
+
if (_destroyed) return; // FIX: G3 销毁后不再重试
|
|
613
|
+
_sendBatchXhr(url, body, attempt + 1);
|
|
614
|
+
}, delay);
|
|
615
|
+
} else if (cfg.enableLocalStorageFallback) {
|
|
616
|
+
// Retries exhausted -> fallback to localStorage
|
|
617
|
+
_saveToLocalStorage(body);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
xhr.send(body);
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (attempt < cfg.maxRetries) {
|
|
623
|
+
var delay = _computeRetryDelay(attempt, null);
|
|
624
|
+
setTimeout(function () {
|
|
625
|
+
if (_destroyed) return; // FIX: G3 销毁后不再重试
|
|
626
|
+
_sendBatchXhr(url, body, attempt + 1);
|
|
627
|
+
}, delay);
|
|
628
|
+
} else if (cfg.enableLocalStorageFallback) {
|
|
629
|
+
_saveToLocalStorage(body);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// V5 Compressed XHR send(body 为原始未压缩 JSON,供 gzip 被拒/失败时明文回退)
|
|
635
|
+
function _sendBatchXhrCompressed(url, compressedBody, body, attempt) {
|
|
636
|
+
try {
|
|
637
|
+
var xhr = new XMLHttpRequest();
|
|
638
|
+
xhr.open("POST", url, true);
|
|
639
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
640
|
+
xhr.setRequestHeader("Content-Encoding", "gzip");
|
|
641
|
+
if (cfg.apiKey) xhr.setRequestHeader("X-API-Key", cfg.apiKey);
|
|
642
|
+
xhr.onreadystatechange = function () {
|
|
643
|
+
if (xhr.readyState !== 4) return;
|
|
644
|
+
if (xhr.status >= 200 && xhr.status < 300) return; // success
|
|
645
|
+
|
|
646
|
+
// 接收端拒绝 gzip(400/415)→ 用原始未压缩数据重发一次,避免发送损坏数据
|
|
647
|
+
if (attempt === 0 && (xhr.status === 400 || xhr.status === 415)) {
|
|
648
|
+
_sendBatchXhr(url, body, 0);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// FIX: CR-3 批次过大(413):拆分重发,不整批重试(body 为原始明文)
|
|
653
|
+
if (xhr.status === 413) {
|
|
654
|
+
_handleBatchTooLarge(url, body);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Fast abort on non-retryable client error
|
|
659
|
+
if (_isNonRetryableStatus(xhr.status)) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Retry with backoff
|
|
664
|
+
if (attempt < cfg.maxRetries) {
|
|
665
|
+
var retryAfterMs = _parseRetryAfter(xhr);
|
|
666
|
+
var delay = _computeRetryDelay(attempt, retryAfterMs);
|
|
667
|
+
setTimeout(function () {
|
|
668
|
+
if (_destroyed) return; // FIX: G3 销毁后不再重试
|
|
669
|
+
_sendBatchXhrCompressed(url, compressedBody, body, attempt + 1);
|
|
670
|
+
}, delay);
|
|
671
|
+
} else if (cfg.enableLocalStorageFallback) {
|
|
672
|
+
// 回退用原始明文(而非 gzip 字节),否则恢复时 JSON.parse 必然失败
|
|
673
|
+
_saveToLocalStorage(body);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
xhr.send(compressedBody);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (attempt < cfg.maxRetries) {
|
|
679
|
+
var delay = _computeRetryDelay(attempt, null);
|
|
680
|
+
setTimeout(function () {
|
|
681
|
+
if (_destroyed) return; // FIX: G3 销毁后不再重试
|
|
682
|
+
_sendBatchXhrCompressed(url, compressedBody, body, attempt + 1);
|
|
683
|
+
}, delay);
|
|
684
|
+
} else if (cfg.enableLocalStorageFallback) {
|
|
685
|
+
_saveToLocalStorage(body);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// V5 Compressed sync XHR send (unload / beforeunload)
|
|
691
|
+
// V5/V6 Fallback: save to localStorage with TTL and metadata wrapper
|
|
692
|
+
function _saveToLocalStorage(body) {
|
|
693
|
+
try {
|
|
694
|
+
if (typeof localStorage === "undefined") return;
|
|
695
|
+
|
|
696
|
+
var pending = [];
|
|
697
|
+
var stored = localStorage.getItem(cfg.localStorageKey);
|
|
698
|
+
if (stored) {
|
|
699
|
+
try {
|
|
700
|
+
pending = JSON.parse(stored);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
pending = [];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
var now = Date.now();
|
|
707
|
+
var ttl = cfg.localStorageTTL || 86400000;
|
|
708
|
+
|
|
709
|
+
// Filter out expired batches
|
|
710
|
+
pending = pending.filter(function (item) {
|
|
711
|
+
if (item && typeof item === "object" && item.timestamp) {
|
|
712
|
+
return (now - item.timestamp) <= ttl;
|
|
713
|
+
}
|
|
714
|
+
return true;
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// Limit pending count
|
|
718
|
+
while (pending.length >= cfg.maxPendingBatches) {
|
|
719
|
+
pending.shift();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
pending.push({
|
|
723
|
+
timestamp: now,
|
|
724
|
+
data: body
|
|
725
|
+
});
|
|
726
|
+
localStorage.setItem(cfg.localStorageKey, JSON.stringify(pending));
|
|
727
|
+
} catch (err) {
|
|
728
|
+
// localStorage quota exceeded or disabled
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// V5/V6 Restore pending batches on startup with TTL checks
|
|
733
|
+
function _restorePendingBatches() {
|
|
734
|
+
try {
|
|
735
|
+
if (typeof localStorage === "undefined") return;
|
|
736
|
+
|
|
737
|
+
var stored = localStorage.getItem(cfg.localStorageKey);
|
|
738
|
+
if (!stored) return;
|
|
739
|
+
|
|
740
|
+
var pending = [];
|
|
741
|
+
try {
|
|
742
|
+
pending = JSON.parse(stored);
|
|
743
|
+
} catch (err) {
|
|
744
|
+
localStorage.removeItem(cfg.localStorageKey);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
localStorage.removeItem(cfg.localStorageKey);
|
|
749
|
+
|
|
750
|
+
var now = Date.now();
|
|
751
|
+
var ttl = cfg.localStorageTTL || 86400000;
|
|
752
|
+
|
|
753
|
+
pending.forEach(function(item) {
|
|
754
|
+
var body = null;
|
|
755
|
+
if (item && typeof item === "object" && item.data) {
|
|
756
|
+
if (item.timestamp && (now - item.timestamp) > ttl) {
|
|
757
|
+
return; // expired
|
|
758
|
+
}
|
|
759
|
+
body = item.data;
|
|
760
|
+
} else if (typeof item === "string") {
|
|
761
|
+
body = item;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!body) return;
|
|
765
|
+
|
|
766
|
+
var parsed;
|
|
767
|
+
try {
|
|
768
|
+
parsed = JSON.parse(body);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
var events = (parsed && Array.isArray(parsed.events)) ? parsed.events : [];
|
|
773
|
+
events.forEach(function(ev) {
|
|
774
|
+
if (ev && typeof ev === "object" && ev.path) {
|
|
775
|
+
_batchQueue.push({ path: ev.path, payload: ev.payload });
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
if (_batchQueue.length > 0) {
|
|
781
|
+
// FIX: CR-3 所有暂存批次的事件合入队列后,由 _flushBatch 按
|
|
782
|
+
// _MAX_BATCH_EVENTS(100) 分片发送 —— 旧实现单次 flush 整个队列,
|
|
783
|
+
// 恢复 10 批 × 20 条 = 200 条时必然触发服务端 413 毒批循环。
|
|
784
|
+
_flushBatch(false);
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
// ignore errors during restoration
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
function _sendBatchSync(url, body) {
|
|
793
|
+
try {
|
|
794
|
+
var xhr = new XMLHttpRequest();
|
|
795
|
+
xhr.open("POST", url, false); // 同步
|
|
796
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
797
|
+
if (cfg.apiKey) xhr.setRequestHeader("X-API-Key", cfg.apiKey);
|
|
798
|
+
xhr.send(body);
|
|
799
|
+
} catch (e) {
|
|
800
|
+
// 页面关闭时同步 XHR 失败,无法重试
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function _installPageHideHook() {
|
|
805
|
+
// FIX: G3 —— 具名并存模块级,destroy 时可 removeEventListener
|
|
806
|
+
_pageHideHookInstalled = true;
|
|
807
|
+
_onVisibilityChange = function () {
|
|
808
|
+
if (document.hidden) {
|
|
809
|
+
_flushBatch(true);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
_onPageHide = function () {
|
|
814
|
+
_flushBatch(true);
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
if (typeof document !== "undefined") {
|
|
818
|
+
document.addEventListener("visibilitychange", _onVisibilityChange);
|
|
819
|
+
}
|
|
820
|
+
if (typeof global !== "undefined") {
|
|
821
|
+
global.addEventListener("pagehide", _onPageHide);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function _isSelfRequest(url) {
|
|
826
|
+
if (!cfg.endpoint) return false;
|
|
827
|
+
var raw = String(url || "");
|
|
828
|
+
if (!raw) return false;
|
|
829
|
+
// FIX(v0.7.0 Minor): 前缀匹配可被相似域名绕过——http://localhost:8000.evil.com
|
|
830
|
+
// 命中 http://localhost:8000 前缀 → 误判为自请求 → 上报数据被静默丢弃。
|
|
831
|
+
// 改为 URL 解析后比较 scheme/host;endpoint 带路径时要求路径前缀一致。
|
|
832
|
+
// 注意:这是防丢数据的正确性修复,不是安全边界(本判定只决定"是否跳过采集")。
|
|
833
|
+
try {
|
|
834
|
+
var parsed = new URL(raw, typeof location !== "undefined" ? location.href : undefined);
|
|
835
|
+
var endpoint = new URL(cfg.endpoint);
|
|
836
|
+
if (parsed.protocol !== endpoint.protocol || parsed.host !== endpoint.host) return false;
|
|
837
|
+
if (endpoint.pathname && endpoint.pathname !== "/") {
|
|
838
|
+
return parsed.pathname.indexOf(endpoint.pathname) === 0;
|
|
839
|
+
}
|
|
840
|
+
return true;
|
|
841
|
+
} catch (e) {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── 全局异常捕获 ──
|
|
847
|
+
function _installErrorHook() {
|
|
848
|
+
_errorHookInstalled = true;
|
|
849
|
+
// window.onerror → 同步异常
|
|
850
|
+
// FIX: G3 —— 原始 handler 存模块级,destroy 时还原
|
|
851
|
+
_origWindowOnerror = global.onerror;
|
|
852
|
+
global.onerror = function (msg, file, line, col, error) {
|
|
853
|
+
var frames = [];
|
|
854
|
+
if (error && error.stack) {
|
|
855
|
+
frames = _parseStack(error.stack);
|
|
856
|
+
} else {
|
|
857
|
+
frames = [{ file: file || "", line: line || 0, function: "" }];
|
|
858
|
+
}
|
|
859
|
+
_send("/ingest/error", {
|
|
860
|
+
exc_type: error ? error.name : "Error",
|
|
861
|
+
message: String(msg),
|
|
862
|
+
frames: frames,
|
|
863
|
+
trace_id: _traceId,
|
|
864
|
+
source: "browser-sdk",
|
|
865
|
+
extra: {
|
|
866
|
+
session_id: _sessionId,
|
|
867
|
+
url: global.location ? global.location.href : "",
|
|
868
|
+
user_agent: navigator ? navigator.userAgent : "",
|
|
869
|
+
release: cfg.release || undefined,
|
|
870
|
+
},
|
|
871
|
+
}, true);
|
|
872
|
+
// FIX(v0.7.1-b1-10): 透传原 handler 返回值:window.onerror 返回 true 可抑制
|
|
873
|
+
// 浏览器默认错误上报,此前丢弃返回值导致宿主页面的抑制语义失效。
|
|
874
|
+
if (_origWindowOnerror) return _origWindowOnerror.apply(this, arguments);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// unhandledrejection → Promise 未捕获
|
|
878
|
+
// FIX: G3 —— 具名并存模块级,destroy 时可 removeEventListener
|
|
879
|
+
_onUnhandledRejection = function (e) {
|
|
880
|
+
var reason = e.reason;
|
|
881
|
+
var excType = "UnhandledRejection";
|
|
882
|
+
var message = "Promise rejected";
|
|
883
|
+
var stackStr = "";
|
|
884
|
+
|
|
885
|
+
if (reason instanceof Error) {
|
|
886
|
+
excType = reason.name || "UnhandledRejection";
|
|
887
|
+
message = reason.message || String(reason);
|
|
888
|
+
stackStr = reason.stack || "";
|
|
889
|
+
} else if (reason && typeof reason === "object") {
|
|
890
|
+
excType = reason.name || reason.type || "UnhandledRejection";
|
|
891
|
+
message = reason.message || (function() {
|
|
892
|
+
try { return JSON.stringify(reason); } catch(err) { return String(reason); }
|
|
893
|
+
})();
|
|
894
|
+
stackStr = reason.stack || "";
|
|
895
|
+
} else if (reason !== undefined && reason !== null) {
|
|
896
|
+
message = String(reason);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
var frames = stackStr ? _parseStack(stackStr) : [];
|
|
900
|
+
if (frames.length === 0 && global.location) {
|
|
901
|
+
frames = [{ file: global.location.href, line: 0, column: 0, function: "unhandledrejection" }];
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
_send("/ingest/error", {
|
|
905
|
+
exc_type: excType,
|
|
906
|
+
message: message,
|
|
907
|
+
frames: frames,
|
|
908
|
+
trace_id: _traceId,
|
|
909
|
+
source: "browser-sdk",
|
|
910
|
+
extra: {
|
|
911
|
+
session_id: _sessionId,
|
|
912
|
+
url: global.location ? global.location.href : "",
|
|
913
|
+
release: cfg.release || undefined,
|
|
914
|
+
},
|
|
915
|
+
}, true);
|
|
916
|
+
};
|
|
917
|
+
global.addEventListener("unhandledrejection", _onUnhandledRejection);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function _parseStack(stack) {
|
|
921
|
+
if (!stack) return [];
|
|
922
|
+
return stack
|
|
923
|
+
.split("\n")
|
|
924
|
+
.slice(1)
|
|
925
|
+
.filter(function (line) { return line.trim(); })
|
|
926
|
+
.map(function (line) {
|
|
927
|
+
var m = line.trim().match(/at\s+(.*?)\s+\(?(.+?):(\d+):(\d+)?\)?/);
|
|
928
|
+
// v0.5.1: 保留 column(source map 精确定位必需;旧版丢弃了该值)
|
|
929
|
+
if (m) return { file: m[2], line: parseInt(m[3]) || 0, column: parseInt(m[4]) || 0, function: m[1] || "" };
|
|
930
|
+
// Chrome format: at file:line:col
|
|
931
|
+
var m2 = line.trim().match(/at\s+(.+?):(\d+):(\d+)/);
|
|
932
|
+
if (m2) return { file: m2[1], line: parseInt(m2[2]) || 0, column: parseInt(m2[3]) || 0, function: "" };
|
|
933
|
+
return { file: "", line: 0, column: 0, function: line.trim() };
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── 网络请求拦截 ──
|
|
938
|
+
var _networkThrottle = {};
|
|
939
|
+
var _onNetworkCapture = null;
|
|
940
|
+
|
|
941
|
+
function _shouldSampleNetwork() {
|
|
942
|
+
return Math.random() < cfg.networkSampleRate;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function _shouldThrottle(method, url) {
|
|
946
|
+
if (cfg.networkThrottleMs <= 0) return false;
|
|
947
|
+
var key = method.toUpperCase() + ":" + url;
|
|
948
|
+
var now = Date.now();
|
|
949
|
+
if (_networkThrottle[key] && now - _networkThrottle[key] < cfg.networkThrottleMs) {
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
_networkThrottle[key] = now;
|
|
953
|
+
_cleanupNetworkThrottle();
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function _cleanupNetworkThrottle() {
|
|
958
|
+
var maxSize = 1000;
|
|
959
|
+
var keys = Object.keys(_networkThrottle);
|
|
960
|
+
if (keys.length > maxSize) {
|
|
961
|
+
var sortedKeys = keys.sort(function(a, b) {
|
|
962
|
+
return _networkThrottle[a] - _networkThrottle[b];
|
|
963
|
+
});
|
|
964
|
+
var removeCount = keys.length - maxSize;
|
|
965
|
+
for (var i = 0; i < removeCount; i++) {
|
|
966
|
+
delete _networkThrottle[sortedKeys[i]];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function _notifyNetworkCapture(record) {
|
|
972
|
+
if (_onNetworkCapture && typeof _onNetworkCapture === 'function') {
|
|
973
|
+
try {
|
|
974
|
+
_onNetworkCapture(record);
|
|
975
|
+
} catch (e) {
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function _serializeRequestBody(body) {
|
|
981
|
+
if (body === null || body === undefined) {
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
if (typeof body === 'string') {
|
|
985
|
+
return body;
|
|
986
|
+
}
|
|
987
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
988
|
+
try {
|
|
989
|
+
return body.toString();
|
|
990
|
+
} catch (e) {
|
|
991
|
+
return '[URLSearchParams]';
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
995
|
+
try {
|
|
996
|
+
var params = [];
|
|
997
|
+
body.forEach(function(value, key) {
|
|
998
|
+
params.push(key + '=' + value);
|
|
999
|
+
});
|
|
1000
|
+
return params.join('&');
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return '[FormData]';
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
1006
|
+
return '[Blob: ' + body.type + ', ' + body.size + ' bytes]';
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) {
|
|
1009
|
+
return '[ArrayBuffer: ' + body.byteLength + ' bytes]';
|
|
1010
|
+
}
|
|
1011
|
+
if (typeof body === 'object') {
|
|
1012
|
+
try {
|
|
1013
|
+
return JSON.stringify(body);
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
return '[object]';
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return String(body);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function _reportNetworkRecord(record, force) {
|
|
1022
|
+
try {
|
|
1023
|
+
if (record && record.url) {
|
|
1024
|
+
record.url = _redact(record.url);
|
|
1025
|
+
}
|
|
1026
|
+
if (record && record.request_body) {
|
|
1027
|
+
record.request_body = _redact(record.request_body);
|
|
1028
|
+
}
|
|
1029
|
+
if (record && record.response_body) {
|
|
1030
|
+
record.response_body = _redact(record.response_body);
|
|
1031
|
+
}
|
|
1032
|
+
if (record && record.error) {
|
|
1033
|
+
record.error = _redact(String(record.error));
|
|
1034
|
+
}
|
|
1035
|
+
if (_pendingUISilentFailure) {
|
|
1036
|
+
_pendingUISilentFailure.sawNetwork = true;
|
|
1037
|
+
_pendingUISilentFailure.lastNetworkAt = _nowSeconds();
|
|
1038
|
+
}
|
|
1039
|
+
// 摘要入环形缓冲,供 reportSilentFailure 拼装 observed_events
|
|
1040
|
+
_pushRecent(_recentNetwork, _summarizeNetworkRecord(record), cfg.silentFailureContextSize);
|
|
1041
|
+
_notifyNetworkCapture(record);
|
|
1042
|
+
_send("/ingest/network", {
|
|
1043
|
+
record: record,
|
|
1044
|
+
trace_id: _traceId,
|
|
1045
|
+
source: "browser-sdk",
|
|
1046
|
+
extra: { session_id: _sessionId },
|
|
1047
|
+
}, force);
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function _normalizeNetworkError(input) {
|
|
1053
|
+
var payload = {};
|
|
1054
|
+
if (input instanceof Error) {
|
|
1055
|
+
payload.error = input.message || String(input);
|
|
1056
|
+
} else if (input && typeof input === "object") {
|
|
1057
|
+
for (var key in input) {
|
|
1058
|
+
if (input.hasOwnProperty(key)) {
|
|
1059
|
+
payload[key] = input[key];
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
} else if (input !== undefined && input !== null) {
|
|
1063
|
+
payload.error = String(input);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
payload.method = (payload.method || "GET").toUpperCase();
|
|
1067
|
+
payload.url = _redact(payload.url || (global.location ? global.location.href : ""));
|
|
1068
|
+
payload.status_code = typeof payload.status_code === "number"
|
|
1069
|
+
? payload.status_code
|
|
1070
|
+
: (typeof payload.status === "number" ? payload.status : 0);
|
|
1071
|
+
payload.duration_ms = typeof payload.duration_ms === "number" ? payload.duration_ms : 0;
|
|
1072
|
+
payload.request_body = payload.request_body === undefined ? null : _redact(payload.request_body);
|
|
1073
|
+
payload.response_body = payload.response_body === undefined ? null : _redact(payload.response_body);
|
|
1074
|
+
payload.error = _redact(payload.error || payload.error_message || "Network error");
|
|
1075
|
+
return payload;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function _buildNetworkErrorDescription(record) {
|
|
1079
|
+
var method = record && record.method ? record.method : "GET";
|
|
1080
|
+
var url = record && record.url ? record.url : "unknown";
|
|
1081
|
+
var status = record && typeof record.status_code === "number" ? record.status_code : 0;
|
|
1082
|
+
var detail = record && record.error ? String(record.error) : "network request failed";
|
|
1083
|
+
return method + " " + url + " failed (" + status + "): " + detail;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function _autoReportNetworkError(record) {
|
|
1087
|
+
if (!cfg.autoDetectNetworkErrors) return;
|
|
1088
|
+
reportSilentFailure({
|
|
1089
|
+
description: _buildNetworkErrorDescription(record),
|
|
1090
|
+
observed: "Detected by Browser SDK V3 network error auto-report",
|
|
1091
|
+
expected: {
|
|
1092
|
+
type: "network_success",
|
|
1093
|
+
status_code: "2xx",
|
|
1094
|
+
},
|
|
1095
|
+
route: global.location ? global.location.pathname : "",
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function _getTextPreview(node) {
|
|
1100
|
+
if (!node) return "";
|
|
1101
|
+
var text = "";
|
|
1102
|
+
if (typeof node.textContent === "string") {
|
|
1103
|
+
text = node.textContent;
|
|
1104
|
+
} else {
|
|
1105
|
+
text = String(node);
|
|
1106
|
+
}
|
|
1107
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function _captureDomSnapshot(target) {
|
|
1111
|
+
var element = target && target.nodeType === 1 ? target : (document && document.body ? document.body : null);
|
|
1112
|
+
if (!element) return null;
|
|
1113
|
+
return {
|
|
1114
|
+
selector: _getSelector(element),
|
|
1115
|
+
text_preview: _getTextPreview(element),
|
|
1116
|
+
route_path: global.location ? global.location.pathname : "",
|
|
1117
|
+
timestamp: _nowSeconds(),
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function _cancelPendingUISilentFailure() {
|
|
1122
|
+
if (_uiSilentFailureTimer) {
|
|
1123
|
+
clearTimeout(_uiSilentFailureTimer);
|
|
1124
|
+
_uiSilentFailureTimer = null;
|
|
1125
|
+
}
|
|
1126
|
+
_pendingUISilentFailure = null;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function _markUISilentFailureDomChange(target) {
|
|
1130
|
+
if (!_pendingUISilentFailure) return;
|
|
1131
|
+
_pendingUISilentFailure.domChanged = true;
|
|
1132
|
+
_pendingUISilentFailure.domSnapshot = _captureDomSnapshot(target || document.body);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function _buildUISilentFailureDescription(pending) {
|
|
1136
|
+
var selector = pending && pending.selector ? pending.selector : "unknown target";
|
|
1137
|
+
var route = pending && pending.routePath ? pending.routePath : "/";
|
|
1138
|
+
return "UI interaction " + pending.eventType + " on " + selector + " produced no visible change on " + route;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function _scheduleUISilentFailureCheck() {
|
|
1142
|
+
if (!_pendingUISilentFailure) return;
|
|
1143
|
+
if (_uiSilentFailureTimer) {
|
|
1144
|
+
clearTimeout(_uiSilentFailureTimer);
|
|
1145
|
+
}
|
|
1146
|
+
_uiSilentFailureTimer = setTimeout(function () {
|
|
1147
|
+
if (!_pendingUISilentFailure) return;
|
|
1148
|
+
var pending = _pendingUISilentFailure;
|
|
1149
|
+
_uiSilentFailureTimer = null;
|
|
1150
|
+
var routeChanged = (global.location ? global.location.pathname : "") !== pending.routePath;
|
|
1151
|
+
var recentDomChange = _lastDomMutationAt > pending.startedAt;
|
|
1152
|
+
if (pending.domChanged || pending.sawNetwork || routeChanged || recentDomChange) {
|
|
1153
|
+
_pendingUISilentFailure = null;
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
var snapshot = _captureDomSnapshot(pending.target);
|
|
1158
|
+
if (snapshot) {
|
|
1159
|
+
_pushRecent(_recentUI, {
|
|
1160
|
+
event_type: "silent_failure_dom_snapshot",
|
|
1161
|
+
target_selector: snapshot.selector,
|
|
1162
|
+
target_text: snapshot.text_preview,
|
|
1163
|
+
timestamp: snapshot.timestamp,
|
|
1164
|
+
route_path: snapshot.route_path,
|
|
1165
|
+
}, cfg.silentFailureContextSize);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
reportSilentFailure({
|
|
1169
|
+
description: _buildUISilentFailureDescription(pending),
|
|
1170
|
+
observed: "Detected by Browser SDK V6 UI silent failure observer",
|
|
1171
|
+
expected: {
|
|
1172
|
+
type: "ui_feedback",
|
|
1173
|
+
selector: pending.selector,
|
|
1174
|
+
within_ms: cfg.uiSilentFailureTimeoutMs,
|
|
1175
|
+
},
|
|
1176
|
+
route: pending.routePath,
|
|
1177
|
+
});
|
|
1178
|
+
_pendingUISilentFailure = null;
|
|
1179
|
+
}, cfg.uiSilentFailureTimeoutMs);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function _armUISilentFailureDetection(eventType, target) {
|
|
1183
|
+
if (!cfg.autoDetectUISilentFailures) return;
|
|
1184
|
+
if (!target) return;
|
|
1185
|
+
var selector = _getSelector(target);
|
|
1186
|
+
if (!selector) return;
|
|
1187
|
+
_cancelPendingUISilentFailure();
|
|
1188
|
+
|
|
1189
|
+
// 延迟 100ms 再开始观察,避免点击本身的 DOM 变化(focus、:active)被误判
|
|
1190
|
+
var observeStartTime = _nowSeconds() + 0.1;
|
|
1191
|
+
|
|
1192
|
+
_pendingUISilentFailure = {
|
|
1193
|
+
eventType: eventType,
|
|
1194
|
+
selector: selector,
|
|
1195
|
+
target: target,
|
|
1196
|
+
targetText: _getTextPreview(target),
|
|
1197
|
+
routePath: global.location ? global.location.pathname : "",
|
|
1198
|
+
startedAt: observeStartTime,
|
|
1199
|
+
domChanged: false,
|
|
1200
|
+
sawNetwork: false,
|
|
1201
|
+
lastNetworkAt: null,
|
|
1202
|
+
};
|
|
1203
|
+
_scheduleUISilentFailureCheck();
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function _installUISilentFailureObserver() {
|
|
1207
|
+
if (!cfg.captureUI || !cfg.autoDetectUISilentFailures) return;
|
|
1208
|
+
if (typeof MutationObserver === "undefined" || typeof document === "undefined") return;
|
|
1209
|
+
if (_uiMutationObserver) return;
|
|
1210
|
+
var root = document.querySelector(cfg.uiSilentFailureObserveSelector) || document.body;
|
|
1211
|
+
if (!root) return;
|
|
1212
|
+
_lastRoutePath = global.location ? global.location.pathname : "";
|
|
1213
|
+
_uiMutationObserver = new MutationObserver(function (mutations) {
|
|
1214
|
+
_lastDomMutationAt = _nowSeconds();
|
|
1215
|
+
if (_pendingUISilentFailure && mutations && mutations.length > 0) {
|
|
1216
|
+
var first = mutations[0];
|
|
1217
|
+
_markUISilentFailureDomChange(first.target || root);
|
|
1218
|
+
}
|
|
1219
|
+
var currentRoute = global.location ? global.location.pathname : "";
|
|
1220
|
+
if (_pendingUISilentFailure && currentRoute !== _lastRoutePath) {
|
|
1221
|
+
_pendingUISilentFailure.domChanged = true;
|
|
1222
|
+
}
|
|
1223
|
+
_lastRoutePath = currentRoute;
|
|
1224
|
+
});
|
|
1225
|
+
_uiMutationObserver.observe(root, {
|
|
1226
|
+
subtree: true,
|
|
1227
|
+
childList: true,
|
|
1228
|
+
attributes: true,
|
|
1229
|
+
characterData: true,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function _installNetworkHook() {
|
|
1234
|
+
if (!cfg.captureNetwork) return;
|
|
1235
|
+
|
|
1236
|
+
// FIX: G3 —— 原始 fetch 存模块级,destroy 时还原
|
|
1237
|
+
_origFetch = global.fetch;
|
|
1238
|
+
if (_origFetch) {
|
|
1239
|
+
_networkHookInstalled = true;
|
|
1240
|
+
global.fetch = function () {
|
|
1241
|
+
var args = arguments;
|
|
1242
|
+
var rawUrl = args[0];
|
|
1243
|
+
var url = "";
|
|
1244
|
+
if (typeof rawUrl === "string") {
|
|
1245
|
+
url = rawUrl;
|
|
1246
|
+
} else if (rawUrl && typeof rawUrl === "object") {
|
|
1247
|
+
url = rawUrl.url || rawUrl.href || String(rawUrl);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (_isSelfRequest(url)) {
|
|
1251
|
+
return _origFetch.apply(this, args);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// FIX(v0.7.1-b7-1): fetch(new Request(url, {method:...})) 时 method 在
|
|
1255
|
+
// Request 对象(args[0])内,第二个 init 参数 args[1] 为 undefined——此前
|
|
1256
|
+
// 只读 args[1].method 导致 method 恒误记 GET(POST 请求被记成 GET)。
|
|
1257
|
+
var init = args[1] || {};
|
|
1258
|
+
var reqObj = (rawUrl && typeof rawUrl === "object") ? rawUrl : null;
|
|
1259
|
+
var method = (init.method || (reqObj && reqObj.method) || "GET");
|
|
1260
|
+
|
|
1261
|
+
if (!_shouldSampleNetwork()) {
|
|
1262
|
+
return _origFetch.apply(this, args);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (_shouldThrottle(method, url)) {
|
|
1266
|
+
return _origFetch.apply(this, args);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
var start = Date.now();
|
|
1270
|
+
var reqBody = _serializeRequestBody((args[1] && args[1].body) || null);
|
|
1271
|
+
|
|
1272
|
+
return _origFetch.apply(this, args).then(function (res) {
|
|
1273
|
+
var clone = res.clone();
|
|
1274
|
+
clone.text().then(function (text) {
|
|
1275
|
+
var record = {
|
|
1276
|
+
url: url,
|
|
1277
|
+
method: method.toUpperCase(),
|
|
1278
|
+
status_code: res.status,
|
|
1279
|
+
duration_ms: Date.now() - start,
|
|
1280
|
+
request_body: reqBody,
|
|
1281
|
+
response_body: text.slice(0, 2000),
|
|
1282
|
+
};
|
|
1283
|
+
_reportNetworkRecord(record);
|
|
1284
|
+
}).catch(function () {});
|
|
1285
|
+
return res;
|
|
1286
|
+
}).catch(function (err) {
|
|
1287
|
+
var record = {
|
|
1288
|
+
url: url,
|
|
1289
|
+
method: method.toUpperCase(),
|
|
1290
|
+
status_code: 0,
|
|
1291
|
+
error: err.message,
|
|
1292
|
+
duration_ms: Date.now() - start,
|
|
1293
|
+
request_body: reqBody,
|
|
1294
|
+
};
|
|
1295
|
+
_reportNetworkRecord(record);
|
|
1296
|
+
_autoReportNetworkError(record);
|
|
1297
|
+
throw err;
|
|
1298
|
+
});
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ── XMLHttpRequest 拦截 ──
|
|
1304
|
+
// FIX(v0.7.1-b1-9): XHR 对象复用(open+send 多轮)时每轮 send 叠加 4 个监听器
|
|
1305
|
+
// 且从不摘除:第 3 次复用后单个请求被记录 3 次(已复现)。挂新监听前先摘除
|
|
1306
|
+
// 上一轮的,且每个终态 handler 触发时自摘除(once 语义),杜绝跨轮累积与
|
|
1307
|
+
// 旧轮次闭包(旧 url/method/start)误报新请求。
|
|
1308
|
+
function _detachNetListeners(xhr) {
|
|
1309
|
+
if (xhr._aiDebugNet) {
|
|
1310
|
+
var h = xhr._aiDebugNet;
|
|
1311
|
+
try {
|
|
1312
|
+
xhr.removeEventListener("load", h.load);
|
|
1313
|
+
xhr.removeEventListener("error", h.error);
|
|
1314
|
+
xhr.removeEventListener("abort", h.abort);
|
|
1315
|
+
xhr.removeEventListener("timeout", h.timeout);
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
}
|
|
1318
|
+
xhr._aiDebugNet = null;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function _installXhrHook() {
|
|
1323
|
+
if (!cfg.captureNetwork) return;
|
|
1324
|
+
|
|
1325
|
+
// FIX: G3 —— 原始 XHR 方法存模块级,destroy 时还原原型
|
|
1326
|
+
_origXhrOpen = XMLHttpRequest.prototype.open;
|
|
1327
|
+
_origXhrSend = XMLHttpRequest.prototype.send;
|
|
1328
|
+
_xhrHookInstalled = true;
|
|
1329
|
+
|
|
1330
|
+
XMLHttpRequest.prototype.open = function () {
|
|
1331
|
+
var args = arguments;
|
|
1332
|
+
this._aiDebugMethod = (args[0] || "GET").toUpperCase();
|
|
1333
|
+
this._aiDebugUrl = args[1] || "";
|
|
1334
|
+
this._aiDebugSkip = _isSelfRequest(this._aiDebugUrl);
|
|
1335
|
+
return _origXhrOpen.apply(this, args);
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
XMLHttpRequest.prototype.send = function () {
|
|
1339
|
+
var args = arguments;
|
|
1340
|
+
var xhr = this;
|
|
1341
|
+
|
|
1342
|
+
if (xhr._aiDebugSkip) {
|
|
1343
|
+
return _origXhrSend.apply(xhr, args);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
var method = xhr._aiDebugMethod || "GET";
|
|
1347
|
+
var url = xhr._aiDebugUrl || "";
|
|
1348
|
+
|
|
1349
|
+
if (!_shouldSampleNetwork()) {
|
|
1350
|
+
return _origXhrSend.apply(xhr, args);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (_shouldThrottle(method, url)) {
|
|
1354
|
+
return _origXhrSend.apply(xhr, args);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
var start = Date.now();
|
|
1358
|
+
var reqBody = _serializeRequestBody(args[0] || null);
|
|
1359
|
+
|
|
1360
|
+
function _onLoad() {
|
|
1361
|
+
try {
|
|
1362
|
+
_detachNetListeners(xhr);
|
|
1363
|
+
var responseText = "";
|
|
1364
|
+
try {
|
|
1365
|
+
responseText = xhr.responseText ? xhr.responseText.slice(0, 2000) : "";
|
|
1366
|
+
} catch (e) {
|
|
1367
|
+
responseText = "";
|
|
1368
|
+
}
|
|
1369
|
+
var record = {
|
|
1370
|
+
url: url,
|
|
1371
|
+
method: method.toUpperCase(),
|
|
1372
|
+
status_code: xhr.status,
|
|
1373
|
+
duration_ms: Date.now() - start,
|
|
1374
|
+
request_body: reqBody,
|
|
1375
|
+
response_body: responseText,
|
|
1376
|
+
};
|
|
1377
|
+
_reportNetworkRecord(record);
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function _onError() {
|
|
1383
|
+
try {
|
|
1384
|
+
_detachNetListeners(xhr);
|
|
1385
|
+
var record = {
|
|
1386
|
+
url: url,
|
|
1387
|
+
method: method.toUpperCase(),
|
|
1388
|
+
status_code: 0,
|
|
1389
|
+
error: "XHR error",
|
|
1390
|
+
duration_ms: Date.now() - start,
|
|
1391
|
+
request_body: reqBody,
|
|
1392
|
+
};
|
|
1393
|
+
_reportNetworkRecord(record);
|
|
1394
|
+
_autoReportNetworkError(record);
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function _onAbort() {
|
|
1400
|
+
try {
|
|
1401
|
+
_detachNetListeners(xhr);
|
|
1402
|
+
var record = {
|
|
1403
|
+
url: url,
|
|
1404
|
+
method: method.toUpperCase(),
|
|
1405
|
+
status_code: 0,
|
|
1406
|
+
error: "XHR aborted",
|
|
1407
|
+
duration_ms: Date.now() - start,
|
|
1408
|
+
request_body: reqBody,
|
|
1409
|
+
};
|
|
1410
|
+
_reportNetworkRecord(record);
|
|
1411
|
+
_autoReportNetworkError(record);
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function _onTimeout() {
|
|
1417
|
+
try {
|
|
1418
|
+
_detachNetListeners(xhr);
|
|
1419
|
+
var record = {
|
|
1420
|
+
url: url,
|
|
1421
|
+
method: method.toUpperCase(),
|
|
1422
|
+
status_code: 0,
|
|
1423
|
+
error: "XHR timeout",
|
|
1424
|
+
duration_ms: Date.now() - start,
|
|
1425
|
+
request_body: reqBody,
|
|
1426
|
+
};
|
|
1427
|
+
_reportNetworkRecord(record);
|
|
1428
|
+
_autoReportNetworkError(record);
|
|
1429
|
+
} catch (e) {
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
_detachNetListeners(xhr);
|
|
1435
|
+
xhr._aiDebugNet = { load: _onLoad, error: _onError, abort: _onAbort, timeout: _onTimeout };
|
|
1436
|
+
xhr.addEventListener("load", _onLoad);
|
|
1437
|
+
xhr.addEventListener("error", _onError);
|
|
1438
|
+
xhr.addEventListener("abort", _onAbort);
|
|
1439
|
+
xhr.addEventListener("timeout", _onTimeout);
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return _origXhrSend.apply(xhr, args);
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ── UI 事件捕获 ──
|
|
1448
|
+
function _installUIHook() {
|
|
1449
|
+
if (!cfg.captureUI) return;
|
|
1450
|
+
|
|
1451
|
+
_uiHookInstalled = true;
|
|
1452
|
+
// FIX: G3 —— handler 具名并存 _uiHandlers(destroy 需同一引用 + capture=true 摘除);
|
|
1453
|
+
// _debounce 用模块级并加过期/尺寸清理(键含动态 className,此前无限增长)。
|
|
1454
|
+
_uiHandlers = {};
|
|
1455
|
+
_uiHookEvents.forEach(function (evt) {
|
|
1456
|
+
var handler = function (e) {
|
|
1457
|
+
// 去重:同一秒内同一元素同类事件只报一次
|
|
1458
|
+
var target = e.target;
|
|
1459
|
+
if (!target) return;
|
|
1460
|
+
var key = evt + ":" + (target.id || target.className || target.tagName);
|
|
1461
|
+
var now = Date.now();
|
|
1462
|
+
if (_debounce[key] && now - _debounce[key] < _DEBOUNCE_TTL_MS) return;
|
|
1463
|
+
_debounce[key] = now;
|
|
1464
|
+
if (Object.keys(_debounce).length > _DEBOUNCE_MAX_KEYS) {
|
|
1465
|
+
_cleanupDebounce(now);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
var uiEvent = {
|
|
1469
|
+
event_type: e.type,
|
|
1470
|
+
target_selector: _getSelector(target),
|
|
1471
|
+
target_text: (target.textContent || "").slice(0, 100),
|
|
1472
|
+
timestamp: now / 1000,
|
|
1473
|
+
route_path: global.location ? global.location.pathname : "",
|
|
1474
|
+
};
|
|
1475
|
+
// 入环形缓冲,供 reportSilentFailure 拼装 observed_events
|
|
1476
|
+
_pushRecent(_recentUI, uiEvent, cfg.silentFailureContextSize);
|
|
1477
|
+
|
|
1478
|
+
_send("/ingest/ui-event", {
|
|
1479
|
+
event: uiEvent,
|
|
1480
|
+
trace_id: _traceId,
|
|
1481
|
+
source: "browser-sdk",
|
|
1482
|
+
extra: { session_id: _sessionId },
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
if (evt === "click" || evt === "submit") {
|
|
1486
|
+
_armUISilentFailureDetection(evt, target);
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
_uiHandlers[evt] = handler;
|
|
1490
|
+
document.addEventListener(evt, handler, true);
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function _getSelector(el) {
|
|
1495
|
+
if (!el) return "";
|
|
1496
|
+
if (el.id) return "#" + el.id;
|
|
1497
|
+
if (el.className && typeof el.className === "string") {
|
|
1498
|
+
return el.tagName.toLowerCase() + "." + el.className.split(" ").join(".");
|
|
1499
|
+
}
|
|
1500
|
+
return el.tagName ? el.tagName.toLowerCase() : "";
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// ── 控制台日志捕获 ──
|
|
1504
|
+
var _consoleHookInstalled = false;
|
|
1505
|
+
|
|
1506
|
+
function _installConsoleHook() {
|
|
1507
|
+
if (!cfg.captureConsole || _consoleHookInstalled) return;
|
|
1508
|
+
_consoleHookInstalled = true;
|
|
1509
|
+
|
|
1510
|
+
// FIX: G3 —— 原始 console 方法存模块级,destroy 时还原
|
|
1511
|
+
_origConsoleError = global.console.error;
|
|
1512
|
+
if (_origConsoleError) {
|
|
1513
|
+
global.console.error = function () {
|
|
1514
|
+
_sendConsole("error", Array.prototype.slice.call(arguments));
|
|
1515
|
+
_origConsoleError.apply(global.console, arguments);
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
_origConsoleWarn = global.console.warn;
|
|
1520
|
+
if (_origConsoleWarn) {
|
|
1521
|
+
global.console.warn = function () {
|
|
1522
|
+
_sendConsole("warn", Array.prototype.slice.call(arguments));
|
|
1523
|
+
_origConsoleWarn.apply(global.console, arguments);
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function _sendConsole(level, args) {
|
|
1529
|
+
var messages = [];
|
|
1530
|
+
for (var i = 0; i < args.length; i++) {
|
|
1531
|
+
var arg = args[i];
|
|
1532
|
+
try {
|
|
1533
|
+
if (typeof arg === "object") {
|
|
1534
|
+
messages.push(JSON.stringify(_redact(arg)));
|
|
1535
|
+
} else {
|
|
1536
|
+
messages.push(String(arg));
|
|
1537
|
+
}
|
|
1538
|
+
} catch (e) {
|
|
1539
|
+
messages.push("[object]");
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
_send("/ingest/console", {
|
|
1543
|
+
level: level,
|
|
1544
|
+
message: messages.join(" "),
|
|
1545
|
+
trace_id: _traceId,
|
|
1546
|
+
source: "browser-sdk",
|
|
1547
|
+
extra: {
|
|
1548
|
+
session_id: _sessionId,
|
|
1549
|
+
url: global.location ? global.location.href : "",
|
|
1550
|
+
},
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ── 公开 API ──
|
|
1555
|
+
/**
|
|
1556
|
+
* 初始化 SDK
|
|
1557
|
+
* @param {object} opts - { endpoint, apiKey, captureErrors, captureNetwork, captureUI, captureConsole, sampleRate, networkSampleRate, networkThrottleMs, autoDetectNetworkErrors, autoDetectUISilentFailures, uiSilentFailureTimeoutMs, uiSilentFailureObserveSelector, silentFailureContextSize, batchSize, batchInterval, maxRetries }
|
|
1558
|
+
*/
|
|
1559
|
+
function init(opts) {
|
|
1560
|
+
if (_inited) return;
|
|
1561
|
+
// FIX: G3 —— HMR/重复载入兜底:全局已有上一代实例时先销毁旧实例,
|
|
1562
|
+
// 避免监听器/定时器/原型包装叠加导致事件重复上报。
|
|
1563
|
+
if (global.__AI_DEBUG_INSTANCE__ && typeof global.__AI_DEBUG_INSTANCE__.destroy === "function") {
|
|
1564
|
+
try { global.__AI_DEBUG_INSTANCE__.destroy(); } catch (e) {}
|
|
1565
|
+
}
|
|
1566
|
+
if (opts) {
|
|
1567
|
+
for (var k in opts) {
|
|
1568
|
+
if (opts.hasOwnProperty(k) && cfg.hasOwnProperty(k)) {
|
|
1569
|
+
cfg[k] = opts[k];
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
// 空数组回退到内置默认列表,防止关闭脱敏
|
|
1574
|
+
if (!cfg.redactFields || (Array.isArray(cfg.redactFields) && cfg.redactFields.length === 0)) {
|
|
1575
|
+
cfg.redactFields = _DEFAULT_REDACT_FIELDS;
|
|
1576
|
+
}
|
|
1577
|
+
if (!cfg.endpoint) {
|
|
1578
|
+
console.warn("[ai-debug] endpoint 未配置,SDK 不上报");
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
_inited = true;
|
|
1582
|
+
_destroyed = false;
|
|
1583
|
+
_installErrorHook();
|
|
1584
|
+
_installNetworkHook();
|
|
1585
|
+
_installXhrHook();
|
|
1586
|
+
_installUIHook();
|
|
1587
|
+
_installUISilentFailureObserver();
|
|
1588
|
+
_installConsoleHook();
|
|
1589
|
+
_installPageHideHook();
|
|
1590
|
+
_restorePendingBatches(); // V5 恢复暂存的批次
|
|
1591
|
+
// 主动换取 beacon 令牌并周期性续期(S1:sendBeacon 场景避免永久 Key 进 URL)
|
|
1592
|
+
_refreshBeaconToken();
|
|
1593
|
+
// FIX: G3 —— 保存心跳句柄,destroy 时 clearInterval(此前未保存、无法停止)
|
|
1594
|
+
_tokenRefreshTimer = setInterval(_refreshBeaconToken, 25000);
|
|
1595
|
+
// FIX: G3 —— 记录当前实例,供 HMR 重载时销毁旧实例
|
|
1596
|
+
global.__AI_DEBUG_INSTANCE__ = api;
|
|
1597
|
+
console.log("[ai-debug] SDK initialized, session=" + _sessionId);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* FIX: G3 —— 销毁 SDK:摘除全部监听器、还原被包装的全局/原型、停止全部定时器、
|
|
1602
|
+
* 清空队列与去重表,并重置初始化标志(之后可安全地重新 init)。幂等,可多次调用。
|
|
1603
|
+
*
|
|
1604
|
+
* 典型用途:
|
|
1605
|
+
* - Vite/webpack HMR 重载(init 内会经 __AI_DEBUG_INSTANCE__ 自动销毁旧实例),
|
|
1606
|
+
* 避免监听器叠加与事件重复上报;
|
|
1607
|
+
* - SPA 卸载 / 测试收尾,防止心跳与定时器泄漏。
|
|
1608
|
+
*
|
|
1609
|
+
* @param {object} [opts]
|
|
1610
|
+
* @param {boolean} [opts.flush=true] - 销毁前是否先把待发批次冲刷上报(best-effort)
|
|
1611
|
+
*/
|
|
1612
|
+
// FIX(v0.7.1-b15-1): 已知限制文档化——destroy 只还原到「SDK 安装前」的状态;
|
|
1613
|
+
// 若第三方脚本在 SDK 之后包装了 fetch/XHR/console,SDK 无法感知/还原它们的
|
|
1614
|
+
// 包装(与业界 destroy 语义一致)。详见 docs/public/SDK_GUIDE.md destroy() 条目。
|
|
1615
|
+
function destroy(opts) {
|
|
1616
|
+
var shouldFlush = !(opts && opts.flush === false);
|
|
1617
|
+
_destroyed = true;
|
|
1618
|
+
|
|
1619
|
+
// 1) 先冲刷待发数据(钩子还原前,best-effort)
|
|
1620
|
+
if (shouldFlush) {
|
|
1621
|
+
try { _flushBatch(true); } catch (e) {}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// 2) 停止全部定时器
|
|
1625
|
+
if (_tokenRefreshTimer) { clearInterval(_tokenRefreshTimer); _tokenRefreshTimer = null; }
|
|
1626
|
+
if (_batchTimer) { clearTimeout(_batchTimer); _batchTimer = null; }
|
|
1627
|
+
if (_pendingTimer) { clearTimeout(_pendingTimer); _pendingTimer = null; }
|
|
1628
|
+
if (_uiSilentFailureTimer) { clearTimeout(_uiSilentFailureTimer); _uiSilentFailureTimer = null; }
|
|
1629
|
+
|
|
1630
|
+
// 3) 还原 window.onerror + 摘除 unhandledrejection
|
|
1631
|
+
if (_errorHookInstalled) {
|
|
1632
|
+
global.onerror = _origWindowOnerror;
|
|
1633
|
+
_origWindowOnerror = null;
|
|
1634
|
+
if (_onUnhandledRejection) {
|
|
1635
|
+
try { global.removeEventListener("unhandledrejection", _onUnhandledRejection); } catch (e) {}
|
|
1636
|
+
}
|
|
1637
|
+
_onUnhandledRejection = null;
|
|
1638
|
+
_errorHookInstalled = false;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// 4) 摘除 pagehide / visibilitychange
|
|
1642
|
+
if (_pageHideHookInstalled) {
|
|
1643
|
+
if (_onPageHide) {
|
|
1644
|
+
try { global.removeEventListener("pagehide", _onPageHide); } catch (e) {}
|
|
1645
|
+
}
|
|
1646
|
+
_onPageHide = null;
|
|
1647
|
+
if (_onVisibilityChange && typeof document !== "undefined") {
|
|
1648
|
+
try { document.removeEventListener("visibilitychange", _onVisibilityChange); } catch (e) {}
|
|
1649
|
+
}
|
|
1650
|
+
_onVisibilityChange = null;
|
|
1651
|
+
_pageHideHookInstalled = false;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// 5) 摘除 UI 捕获监听(注册时带 capture=true,摘除必须同参)
|
|
1655
|
+
if (_uiHookInstalled && _uiHandlers && typeof document !== "undefined") {
|
|
1656
|
+
for (var i = 0; i < _uiHookEvents.length; i++) {
|
|
1657
|
+
var evt = _uiHookEvents[i];
|
|
1658
|
+
if (_uiHandlers[evt]) {
|
|
1659
|
+
try { document.removeEventListener(evt, _uiHandlers[evt], true); } catch (e) {}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
_uiHandlers = null;
|
|
1664
|
+
_uiHookInstalled = false;
|
|
1665
|
+
|
|
1666
|
+
// 6) 还原 fetch / XHR 原型 / console
|
|
1667
|
+
if (_networkHookInstalled) {
|
|
1668
|
+
global.fetch = _origFetch;
|
|
1669
|
+
_origFetch = null;
|
|
1670
|
+
_networkHookInstalled = false;
|
|
1671
|
+
}
|
|
1672
|
+
if (_xhrHookInstalled && typeof XMLHttpRequest !== "undefined") {
|
|
1673
|
+
XMLHttpRequest.prototype.open = _origXhrOpen;
|
|
1674
|
+
XMLHttpRequest.prototype.send = _origXhrSend;
|
|
1675
|
+
_origXhrOpen = null;
|
|
1676
|
+
_origXhrSend = null;
|
|
1677
|
+
_xhrHookInstalled = false;
|
|
1678
|
+
}
|
|
1679
|
+
if (_consoleHookInstalled && global.console) {
|
|
1680
|
+
if (_origConsoleError) { global.console.error = _origConsoleError; }
|
|
1681
|
+
if (_origConsoleWarn) { global.console.warn = _origConsoleWarn; }
|
|
1682
|
+
_origConsoleError = null;
|
|
1683
|
+
_origConsoleWarn = null;
|
|
1684
|
+
_consoleHookInstalled = false;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// 7) 断开 MutationObserver
|
|
1688
|
+
if (_uiMutationObserver) {
|
|
1689
|
+
try { _uiMutationObserver.disconnect(); } catch (e) {}
|
|
1690
|
+
_uiMutationObserver = null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// 8) 清空队列 / 缓冲 / 去重表 / 回调槽
|
|
1694
|
+
_batchQueue = [];
|
|
1695
|
+
_pendingBatches = [];
|
|
1696
|
+
_batchTimestamps = [];
|
|
1697
|
+
_recentNetwork = [];
|
|
1698
|
+
_recentUI = [];
|
|
1699
|
+
_debounce = {};
|
|
1700
|
+
_networkThrottle = {};
|
|
1701
|
+
_pendingUISilentFailure = null;
|
|
1702
|
+
_onNetworkCapture = null;
|
|
1703
|
+
_onSilentFailureReport = null;
|
|
1704
|
+
|
|
1705
|
+
// 9) 重置初始化标志 + 清除全局实例标记(允许安全重建)
|
|
1706
|
+
_inited = false;
|
|
1707
|
+
if (global.__AI_DEBUG_INSTANCE__ === api) {
|
|
1708
|
+
try { delete global.__AI_DEBUG_INSTANCE__; } catch (e) { global.__AI_DEBUG_INSTANCE__ = undefined; }
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* 手动上报静默失败
|
|
1714
|
+
*
|
|
1715
|
+
* 自动从环形缓冲取出最近 N 条 network/UI 事件(N = cfg.silentFailureContextSize,默认 20)
|
|
1716
|
+
* 拼装为 observed_events 数组与 trace_id 一起上报,服务端会按 kind 分类入库,
|
|
1717
|
+
* 保证 AI 调试时通过 MCP `context` 工具能拿到完整事件链。
|
|
1718
|
+
*
|
|
1719
|
+
* @param {object} payload
|
|
1720
|
+
* @param {string} payload.description - 静默失败描述(必填)
|
|
1721
|
+
* @param {string} [payload.observed] - 用户对现象的文字描述,如"点击后无反应"
|
|
1722
|
+
* @param {object} [payload.expected] - 期望行为,如 {type:"route_change", to:"/done"}
|
|
1723
|
+
* @param {string} [payload.route] - 当前路由(可选,默认取 location.pathname)
|
|
1724
|
+
*
|
|
1725
|
+
* observed_events 元素结构(SDK 自动附加,非用户传入):
|
|
1726
|
+
* {
|
|
1727
|
+
* kind: "network" | "ui", // 事件类型
|
|
1728
|
+
* data: { ... } // network 摘要或 UI 事件原始结构
|
|
1729
|
+
* }
|
|
1730
|
+
* - kind="network" 时 data 形如:
|
|
1731
|
+
* { method, url, status_code, duration_ms, timestamp, request_body_preview, error }
|
|
1732
|
+
* - kind="ui" 时 data 形如:
|
|
1733
|
+
* { event_type, target_selector, target_text, timestamp, route_path }
|
|
1734
|
+
*/
|
|
1735
|
+
function reportSilentFailure(payload) {
|
|
1736
|
+
payload = payload || {};
|
|
1737
|
+
var observedEvents = [];
|
|
1738
|
+
for (var i = 0; i < _recentNetwork.length; i++) {
|
|
1739
|
+
observedEvents.push({ kind: "network", data: _recentNetwork[i] });
|
|
1740
|
+
}
|
|
1741
|
+
for (var j = 0; j < _recentUI.length; j++) {
|
|
1742
|
+
observedEvents.push({ kind: "ui", data: _recentUI[j] });
|
|
1743
|
+
}
|
|
1744
|
+
var silentPayload = {
|
|
1745
|
+
message: payload.description,
|
|
1746
|
+
expectation: payload.expected,
|
|
1747
|
+
observed: payload.observed,
|
|
1748
|
+
observed_events: observedEvents,
|
|
1749
|
+
trace_id: _traceId,
|
|
1750
|
+
source: "browser-sdk",
|
|
1751
|
+
extra: {
|
|
1752
|
+
session_id: _sessionId,
|
|
1753
|
+
url: global.location ? global.location.href : "",
|
|
1754
|
+
},
|
|
1755
|
+
};
|
|
1756
|
+
if (_onSilentFailureReport && typeof _onSilentFailureReport === "function") {
|
|
1757
|
+
try {
|
|
1758
|
+
_onSilentFailureReport(silentPayload);
|
|
1759
|
+
} catch (e) {
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
_send("/ingest/silent-failure", {
|
|
1763
|
+
message: silentPayload.message,
|
|
1764
|
+
expectation: silentPayload.expectation,
|
|
1765
|
+
observed: silentPayload.observed,
|
|
1766
|
+
observed_events: silentPayload.observed_events,
|
|
1767
|
+
trace_id: silentPayload.trace_id,
|
|
1768
|
+
source: silentPayload.source,
|
|
1769
|
+
extra: silentPayload.extra,
|
|
1770
|
+
}, true);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* 手动上报网络错误,并自动附带最近的 UI / network 上下文。
|
|
1775
|
+
* @param {Error|object|string} error - Error 实例或 {method, url, status_code, duration_ms, request_body, response_body, error}
|
|
1776
|
+
*/
|
|
1777
|
+
function reportNetworkError(error) {
|
|
1778
|
+
var record = _normalizeNetworkError(error);
|
|
1779
|
+
// FIX: P1-G2 —— 手动 API 豁免采样(force=true)
|
|
1780
|
+
_reportNetworkRecord(record, true);
|
|
1781
|
+
_autoReportNetworkError(record);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* 手动上报异常
|
|
1786
|
+
* @param {Error} error
|
|
1787
|
+
* @param {object} extra
|
|
1788
|
+
*/
|
|
1789
|
+
function reportError(error, extra) {
|
|
1790
|
+
_send("/ingest/error", {
|
|
1791
|
+
exc_type: error ? error.name || "Error" : "Error",
|
|
1792
|
+
message: error ? error.message || String(error) : "",
|
|
1793
|
+
frames: error && error.stack ? _parseStack(error.stack) : [],
|
|
1794
|
+
trace_id: _traceId,
|
|
1795
|
+
source: "browser-sdk",
|
|
1796
|
+
extra: Object.assign({
|
|
1797
|
+
session_id: _sessionId,
|
|
1798
|
+
url: global.location ? global.location.href : "",
|
|
1799
|
+
release: cfg.release || undefined,
|
|
1800
|
+
}, extra || {}),
|
|
1801
|
+
}, true);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* 手动上报 UI 事件
|
|
1806
|
+
* @param {object} event - { event_type, target_selector, route_path }
|
|
1807
|
+
*/
|
|
1808
|
+
function reportUIEvent(event) {
|
|
1809
|
+
_send("/ingest/ui-event", {
|
|
1810
|
+
event: {
|
|
1811
|
+
event_type: event.event_type || "click",
|
|
1812
|
+
target_selector: event.target_selector || "",
|
|
1813
|
+
target_text: event.target_text || "",
|
|
1814
|
+
timestamp: Date.now() / 1000,
|
|
1815
|
+
route_path: event.route_path || (global.location ? global.location.pathname : ""),
|
|
1816
|
+
},
|
|
1817
|
+
trace_id: _traceId,
|
|
1818
|
+
source: "browser-sdk",
|
|
1819
|
+
extra: { session_id: _sessionId },
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* 获取当前会话 ID
|
|
1825
|
+
*/
|
|
1826
|
+
function getSessionId() {
|
|
1827
|
+
return _sessionId;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* 获取当前追踪 ID
|
|
1832
|
+
*/
|
|
1833
|
+
function getTraceId() {
|
|
1834
|
+
return _traceId;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* 设置追踪 ID(用于关联不同上报到同一业务操作)
|
|
1839
|
+
* @param {string} id - 新的追踪 ID
|
|
1840
|
+
*/
|
|
1841
|
+
function setTraceId(id) {
|
|
1842
|
+
if (id) _traceId = id;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* 手动 flush 批量队列(用于测试或需要立即上报的场景)
|
|
1847
|
+
*/
|
|
1848
|
+
function flush() {
|
|
1849
|
+
_flushBatch(false);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// ── 导出 ──
|
|
1853
|
+
var api = {
|
|
1854
|
+
init: init,
|
|
1855
|
+
destroy: destroy,
|
|
1856
|
+
flush: flush,
|
|
1857
|
+
reportSilentFailure: reportSilentFailure,
|
|
1858
|
+
reportNetworkError: reportNetworkError,
|
|
1859
|
+
reportError: reportError,
|
|
1860
|
+
reportUIEvent: reportUIEvent,
|
|
1861
|
+
getSessionId: getSessionId,
|
|
1862
|
+
getTraceId: getTraceId,
|
|
1863
|
+
setTraceId: setTraceId,
|
|
1864
|
+
_getPublicConfig: function() {
|
|
1865
|
+
var copy = {};
|
|
1866
|
+
for (var k in cfg) {
|
|
1867
|
+
if (cfg.hasOwnProperty(k) && k !== 'apiKey') {
|
|
1868
|
+
copy[k] = cfg[k];
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return copy;
|
|
1872
|
+
},
|
|
1873
|
+
// 测试辅助:运行时改单个配置项(绕过 init 的 _inited 守卫,仅供 e2e 测试用)
|
|
1874
|
+
_setConfig: function(key, value) {
|
|
1875
|
+
if (cfg.hasOwnProperty(key)) cfg[key] = value;
|
|
1876
|
+
},
|
|
1877
|
+
// 测试辅助:只读查询初始化状态
|
|
1878
|
+
get _inited() { return _inited; },
|
|
1879
|
+
_getPendingUISilentFailure: function() { return _pendingUISilentFailure; },
|
|
1880
|
+
_getLastDomMutationAt: function() { return _lastDomMutationAt; },
|
|
1881
|
+
_getUIMutationObserver: function() { return _uiMutationObserver; },
|
|
1882
|
+
// FIX: G3 测试辅助:去重表当前键数(验证过期/尺寸清理生效)
|
|
1883
|
+
_getDebounceSize: function() { return Object.keys(_debounce).length; },
|
|
1884
|
+
_isDestroyed: function() { return _destroyed; },
|
|
1885
|
+
onNetworkCapture: function (callback) {
|
|
1886
|
+
_onNetworkCapture = callback;
|
|
1887
|
+
},
|
|
1888
|
+
onSilentFailureReport: function (callback) {
|
|
1889
|
+
_onSilentFailureReport = callback;
|
|
1890
|
+
},
|
|
1891
|
+
_captureDomSnapshot: _captureDomSnapshot,
|
|
1892
|
+
_computeRetryDelay: _computeRetryDelay,
|
|
1893
|
+
_isNonRetryableStatus: _isNonRetryableStatus,
|
|
1894
|
+
_parseRetryAfter: _parseRetryAfter,
|
|
1895
|
+
_saveToLocalStorage: _saveToLocalStorage,
|
|
1896
|
+
_restorePendingBatches: _restorePendingBatches,
|
|
1897
|
+
_flushBatch: _flushBatch,
|
|
1898
|
+
// 测试辅助(v0.7.0 Minor 单测,只读):自请求判定与定时 flush 句柄状态
|
|
1899
|
+
_isSelfRequest: _isSelfRequest,
|
|
1900
|
+
get _batchTimerScheduled() { return !!_batchTimer; },
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
// 支持 CommonJS / ES module / 全局变量
|
|
1904
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
1905
|
+
module.exports = api;
|
|
1906
|
+
} else if (typeof define === "function" && define.amd) {
|
|
1907
|
+
define(function () { return api; });
|
|
1908
|
+
} else {
|
|
1909
|
+
global.AiDebug = api;
|
|
1910
|
+
}
|
|
1911
|
+
})(typeof window !== "undefined" ? window : this);
|