@guomain/monitor-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web.umd.js ADDED
@@ -0,0 +1,717 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GMMonitor = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ /**
8
+ * 默认采集与发送阈值。
9
+ */
10
+ const defaultLimits = {
11
+ maxQueueSize: 50
12
+ };
13
+ /**
14
+ * 创建通用监控实例。
15
+ * @param config SDK 初始化配置,包含应用标识、上报地址、自定义发送函数、上下文和阈值。
16
+ */
17
+ function createMonitor(config) {
18
+ if (!config.appId) {
19
+ throw new Error('appId is required');
20
+ }
21
+ if (!(config.dsn || config.transport)) {
22
+ throw new Error('dsn or transport is required');
23
+ }
24
+ const mergedLimits = { ...defaultLimits, ...config.limits };
25
+ const plugins = [];
26
+ const pluginTeardowns = [];
27
+ const queue = [];
28
+ const queuedKeys = new Set();
29
+ let draining = false;
30
+ let idleDrainHandle;
31
+ let started = false;
32
+ async function defaultTransport(payloads) {
33
+ if (!config.dsn) {
34
+ return;
35
+ }
36
+ if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
37
+ const sent = navigator.sendBeacon(config.dsn, JSON.stringify(payloads));
38
+ if (sent) {
39
+ return;
40
+ }
41
+ }
42
+ // sendBeacon 不可用或返回 false 时回退到 fetch
43
+ await fetch(config.dsn, {
44
+ method: 'POST',
45
+ body: JSON.stringify(payloads),
46
+ headers: { 'Content-Type': 'application/json' }
47
+ });
48
+ }
49
+ const setupPlugin = (plugin) => {
50
+ try {
51
+ const teardown = plugin.setup(monitor);
52
+ if (typeof teardown === 'function') {
53
+ pluginTeardowns.push(teardown);
54
+ }
55
+ }
56
+ catch (error) {
57
+ reportSdkError(monitor, error, {
58
+ stage: 'plugin',
59
+ pluginName: plugin.name
60
+ });
61
+ }
62
+ };
63
+ const monitor = {
64
+ config,
65
+ limits: mergedLimits,
66
+ start() {
67
+ if (started) {
68
+ return;
69
+ }
70
+ started = true;
71
+ plugins.forEach(plugin => {
72
+ setupPlugin(plugin);
73
+ });
74
+ },
75
+ stop() {
76
+ if (!started) {
77
+ return;
78
+ }
79
+ started = false;
80
+ const tasks = pluginTeardowns.splice(0).reverse();
81
+ tasks.forEach(task => {
82
+ task();
83
+ });
84
+ },
85
+ use(plugin) {
86
+ plugins.push(plugin);
87
+ if (started) {
88
+ setupPlugin(plugin);
89
+ }
90
+ return monitor;
91
+ },
92
+ capture(event) {
93
+ if (!started) {
94
+ return;
95
+ }
96
+ const key = stringifyQueueEvent(event);
97
+ if (queuedKeys.has(key)) {
98
+ return;
99
+ }
100
+ if (queue.length >= mergedLimits.maxQueueSize) {
101
+ const removed = queue.shift();
102
+ if (removed) {
103
+ queuedKeys.delete(removed.key);
104
+ }
105
+ }
106
+ queuedKeys.add(key);
107
+ queue.push({ event, key });
108
+ scheduleIdleDrain();
109
+ },
110
+ async flush() {
111
+ await drainQueue();
112
+ }
113
+ };
114
+ async function drainQueue() {
115
+ if (draining) {
116
+ return;
117
+ }
118
+ cancelIdleDrain();
119
+ draining = true;
120
+ try {
121
+ const batch = queue.splice(0);
122
+ if (!batch.length) {
123
+ return;
124
+ }
125
+ batch.forEach(item => {
126
+ queuedKeys.delete(item.key);
127
+ });
128
+ let context;
129
+ try {
130
+ if (config.context) {
131
+ context = typeof config.context === 'function' ? await config.context() : config.context;
132
+ }
133
+ }
134
+ catch (error) {
135
+ reportSdkError(monitor, error, { stage: 'context' });
136
+ }
137
+ const payloads = batch.map(({ event }) => ({
138
+ ...event,
139
+ timestamp: event.timestamp ?? Date.now(),
140
+ appId: config.appId,
141
+ release: config.release,
142
+ context
143
+ }));
144
+ try {
145
+ await (config.transport ?? defaultTransport)(payloads);
146
+ }
147
+ catch (error) {
148
+ // transport 失败直接 console.error,不入队重入(避免 sdk-error 走同一故障通道导致风暴)
149
+ if (typeof console !== 'undefined' && console.error) {
150
+ console.error('[gm-monitor] Transport failed:', error);
151
+ }
152
+ }
153
+ }
154
+ finally {
155
+ draining = false;
156
+ }
157
+ }
158
+ function scheduleIdleDrain() {
159
+ if (idleDrainHandle !== undefined) {
160
+ return;
161
+ }
162
+ if (typeof requestIdleCallback === 'function') {
163
+ idleDrainHandle = requestIdleCallback(() => {
164
+ idleDrainHandle = undefined;
165
+ void drainQueue();
166
+ });
167
+ return;
168
+ }
169
+ idleDrainHandle = setTimeout(() => {
170
+ idleDrainHandle = undefined;
171
+ void drainQueue();
172
+ }, 16);
173
+ }
174
+ function cancelIdleDrain() {
175
+ if (idleDrainHandle === undefined) {
176
+ return;
177
+ }
178
+ if (typeof cancelIdleCallback === 'function') {
179
+ cancelIdleCallback(idleDrainHandle);
180
+ }
181
+ else {
182
+ clearTimeout(idleDrainHandle);
183
+ }
184
+ idleDrainHandle = undefined;
185
+ }
186
+ return monitor;
187
+ }
188
+ /**
189
+ * 把任意错误值归一化为 SDK 事件。
190
+ * @param type 事件类型,用于标识错误来源。
191
+ * @param error 原始错误,可以是 Error、字符串或其他未知值。
192
+ * @param meta 附加 tags 和 extra。
193
+ */
194
+ function toErrorEvent(type, error, meta) {
195
+ if (error instanceof Error) {
196
+ return {
197
+ type,
198
+ message: error.message,
199
+ stack: error.stack,
200
+ timestamp: Date.now(),
201
+ ...meta
202
+ };
203
+ }
204
+ let message;
205
+ if (typeof error === 'string') {
206
+ message = error;
207
+ }
208
+ else if (error === undefined) {
209
+ message = 'undefined';
210
+ }
211
+ else {
212
+ try {
213
+ message = JSON.stringify(error);
214
+ }
215
+ catch {
216
+ message = String(error);
217
+ }
218
+ }
219
+ return {
220
+ type,
221
+ message,
222
+ timestamp: Date.now(),
223
+ ...meta
224
+ };
225
+ }
226
+ function stringifyQueueEvent(event) {
227
+ return JSON.stringify({
228
+ type: event.type,
229
+ message: event.message,
230
+ url: event.url,
231
+ stack: event.stack,
232
+ filename: event.filename,
233
+ lineno: event.lineno,
234
+ colno: event.colno,
235
+ tags: event.tags,
236
+ extra: event.extra
237
+ });
238
+ }
239
+ /**
240
+ * 上报 SDK 自身异常。同名 sdk-error 由 dedup 天然合并,不会风暴。
241
+ */
242
+ function reportSdkError(monitor, error, extra) {
243
+ try {
244
+ monitor.capture(toErrorEvent('sdk-error', error, { extra }));
245
+ }
246
+ catch {
247
+ // SDK 上报通道自身崩溃时静默吞掉,避免递归。
248
+ if (typeof console !== 'undefined' && console.error) {
249
+ console.error('[gm-monitor] SDK internal error:', error);
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * 注册浏览器事件并返回清理函数。
256
+ * @param target 事件目标,目前只处理 window。
257
+ * @param type 事件类型。
258
+ * @param listener 事件监听函数。
259
+ * @param options addEventListener 配置。
260
+ */
261
+ function on(target, type, listener, options) {
262
+ target.addEventListener(type, listener, options);
263
+ return () => target.removeEventListener(type, listener, options);
264
+ }
265
+ /**
266
+ * 从资源节点读取加载地址。
267
+ * @param target 触发 error 事件的资源节点。
268
+ */
269
+ function getResourceUrl(target) {
270
+ if (!(target instanceof Element)) {
271
+ return undefined;
272
+ }
273
+ return (target.getAttribute('src') ??
274
+ target.getAttribute('href') ??
275
+ target.getAttribute('data') ??
276
+ undefined);
277
+ }
278
+
279
+ /**
280
+ * 监听未处理的 Promise rejection。
281
+ * @param monitor 当前监控实例。
282
+ */
283
+ function setupPromiseError(monitor) {
284
+ return on(window, 'unhandledrejection', event => {
285
+ monitor.capture(toErrorEvent('promise-error', event.reason));
286
+ });
287
+ }
288
+
289
+ const requestMonitors = new Set();
290
+ const xhrInfo = new WeakMap();
291
+ let originalFetch;
292
+ let originalOpen;
293
+ let originalSend;
294
+ // 全局 transport 执行计数:在任一 monitor 的 transport 调用期间增加,
295
+ // 用于跳过 transport 自身请求的采集,避免 SDK 自激上报。
296
+ let activeTransportDepth = 0;
297
+ /**
298
+ * 标记一次 transport 调用开始,由 monitor-web 在执行用户 transport 前调用。
299
+ */
300
+ function beginTransportScope() {
301
+ activeTransportDepth += 1;
302
+ }
303
+ /**
304
+ * 标记一次 transport 调用结束。
305
+ */
306
+ function endTransportScope() {
307
+ if (activeTransportDepth > 0) {
308
+ activeTransportDepth -= 1;
309
+ }
310
+ }
311
+ /**
312
+ * 安装请求错误监控。
313
+ * @param monitor 当前监控实例。
314
+ */
315
+ function setupRequestError(monitor) {
316
+ requestMonitors.add(monitor);
317
+ patchFetch();
318
+ patchXhr();
319
+ return () => {
320
+ requestMonitors.delete(monitor);
321
+ restoreRequestPatch();
322
+ };
323
+ }
324
+ /**
325
+ * 对 fetch 做 AOP 包装。
326
+ */
327
+ function patchFetch() {
328
+ if (originalFetch || typeof fetch !== 'function') {
329
+ return;
330
+ }
331
+ originalFetch = fetch;
332
+ const localFetch = fetch;
333
+ globalThis.fetch = (async (input, init) => {
334
+ const info = getFetchInfo(input, init);
335
+ try {
336
+ const response = await localFetch(input, init);
337
+ if (response && !response.ok) {
338
+ captureForActiveMonitors({
339
+ ...info,
340
+ status: response.status,
341
+ statusText: response.statusText
342
+ });
343
+ }
344
+ return response;
345
+ }
346
+ catch (error) {
347
+ captureForActiveMonitors({ ...info, error });
348
+ throw error;
349
+ }
350
+ });
351
+ }
352
+ /**
353
+ * 对 XMLHttpRequest 做 AOP 包装。
354
+ */
355
+ function patchXhr() {
356
+ if (originalOpen || typeof XMLHttpRequest === 'undefined') {
357
+ return;
358
+ }
359
+ const proto = XMLHttpRequest.prototype;
360
+ originalOpen = proto.open;
361
+ originalSend = proto.send;
362
+ proto.open = function open(method, url, ...rest) {
363
+ xhrInfo.set(this, { method: method.toUpperCase(), url: String(url) });
364
+ return originalOpen.apply(this, [method, url, ...rest]);
365
+ };
366
+ proto.send = function send(...args) {
367
+ let captured = false;
368
+ const handleDone = (event) => {
369
+ if (captured) {
370
+ return;
371
+ }
372
+ const info = xhrInfo.get(this);
373
+ if (!info) {
374
+ return;
375
+ }
376
+ // status === 0 仅在 'error' / 'timeout' 时上报,避免把主动 abort 当成请求错误。
377
+ if (this.status >= 400 || (this.status === 0 && event.type !== 'loadend')) {
378
+ captured = true;
379
+ captureForActiveMonitors({
380
+ ...info,
381
+ status: this.status,
382
+ statusText: this.statusText
383
+ });
384
+ }
385
+ };
386
+ this.addEventListener('loadend', handleDone, { once: true });
387
+ this.addEventListener('error', handleDone, { once: true });
388
+ this.addEventListener('timeout', handleDone, { once: true });
389
+ return originalSend?.apply(this, args);
390
+ };
391
+ }
392
+ /**
393
+ * 没有活跃监控实例时还原原生请求方法。
394
+ */
395
+ function restoreRequestPatch() {
396
+ if (requestMonitors.size > 0) {
397
+ return;
398
+ }
399
+ if (originalFetch) {
400
+ globalThis.fetch = originalFetch;
401
+ originalFetch = undefined;
402
+ }
403
+ if (typeof XMLHttpRequest !== 'undefined' && originalOpen && originalSend) {
404
+ XMLHttpRequest.prototype.open = originalOpen;
405
+ XMLHttpRequest.prototype.send = originalSend;
406
+ originalOpen = undefined;
407
+ originalSend = undefined;
408
+ }
409
+ }
410
+ /**
411
+ * 向所有活跃监控实例分发请求错误。
412
+ * @param info 请求错误信息。
413
+ */
414
+ function captureForActiveMonitors(info) {
415
+ // transport 执行中的请求不入队,避免 SDK 自身上报的请求被采集回送形成自激。
416
+ if (activeTransportDepth > 0) {
417
+ return;
418
+ }
419
+ requestMonitors.forEach(monitor => {
420
+ if (!isMonitorDsn(monitor, info.url)) {
421
+ captureRequestError(monitor, info);
422
+ }
423
+ });
424
+ }
425
+ /**
426
+ * 上报单个请求错误事件。
427
+ * @param monitor 当前监控实例。
428
+ * @param info 请求错误信息。
429
+ */
430
+ function captureRequestError(monitor, info) {
431
+ monitor.capture({
432
+ type: 'request-error',
433
+ message: `Request failed: ${info.method} ${info.url}`,
434
+ timestamp: Date.now(),
435
+ url: info.url,
436
+ extra: { ...info }
437
+ });
438
+ }
439
+ /**
440
+ * 从 fetch 参数中提取请求方法和地址。
441
+ * @param input fetch 第一个参数。
442
+ * @param init fetch 初始化配置。
443
+ */
444
+ function getFetchInfo(input, init) {
445
+ if (input instanceof Request) {
446
+ return {
447
+ method: (init?.method ?? input.method ?? 'GET').toUpperCase(),
448
+ url: input.url
449
+ };
450
+ }
451
+ return {
452
+ method: (init?.method ?? 'GET').toUpperCase(),
453
+ url: String(input)
454
+ };
455
+ }
456
+ /**
457
+ * 判断请求是否为 SDK 自己的上报地址,避免默认 transport 的 fetch 兜底被自采集。
458
+ * @param monitor 当前监控实例。
459
+ * @param url 请求地址。
460
+ */
461
+ function isMonitorDsn(monitor, url) {
462
+ const dsn = monitor.config.dsn;
463
+ if (!dsn) {
464
+ return false;
465
+ }
466
+ return normalizeRequestUrl(url) === normalizeRequestUrl(dsn);
467
+ }
468
+ function normalizeRequestUrl(url) {
469
+ if (typeof URL === 'undefined') {
470
+ return url;
471
+ }
472
+ try {
473
+ const base = typeof location === 'undefined' ? undefined : location.href;
474
+ return new URL(url, base).href;
475
+ }
476
+ catch {
477
+ return url;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * 监听资源加载错误。
483
+ * @param monitor 当前监控实例。
484
+ */
485
+ function setupResourceError(monitor) {
486
+ return on(window, 'error', event => {
487
+ if (event.target === window) {
488
+ return;
489
+ }
490
+ const url = getResourceUrl(event.target);
491
+ if (!url) {
492
+ return;
493
+ }
494
+ monitor.capture({
495
+ type: 'resource-error',
496
+ message: `Resource load failed: ${url}`,
497
+ url,
498
+ timestamp: Date.now()
499
+ });
500
+ }, true);
501
+ }
502
+
503
+ /**
504
+ * 监听 JS 运行时错误。
505
+ * @param monitor 当前监控实例。
506
+ */
507
+ function setupRuntimeError(monitor) {
508
+ return on(window, 'error', event => {
509
+ if (!('message' in event)) {
510
+ return;
511
+ }
512
+ monitor.capture({
513
+ type: 'runtime-error',
514
+ message: event.message,
515
+ stack: event.error instanceof Error ? event.error.stack : undefined,
516
+ filename: event.filename,
517
+ lineno: event.lineno,
518
+ colno: event.colno,
519
+ timestamp: Date.now()
520
+ });
521
+ }, true);
522
+ }
523
+
524
+ /**
525
+ * 安装 Web 错误监控能力。
526
+ * @param monitor 当前监控实例。
527
+ */
528
+ function setupWebError(monitor) {
529
+ const teardowns = [];
530
+ const setup = [
531
+ { name: 'runtime', fn: () => setupRuntimeError(monitor) },
532
+ { name: 'promise', fn: () => setupPromiseError(monitor) },
533
+ { name: 'resource', fn: () => setupResourceError(monitor) },
534
+ { name: 'request', fn: () => setupRequestError(monitor) }
535
+ ];
536
+ for (const { name, fn } of setup) {
537
+ try {
538
+ teardowns.push(fn());
539
+ }
540
+ catch {
541
+ // 单个错误监听器安装失败不影响其他监听器
542
+ if (typeof console !== 'undefined' && console.warn) {
543
+ console.warn(`[gm-monitor] Failed to setup ${name} error listener`);
544
+ }
545
+ }
546
+ }
547
+ return () => {
548
+ const cleanupTasks = teardowns.splice(0).reverse();
549
+ cleanupTasks.forEach(task => {
550
+ try {
551
+ task();
552
+ }
553
+ catch {
554
+ // 单个清理失败不影响后续
555
+ }
556
+ });
557
+ };
558
+ }
559
+
560
+ /**
561
+ * 创建 Web 端监控实例。
562
+ * @param config SDK 初始化配置,包含应用标识、Web 错误开关、Vue app、transport 等。
563
+ */
564
+ function createWebMonitor(config) {
565
+ const monitor = createMonitor({
566
+ ...config,
567
+ transport: wrapTransport(config.transport)
568
+ });
569
+ const startCore = monitor.start;
570
+ const stopCore = monitor.stop;
571
+ const teardowns = [];
572
+ const ReactBoundary = createReactBoundaryType(monitor);
573
+ let webStarted = false;
574
+ monitor.start = () => {
575
+ if (webStarted) {
576
+ return;
577
+ }
578
+ startCore();
579
+ try {
580
+ webStarted = true;
581
+ if (config.error !== false) {
582
+ teardowns.push(setupWebError(monitor));
583
+ }
584
+ const teardownVue = setupVue(config, monitor);
585
+ if (teardownVue) {
586
+ teardowns.push(teardownVue);
587
+ }
588
+ teardowns.push(setupFlushOnPageUnload(monitor));
589
+ }
590
+ catch {
591
+ webStarted = false;
592
+ runTeardowns(teardowns);
593
+ stopCore();
594
+ }
595
+ };
596
+ monitor.stop = () => {
597
+ if (!webStarted) {
598
+ stopCore();
599
+ return;
600
+ }
601
+ runTeardowns(teardowns);
602
+ webStarted = false;
603
+ stopCore();
604
+ };
605
+ monitor.renderReact = (root, element) => {
606
+ root.render(createReactBoundaryElement(ReactBoundary, element));
607
+ };
608
+ return monitor;
609
+ }
610
+ /**
611
+ * 创建 React ErrorBoundary 类型。
612
+ * @param monitor 当前监控实例,用于在 componentDidCatch 中上报 react-error。
613
+ */
614
+ function createReactBoundaryType(monitor) {
615
+ class MonitorReactBoundary {
616
+ static getDerivedStateFromError() {
617
+ return { hasError: true };
618
+ }
619
+ constructor(props) {
620
+ this.state = { hasError: false };
621
+ this.props = props;
622
+ }
623
+ componentDidCatch(error, info) {
624
+ monitor.capture(toErrorEvent('react-error', error, { extra: { info } }));
625
+ }
626
+ render() {
627
+ return this.state.hasError ? null : this.props.children;
628
+ }
629
+ }
630
+ // React 通过该标记识别 class component,这里避免为了一个边界组件强制引入 React 运行时。
631
+ Object.defineProperty(MonitorReactBoundary.prototype, 'isReactComponent', {
632
+ value: {}
633
+ });
634
+ return MonitorReactBoundary;
635
+ }
636
+ /**
637
+ * 创建 React element 形态的边界节点。
638
+ * @param type React ErrorBoundary 类型。
639
+ * @param children 需要被边界包裹的业务节点。
640
+ */
641
+ function createReactBoundaryElement(type, children) {
642
+ return {
643
+ $$typeof: Symbol.for('react.element'),
644
+ type,
645
+ key: null,
646
+ ref: null,
647
+ props: { children },
648
+ _owner: null
649
+ };
650
+ }
651
+ /**
652
+ * 接入 Vue 全局错误处理。
653
+ * @param config SDK 初始化配置,读取其中的 vue.app。
654
+ * @param monitor 当前监控实例。
655
+ */
656
+ function setupVue(config, monitor) {
657
+ const app = config.vue?.app;
658
+ if (!app) {
659
+ return undefined;
660
+ }
661
+ const previousHandler = app.config.errorHandler;
662
+ app.config.errorHandler = (err, instance, info) => {
663
+ monitor.capture(toErrorEvent('vue-error', err, { extra: { info } }));
664
+ previousHandler?.(err, instance, info);
665
+ };
666
+ return () => {
667
+ app.config.errorHandler = previousHandler;
668
+ };
669
+ }
670
+ function setupFlushOnPageUnload(monitor) {
671
+ if (typeof window === 'undefined') {
672
+ return () => undefined;
673
+ }
674
+ const handlePageUnload = () => {
675
+ void monitor.flush();
676
+ };
677
+ window.addEventListener('pagehide', handlePageUnload);
678
+ window.addEventListener('beforeunload', handlePageUnload);
679
+ return () => {
680
+ window.removeEventListener('pagehide', handlePageUnload);
681
+ window.removeEventListener('beforeunload', handlePageUnload);
682
+ };
683
+ }
684
+ function runTeardowns(teardowns) {
685
+ const cleanupTasks = teardowns.splice(0).reverse();
686
+ cleanupTasks.forEach(task => {
687
+ try {
688
+ task();
689
+ }
690
+ catch {
691
+ // 单个 teardown 失败不影响后续清理
692
+ }
693
+ });
694
+ }
695
+ /**
696
+ * 包装用户 transport:在执行期间标记请求 AOP 跳过自身请求,避免 SDK 自激上报。
697
+ * 未配置 transport 时返回 undefined,沿用 core 的默认 sendBeacon 路径
698
+ * (sendBeacon 不经过 fetch 补丁,默认 transport 的 fetch 兜底由 dsn 自排除处理)。
699
+ */
700
+ function wrapTransport(transport) {
701
+ if (!transport) {
702
+ return undefined;
703
+ }
704
+ return async (payloads) => {
705
+ beginTransportScope();
706
+ try {
707
+ await transport(payloads);
708
+ }
709
+ finally {
710
+ endTransportScope();
711
+ }
712
+ };
713
+ }
714
+
715
+ exports.createWebMonitor = createWebMonitor;
716
+
717
+ }));