@webex/internal-plugin-metrics 3.12.0-llmrefactor.2 → 3.12.0-llmrefactor.3
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/README.md +36 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/metrics.js +1 -1
- package/dist/new-metrics.js +3 -0
- package/dist/new-metrics.js.map +1 -1
- package/dist/types/config.d.ts +3 -0
- package/dist/types/unhandled-exception-telemetry/index.d.ts +60 -0
- package/dist/types/unhandled-exception-telemetry/utils.d.ts +6 -0
- package/dist/unhandled-exception-telemetry/index.js +330 -0
- package/dist/unhandled-exception-telemetry/index.js.map +1 -0
- package/dist/unhandled-exception-telemetry/utils.js +105 -0
- package/dist/unhandled-exception-telemetry/utils.js.map +1 -0
- package/package.json +3 -3
- package/src/config.js +3 -0
- package/src/new-metrics.ts +3 -0
- package/src/unhandled-exception-telemetry/index.ts +403 -0
- package/src/unhandled-exception-telemetry/utils.ts +101 -0
- package/test/unit/spec/new-metrics.ts +26 -6
- package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
- package/test/unit/spec/unhandled-exception-telemetry.ts +688 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import {assert} from '@webex/test-helper-chai';
|
|
2
|
+
import sinon, {SinonFakeTimers} from 'sinon';
|
|
3
|
+
import uuid from 'uuid';
|
|
4
|
+
|
|
5
|
+
import config from '@webex/internal-plugin-metrics/src/config';
|
|
6
|
+
import * as Telemetry from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry';
|
|
7
|
+
|
|
8
|
+
const TEST_USERINFO = ['fixture-user', 'fixture-value'].join(':');
|
|
9
|
+
|
|
10
|
+
class FakeWindow {
|
|
11
|
+
readonly registrations: Array<{capture: boolean; type: string}> = [];
|
|
12
|
+
readonly removals: Array<{capture: boolean; type: string}> = [];
|
|
13
|
+
private readonly listeners = new Map<string, Array<(event: any) => void>>();
|
|
14
|
+
|
|
15
|
+
addEventListener(
|
|
16
|
+
type: string,
|
|
17
|
+
listener: (event: any) => void,
|
|
18
|
+
options?: boolean | {capture?: boolean}
|
|
19
|
+
) {
|
|
20
|
+
this.registrations.push({
|
|
21
|
+
capture: typeof options === 'boolean' ? options : options?.capture ?? false,
|
|
22
|
+
type,
|
|
23
|
+
});
|
|
24
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
removeEventListener(
|
|
28
|
+
type: string,
|
|
29
|
+
listener: (event: any) => void,
|
|
30
|
+
options?: boolean | {capture?: boolean}
|
|
31
|
+
) {
|
|
32
|
+
this.removals.push({
|
|
33
|
+
capture: typeof options === 'boolean' ? options : options?.capture ?? false,
|
|
34
|
+
type,
|
|
35
|
+
});
|
|
36
|
+
this.listeners.set(
|
|
37
|
+
type,
|
|
38
|
+
(this.listeners.get(type) ?? []).filter((registeredListener) => registeredListener !== listener)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
dispatch(type: string, event: any) {
|
|
43
|
+
this.listeners.get(type)?.forEach((listener) => listener(event));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
listenerCount(type: string) {
|
|
47
|
+
return this.listeners.get(type)?.length ?? 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('SDK unhandled exception telemetry', () => {
|
|
52
|
+
let browser: FakeWindow;
|
|
53
|
+
let clock: SinonFakeTimers;
|
|
54
|
+
let originalWindow: PropertyDescriptor | undefined;
|
|
55
|
+
|
|
56
|
+
const makeWebex = (overrides: Record<string, any> = {}) => ({
|
|
57
|
+
canAuthorize: true,
|
|
58
|
+
version: '4.0.0',
|
|
59
|
+
config: {
|
|
60
|
+
appName: 'test-app',
|
|
61
|
+
appVersion: '1.2.3',
|
|
62
|
+
metrics: {
|
|
63
|
+
unhandledExceptionTelemetry: {
|
|
64
|
+
enabled: true,
|
|
65
|
+
getMetadata: () => ({dataCenter: 'eu-central', orgId: 'org-1', surface: 'meetings'}),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
internal: {
|
|
70
|
+
metrics: {
|
|
71
|
+
submitClientMetrics: sinon.stub().resolves(),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
logger: {
|
|
75
|
+
error: sinon.stub(),
|
|
76
|
+
},
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
clock = sinon.useFakeTimers({now: 1_000, toFake: ['Date', 'setTimeout', 'clearTimeout']});
|
|
82
|
+
sinon.stub(uuid, 'v4').returns('event-1');
|
|
83
|
+
browser = new FakeWindow();
|
|
84
|
+
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
85
|
+
Object.defineProperty(globalThis, 'window', {
|
|
86
|
+
configurable: true,
|
|
87
|
+
value: browser,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
clock.restore();
|
|
93
|
+
sinon.restore();
|
|
94
|
+
|
|
95
|
+
if (originalWindow) {
|
|
96
|
+
Object.defineProperty(globalThis, 'window', originalWindow);
|
|
97
|
+
} else {
|
|
98
|
+
Reflect.deleteProperty(globalThis, 'window');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('deduplicates seven matching errors and submits one sanitized event', async () => {
|
|
103
|
+
const webex = makeWebex();
|
|
104
|
+
const error = new TypeError(
|
|
105
|
+
'GET https://api.example.test/media?token=message-secret#fragment failed'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
error.stack =
|
|
109
|
+
'TypeError: GET https://api.example.test/media?token=stack-secret#fragment failed\n at start (https://example.test/app.js?token=filename-secret#fragment:10:20)';
|
|
110
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
111
|
+
const errorEvent = {
|
|
112
|
+
error,
|
|
113
|
+
filename: 'https://example.test/app.js?token=secret',
|
|
114
|
+
lineno: 10,
|
|
115
|
+
colno: 20,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (let occurrence = 0; occurrence < 7; occurrence += 1) {
|
|
119
|
+
browser.dispatch('error', errorEvent);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
clock.tick(999);
|
|
123
|
+
assert.notCalled(webex.internal.metrics.submitClientMetrics);
|
|
124
|
+
|
|
125
|
+
await clock.tickAsync(1);
|
|
126
|
+
|
|
127
|
+
const submittedEvent =
|
|
128
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload;
|
|
129
|
+
|
|
130
|
+
assert.calledOnceWithExactly(
|
|
131
|
+
webex.internal.metrics.submitClientMetrics,
|
|
132
|
+
Telemetry.UNHANDLED_EXCEPTION_METRIC_NAME,
|
|
133
|
+
{
|
|
134
|
+
tags: {
|
|
135
|
+
app_name: 'test-app',
|
|
136
|
+
exception_kind: 'error',
|
|
137
|
+
runtime: 'browser',
|
|
138
|
+
},
|
|
139
|
+
fields: {
|
|
140
|
+
captured_at: 1_000,
|
|
141
|
+
error_fingerprint: submittedEvent.error.fingerprint,
|
|
142
|
+
error_name: 'TypeError',
|
|
143
|
+
event_id: 'event-1',
|
|
144
|
+
occurrence_count: 7,
|
|
145
|
+
},
|
|
146
|
+
eventPayload: submittedEvent,
|
|
147
|
+
},
|
|
148
|
+
undefined
|
|
149
|
+
);
|
|
150
|
+
assert.equal(submittedEvent.error.filename, 'https://example.test/app.js');
|
|
151
|
+
assert.equal(submittedEvent.error.message, 'GET https://api.example.test/media failed');
|
|
152
|
+
assert.equal(
|
|
153
|
+
submittedEvent.error.stack,
|
|
154
|
+
'TypeError: GET https://api.example.test/media failed\n at start (https://example.test/app.js)'
|
|
155
|
+
);
|
|
156
|
+
assert.deepEqual(submittedEvent.metadata, {
|
|
157
|
+
dataCenter: 'eu-central',
|
|
158
|
+
orgId: 'org-1',
|
|
159
|
+
surface: 'meetings',
|
|
160
|
+
});
|
|
161
|
+
assert.include(submittedEvent.common, {
|
|
162
|
+
appName: 'test-app',
|
|
163
|
+
appVersion: '1.2.3',
|
|
164
|
+
runtime: 'browser',
|
|
165
|
+
sdkVersion: '4.0.0',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps stackless errors from different source coordinates separate', async () => {
|
|
170
|
+
const webex = makeWebex();
|
|
171
|
+
|
|
172
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
173
|
+
browser.dispatch('error', {
|
|
174
|
+
message: 'script failed',
|
|
175
|
+
filename: 'https://example.test/app.js',
|
|
176
|
+
lineno: 10,
|
|
177
|
+
colno: 20,
|
|
178
|
+
});
|
|
179
|
+
browser.dispatch('error', {
|
|
180
|
+
message: 'script failed',
|
|
181
|
+
filename: 'https://example.test/app.js',
|
|
182
|
+
lineno: 11,
|
|
183
|
+
colno: 20,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await clock.tickAsync(1_000);
|
|
187
|
+
|
|
188
|
+
assert.calledTwice(webex.internal.metrics.submitClientMetrics);
|
|
189
|
+
assert.deepEqual(
|
|
190
|
+
webex.internal.metrics.submitClientMetrics.args.map(
|
|
191
|
+
([, properties]: [string, any]) => properties.eventPayload.error.line
|
|
192
|
+
),
|
|
193
|
+
[10, 11]
|
|
194
|
+
);
|
|
195
|
+
webex.internal.metrics.submitClientMetrics.args.forEach(([, properties]: [string, any]) => {
|
|
196
|
+
assert.equal(properties.eventPayload.occurrenceCount, 1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('removes listeners and flushes pending events from a superseded SDK instance', async () => {
|
|
201
|
+
const staleWebex = makeWebex();
|
|
202
|
+
const currentWebex = makeWebex({
|
|
203
|
+
config: {
|
|
204
|
+
appName: 'current-app',
|
|
205
|
+
metrics: {unhandledExceptionTelemetry: {enabled: true}},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
Telemetry.startUnhandledExceptionTelemetry(staleWebex);
|
|
210
|
+
browser.dispatch('error', {message: 'stale failure'});
|
|
211
|
+
Telemetry.startUnhandledExceptionTelemetry(currentWebex);
|
|
212
|
+
|
|
213
|
+
assert.equal(browser.listenerCount('error'), 1);
|
|
214
|
+
assert.equal(browser.listenerCount('unhandledrejection'), 1);
|
|
215
|
+
|
|
216
|
+
browser.dispatch('error', {message: 'current failure'});
|
|
217
|
+
await clock.tickAsync(1_000);
|
|
218
|
+
|
|
219
|
+
assert.calledOnce(staleWebex.internal.metrics.submitClientMetrics);
|
|
220
|
+
assert.equal(
|
|
221
|
+
staleWebex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message,
|
|
222
|
+
'stale failure'
|
|
223
|
+
);
|
|
224
|
+
assert.calledOnce(currentWebex.internal.metrics.submitClientMetrics);
|
|
225
|
+
assert.equal(
|
|
226
|
+
currentWebex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message,
|
|
227
|
+
'current failure'
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('removes query parameters and fragments from relative URLs', async () => {
|
|
232
|
+
const webex = makeWebex();
|
|
233
|
+
const error = new Error(
|
|
234
|
+
'GET /api/messages?token=root-secret#fragment or api/messages?token=bare-secret#fragment or GET api?token=single-secret#fragment failed'
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
error.stack =
|
|
238
|
+
'Error: GET /api/messages?token=stack-secret#fragment failed\n at load (../scripts/app.js?token=dot-secret#fragment:10:20)\n at retry (scripts/retry.js?token=bare-secret#fragment:30:40)\n at main (app.js?token=single-secret#fragment:50:60)';
|
|
239
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
240
|
+
browser.dispatch('error', {error});
|
|
241
|
+
|
|
242
|
+
await clock.tickAsync(1_000);
|
|
243
|
+
|
|
244
|
+
const submittedError =
|
|
245
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error;
|
|
246
|
+
|
|
247
|
+
assert.equal(submittedError.message, 'GET /api/messages or api/messages or GET api failed');
|
|
248
|
+
assert.equal(
|
|
249
|
+
submittedError.stack,
|
|
250
|
+
'Error: GET /api/messages failed\n at load (../scripts/app.js)\n at retry (scripts/retry.js)\n at main (app.js)'
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('gives each distinct error a full deduplication window', async () => {
|
|
255
|
+
const webex = makeWebex();
|
|
256
|
+
|
|
257
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
258
|
+
browser.dispatch('error', {message: 'first failure'});
|
|
259
|
+
clock.tick(500);
|
|
260
|
+
browser.dispatch('error', {message: 'second failure'});
|
|
261
|
+
|
|
262
|
+
await clock.tickAsync(500);
|
|
263
|
+
|
|
264
|
+
assert.calledOnce(webex.internal.metrics.submitClientMetrics);
|
|
265
|
+
assert.equal(
|
|
266
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message,
|
|
267
|
+
'first failure'
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
clock.tick(499);
|
|
271
|
+
assert.calledOnce(webex.internal.metrics.submitClientMetrics);
|
|
272
|
+
|
|
273
|
+
await clock.tickAsync(1);
|
|
274
|
+
|
|
275
|
+
assert.calledTwice(webex.internal.metrics.submitClientMetrics);
|
|
276
|
+
assert.equal(
|
|
277
|
+
webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.error.message,
|
|
278
|
+
'second failure'
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('starts a new window for a matching error when the previous timer is delayed', async () => {
|
|
283
|
+
const webex = makeWebex();
|
|
284
|
+
const errorEvent = {message: 'repeated failure'};
|
|
285
|
+
|
|
286
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
287
|
+
browser.dispatch('error', errorEvent);
|
|
288
|
+
browser.dispatch('error', errorEvent);
|
|
289
|
+
|
|
290
|
+
// Simulate browser timer throttling by advancing wall-clock time without running timers.
|
|
291
|
+
clock.setSystemTime(2_500);
|
|
292
|
+
browser.dispatch('error', errorEvent);
|
|
293
|
+
|
|
294
|
+
assert.calledOnce(webex.internal.metrics.submitClientMetrics);
|
|
295
|
+
assert.equal(
|
|
296
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.occurrenceCount,
|
|
297
|
+
2
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
await clock.tickAsync(1_000);
|
|
301
|
+
|
|
302
|
+
assert.calledTwice(webex.internal.metrics.submitClientMetrics);
|
|
303
|
+
assert.equal(
|
|
304
|
+
webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.occurrenceCount,
|
|
305
|
+
1
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('truncates error messages to 4096 characters', async () => {
|
|
310
|
+
const webex = makeWebex();
|
|
311
|
+
|
|
312
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
313
|
+
browser.dispatch('error', {message: 'x'.repeat(4_097)});
|
|
314
|
+
|
|
315
|
+
await clock.tickAsync(1_000);
|
|
316
|
+
|
|
317
|
+
assert.lengthOf(
|
|
318
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.message,
|
|
319
|
+
4_096
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('captures promise rejections and resource failures', async () => {
|
|
324
|
+
const webex = makeWebex();
|
|
325
|
+
|
|
326
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
327
|
+
browser.dispatch('unhandledrejection', {reason: 'promise failed'});
|
|
328
|
+
browser.dispatch('error', {
|
|
329
|
+
target: {
|
|
330
|
+
tagName: 'SCRIPT',
|
|
331
|
+
src: 'https://cdn.example.test/webex.js?token=secret',
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await clock.tickAsync(1_000);
|
|
336
|
+
|
|
337
|
+
const errors = webex.internal.metrics.submitClientMetrics.args.map(
|
|
338
|
+
([, properties]: [string, any]) => properties.eventPayload.error
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
assert.include(errors[0], {
|
|
342
|
+
kind: 'unhandledrejection',
|
|
343
|
+
message: 'promise failed',
|
|
344
|
+
name: 'UnhandledRejection',
|
|
345
|
+
});
|
|
346
|
+
assert.include(errors[1], {
|
|
347
|
+
kind: 'resource_error',
|
|
348
|
+
resourceType: 'SCRIPT',
|
|
349
|
+
resourceUrl: 'https://cdn.example.test/webex.js',
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('contains rejection reasons whose property getters throw', async () => {
|
|
354
|
+
const webex = makeWebex();
|
|
355
|
+
const reason = new Proxy(
|
|
356
|
+
{},
|
|
357
|
+
{
|
|
358
|
+
get() {
|
|
359
|
+
throw new Error('unreadable rejection');
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
365
|
+
|
|
366
|
+
assert.doesNotThrow(() => browser.dispatch('unhandledrejection', {reason}));
|
|
367
|
+
|
|
368
|
+
await clock.tickAsync(1_000);
|
|
369
|
+
|
|
370
|
+
assert.notCalled(webex.internal.metrics.submitClientMetrics);
|
|
371
|
+
assert.calledOnceWithExactly(
|
|
372
|
+
webex.logger.error,
|
|
373
|
+
'Unhandled Exception Telemetry -->',
|
|
374
|
+
'Failed to extract an unhandled rejection.'
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('registers and removes error and rejection listeners in the capture phase', () => {
|
|
379
|
+
Telemetry.startUnhandledExceptionTelemetry(makeWebex());
|
|
380
|
+
|
|
381
|
+
assert.deepEqual(browser.registrations, [
|
|
382
|
+
{capture: true, type: 'error'},
|
|
383
|
+
{capture: true, type: 'unhandledrejection'},
|
|
384
|
+
]);
|
|
385
|
+
|
|
386
|
+
Telemetry.startUnhandledExceptionTelemetry(
|
|
387
|
+
makeWebex({config: {metrics: {unhandledExceptionTelemetry: {enabled: false}}}})
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
assert.deepEqual(browser.removals, [
|
|
391
|
+
{capture: true, type: 'error'},
|
|
392
|
+
{capture: true, type: 'unhandledrejection'},
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('sanitizes and truncates rejection names before submission', async () => {
|
|
397
|
+
const webex = makeWebex();
|
|
398
|
+
|
|
399
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
400
|
+
browser.dispatch('unhandledrejection', {
|
|
401
|
+
reason: {
|
|
402
|
+
name: `GET https://${TEST_USERINFO}@example.test/path?token=query-secret ${'x'.repeat(
|
|
403
|
+
300
|
|
404
|
+
)}`,
|
|
405
|
+
message: 'promise failed',
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await clock.tickAsync(1_000);
|
|
410
|
+
|
|
411
|
+
const properties = webex.internal.metrics.submitClientMetrics.firstCall.args[1];
|
|
412
|
+
const errorName = properties.eventPayload.error.name;
|
|
413
|
+
|
|
414
|
+
assert.lengthOf(errorName, 256);
|
|
415
|
+
assert.match(errorName, /^GET https:\/\/example\.test\/path x+$/);
|
|
416
|
+
assert.equal(properties.fields.error_name, errorName);
|
|
417
|
+
assert.notInclude(JSON.stringify(properties), 'secret');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('redacts non-network URLs from error messages and stacks', async () => {
|
|
421
|
+
const webex = makeWebex();
|
|
422
|
+
const error = new Error(
|
|
423
|
+
`url=https://${TEST_USERINFO}@example.test/path?token=message-secret payload=data:text/plain,inline-secret`
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
error.stack =
|
|
427
|
+
'Error: resource failed\n at load (payload=blob:https://example.test/stack-secret)';
|
|
428
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
429
|
+
browser.dispatch('error', {error});
|
|
430
|
+
|
|
431
|
+
await clock.tickAsync(1_000);
|
|
432
|
+
|
|
433
|
+
const submittedError =
|
|
434
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error;
|
|
435
|
+
|
|
436
|
+
assert.equal(
|
|
437
|
+
submittedError.message,
|
|
438
|
+
'url=https://example.test/path payload=[redacted-url]'
|
|
439
|
+
);
|
|
440
|
+
assert.equal(
|
|
441
|
+
submittedError.stack,
|
|
442
|
+
'Error: resource failed\n at load (payload=[redacted-url])'
|
|
443
|
+
);
|
|
444
|
+
assert.notInclude(JSON.stringify(submittedError), 'secret');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('captures the selected srcset candidate instead of the fallback image source', async () => {
|
|
448
|
+
const webex = makeWebex();
|
|
449
|
+
|
|
450
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
451
|
+
browser.dispatch('error', {
|
|
452
|
+
target: {
|
|
453
|
+
tagName: 'IMG',
|
|
454
|
+
currentSrc: 'https://cdn.example.test/responsive-image.png?token=secret',
|
|
455
|
+
src: 'https://cdn.example.test/fallback-image.png',
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await clock.tickAsync(1_000);
|
|
460
|
+
|
|
461
|
+
const error = webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error;
|
|
462
|
+
|
|
463
|
+
assert.include(error, {
|
|
464
|
+
kind: 'resource_error',
|
|
465
|
+
resourceType: 'IMG',
|
|
466
|
+
resourceUrl: 'https://cdn.example.test/responsive-image.png',
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('ignores resource failures from non-network URL schemes', async () => {
|
|
471
|
+
const webex = makeWebex();
|
|
472
|
+
|
|
473
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
474
|
+
browser.dispatch('error', {
|
|
475
|
+
target: {
|
|
476
|
+
tagName: 'IMG',
|
|
477
|
+
src: 'data:image/svg+xml,<svg>inline-content</svg>',
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await clock.tickAsync(1_000);
|
|
482
|
+
|
|
483
|
+
assert.notCalled(webex.internal.metrics.submitClientMetrics);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('caps resource URLs before submission', async () => {
|
|
487
|
+
const webex = makeWebex();
|
|
488
|
+
|
|
489
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
490
|
+
browser.dispatch('error', {
|
|
491
|
+
target: {
|
|
492
|
+
tagName: 'SCRIPT',
|
|
493
|
+
src: `https://cdn.example.test/${'x'.repeat(3_000)}?token=secret`,
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await clock.tickAsync(1_000);
|
|
498
|
+
|
|
499
|
+
const resourceUrl =
|
|
500
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error.resourceUrl;
|
|
501
|
+
|
|
502
|
+
assert.lengthOf(resourceUrl, 2_048);
|
|
503
|
+
assert.notInclude(resourceUrl, 'secret');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('removes URL userinfo from error and resource telemetry', async () => {
|
|
507
|
+
const webex = makeWebex();
|
|
508
|
+
const error = new Error(
|
|
509
|
+
`GET https://${TEST_USERINFO}@api.example.test/messages?token=query-secret failed`
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
error.stack =
|
|
513
|
+
`Error: request failed\n at load (https://${TEST_USERINFO}@example.test/app.js?token=query-secret:10:20)`;
|
|
514
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
515
|
+
browser.dispatch('error', {
|
|
516
|
+
error,
|
|
517
|
+
filename: `https://${TEST_USERINFO}@example.test/app.js?token=query-secret#fragment`,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
await clock.tickAsync(1_000);
|
|
521
|
+
|
|
522
|
+
const errorTelemetry =
|
|
523
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload.error;
|
|
524
|
+
|
|
525
|
+
assert.equal(
|
|
526
|
+
errorTelemetry.message,
|
|
527
|
+
'GET https://api.example.test/messages failed'
|
|
528
|
+
);
|
|
529
|
+
assert.equal(
|
|
530
|
+
errorTelemetry.stack,
|
|
531
|
+
'Error: request failed\n at load (https://example.test/app.js)'
|
|
532
|
+
);
|
|
533
|
+
assert.equal(errorTelemetry.filename, 'https://example.test/app.js');
|
|
534
|
+
|
|
535
|
+
browser.dispatch('error', {
|
|
536
|
+
target: {
|
|
537
|
+
tagName: 'SCRIPT',
|
|
538
|
+
src: `https://${TEST_USERINFO}@cdn.example.test/app.js?token=query-secret`,
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
await clock.tickAsync(1_000);
|
|
543
|
+
|
|
544
|
+
const resourceTelemetry =
|
|
545
|
+
webex.internal.metrics.submitClientMetrics.secondCall.args[1].eventPayload.error;
|
|
546
|
+
|
|
547
|
+
assert.equal(resourceTelemetry.resourceUrl, 'https://cdn.example.test/app.js');
|
|
548
|
+
assert.notInclude(JSON.stringify({errorTelemetry, resourceTelemetry}), 'secret');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('does not install browser listeners when disabled', () => {
|
|
552
|
+
const webex = makeWebex({
|
|
553
|
+
config: {
|
|
554
|
+
metrics: {
|
|
555
|
+
unhandledExceptionTelemetry: {enabled: false},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
561
|
+
assert.isEmpty(browser.registrations);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('stops an active reporter when a new SDK instance disables telemetry', async () => {
|
|
565
|
+
const enabledWebex = makeWebex();
|
|
566
|
+
const disabledWebex = makeWebex({
|
|
567
|
+
config: {
|
|
568
|
+
metrics: {
|
|
569
|
+
unhandledExceptionTelemetry: {enabled: false},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
Telemetry.startUnhandledExceptionTelemetry(enabledWebex);
|
|
575
|
+
browser.dispatch('error', {message: 'pending failure'});
|
|
576
|
+
|
|
577
|
+
Telemetry.startUnhandledExceptionTelemetry(disabledWebex);
|
|
578
|
+
assert.equal(browser.listenerCount('error'), 0);
|
|
579
|
+
assert.equal(browser.listenerCount('unhandledrejection'), 0);
|
|
580
|
+
|
|
581
|
+
browser.dispatch('error', {message: 'ignored failure'});
|
|
582
|
+
await clock.tickAsync(1_000);
|
|
583
|
+
|
|
584
|
+
assert.notCalled(enabledWebex.internal.metrics.submitClientMetrics);
|
|
585
|
+
assert.notCalled(disabledWebex.internal.metrics.submitClientMetrics);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('does not install browser listeners when not configured', () => {
|
|
589
|
+
const webex = makeWebex({config: {metrics: {}}});
|
|
590
|
+
|
|
591
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
592
|
+
assert.isEmpty(browser.registrations);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('stops an active reporter and discards pending events when the environment is unsupported', async () => {
|
|
596
|
+
const enabledWebex = makeWebex();
|
|
597
|
+
|
|
598
|
+
Telemetry.startUnhandledExceptionTelemetry(enabledWebex);
|
|
599
|
+
browser.dispatch('error', {message: 'pending failure'});
|
|
600
|
+
Object.defineProperty(globalThis, 'window', {
|
|
601
|
+
configurable: true,
|
|
602
|
+
value: {},
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
Telemetry.startUnhandledExceptionTelemetry(makeWebex());
|
|
606
|
+
assert.equal(browser.listenerCount('error'), 0);
|
|
607
|
+
assert.equal(browser.listenerCount('unhandledrejection'), 0);
|
|
608
|
+
|
|
609
|
+
await clock.tickAsync(1_000);
|
|
610
|
+
|
|
611
|
+
assert.notCalled(enabledWebex.internal.metrics.submitClientMetrics);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('contains metadata and submission failures', async () => {
|
|
615
|
+
const webex = makeWebex({
|
|
616
|
+
config: {
|
|
617
|
+
metrics: {
|
|
618
|
+
unhandledExceptionTelemetry: {
|
|
619
|
+
enabled: true,
|
|
620
|
+
getMetadata: () => {
|
|
621
|
+
throw new Error('metadata unavailable');
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
internal: {
|
|
627
|
+
metrics: {
|
|
628
|
+
submitClientMetrics: sinon.stub().rejects(new Error('metrics unavailable')),
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
634
|
+
browser.dispatch('error', {message: 'application failed'});
|
|
635
|
+
|
|
636
|
+
await clock.tickAsync(1_000);
|
|
637
|
+
|
|
638
|
+
assert.equal(
|
|
639
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload
|
|
640
|
+
.metadataCaptureStatus,
|
|
641
|
+
'provider_error'
|
|
642
|
+
);
|
|
643
|
+
assert.calledOnceWithExactly(
|
|
644
|
+
webex.logger.error,
|
|
645
|
+
'Unhandled Exception Telemetry -->',
|
|
646
|
+
'Failed to submit exception telemetry.'
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('routes telemetry through pre-login metrics before authentication', async () => {
|
|
651
|
+
const webex = makeWebex({canAuthorize: false});
|
|
652
|
+
|
|
653
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
654
|
+
browser.dispatch('error', {message: 'pre-login failure'});
|
|
655
|
+
|
|
656
|
+
await clock.tickAsync(1_000);
|
|
657
|
+
|
|
658
|
+
assert.equal(webex.internal.metrics.submitClientMetrics.firstCall.args[2], 'event-1');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('omits metadata that does not serialize to an object', async () => {
|
|
662
|
+
const webex = makeWebex({
|
|
663
|
+
config: {
|
|
664
|
+
metrics: {
|
|
665
|
+
unhandledExceptionTelemetry: {
|
|
666
|
+
enabled: true,
|
|
667
|
+
getMetadata: () => ['not', 'an', 'object'],
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
Telemetry.startUnhandledExceptionTelemetry(webex);
|
|
674
|
+
browser.dispatch('error', {message: 'application failed'});
|
|
675
|
+
|
|
676
|
+
await clock.tickAsync(1_000);
|
|
677
|
+
|
|
678
|
+
const submittedEvent =
|
|
679
|
+
webex.internal.metrics.submitClientMetrics.firstCall.args[1].eventPayload;
|
|
680
|
+
|
|
681
|
+
assert.isUndefined(submittedEvent.metadata);
|
|
682
|
+
assert.equal(submittedEvent.metadataCaptureStatus, 'invalid_type');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('is disabled by default', () => {
|
|
686
|
+
assert.isFalse(config.metrics.unhandledExceptionTelemetry.enabled);
|
|
687
|
+
});
|
|
688
|
+
});
|