@webex/internal-plugin-metrics 3.11.0 → 3.12.0-auth-prejoin-fetch.1
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/automated-user.js +19 -0
- package/dist/automated-user.js.map +1 -0
- package/dist/batcher.js +3 -0
- package/dist/batcher.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js +639 -65
- package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics.js +168 -76
- package/dist/call-diagnostic/call-diagnostic-metrics.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics.util.js +18 -4
- package/dist/call-diagnostic/call-diagnostic-metrics.util.js.map +1 -1
- package/dist/call-diagnostic/config.js +17 -3
- package/dist/call-diagnostic/config.js.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/generic-metrics.js +8 -6
- package/dist/generic-metrics.js.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/metrics.js +1 -1
- package/dist/metrics.types.js +4 -0
- package/dist/metrics.types.js.map +1 -1
- package/dist/new-metrics.js +108 -26
- package/dist/new-metrics.js.map +1 -1
- package/dist/prelogin-metrics.js +106 -0
- package/dist/prelogin-metrics.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
- package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/index.js +143 -0
- package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/types.js +7 -0
- package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
- package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
- package/dist/types/automated-user.d.ts +2 -0
- package/dist/types/call-diagnostic/call-diagnostic-metrics-latencies.d.ts +208 -5
- package/dist/types/call-diagnostic/call-diagnostic-metrics.d.ts +79 -15
- package/dist/types/call-diagnostic/call-diagnostic-metrics.util.d.ts +5 -0
- package/dist/types/call-diagnostic/config.d.ts +5 -0
- package/dist/types/config.d.ts +4 -0
- package/dist/types/index.d.ts +6 -3
- package/dist/types/metrics.types.d.ts +9 -4
- package/dist/types/new-metrics.d.ts +29 -1
- package/dist/types/prelogin-metrics.d.ts +47 -0
- package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
- package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
- package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
- package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -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 +12 -11
- package/src/automated-user.ts +16 -0
- package/src/batcher.js +4 -0
- package/src/call-diagnostic/call-diagnostic-metrics-latencies.ts +800 -75
- package/src/call-diagnostic/call-diagnostic-metrics.ts +102 -15
- package/src/call-diagnostic/call-diagnostic-metrics.util.ts +22 -2
- package/src/call-diagnostic/config.ts +15 -0
- package/src/config.js +4 -0
- package/src/generic-metrics.ts +6 -6
- package/src/index.ts +9 -0
- package/src/metrics.types.ts +27 -5
- package/src/new-metrics.ts +98 -6
- package/src/prelogin-metrics.ts +94 -0
- package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
- package/src/privacy-and-security-permission-enricher/index.ts +142 -0
- package/src/privacy-and-security-permission-enricher/types.ts +21 -0
- package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
- package/src/unhandled-exception-telemetry/index.ts +403 -0
- package/src/unhandled-exception-telemetry/utils.ts +101 -0
- package/test/unit/spec/automated-user.ts +43 -0
- package/test/unit/spec/batcher.js +43 -0
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts +47 -11
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-latencies.ts +1550 -416
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +887 -159
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts +61 -3
- package/test/unit/spec/new-metrics.ts +203 -50
- package/test/unit/spec/prelogin-metrics-batcher.ts +71 -36
- package/test/unit/spec/prelogin-metrics.ts +132 -0
- package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
- package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
- package/test/unit/spec/unhandled-exception-telemetry.ts +688 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* eslint-disable require-jsdoc, valid-jsdoc */
|
|
2
|
+
|
|
3
|
+
import {safeSetTimeout} from '@webex/common-timers';
|
|
4
|
+
import uuid from 'uuid';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createFingerprint,
|
|
8
|
+
removeUrlDetails,
|
|
9
|
+
removeUrlDetailsFromText,
|
|
10
|
+
sanitizeResourceUrl,
|
|
11
|
+
stringifyReason,
|
|
12
|
+
truncate,
|
|
13
|
+
} from './utils';
|
|
14
|
+
|
|
15
|
+
export const UNHANDLED_EXCEPTION_METRIC_NAME = 'JS_SDK_OBSERVED_CLIENT_UNHANDLED_EXCEPTION';
|
|
16
|
+
|
|
17
|
+
const DEDUPE_WINDOW_MS = 1_000;
|
|
18
|
+
const MAX_ERROR_MESSAGE_LENGTH = 4_096;
|
|
19
|
+
const MAX_ERROR_NAME_LENGTH = 256;
|
|
20
|
+
const MAX_METADATA_LENGTH = 32_000;
|
|
21
|
+
const MAX_STACK_LENGTH = 8_192;
|
|
22
|
+
|
|
23
|
+
const TELEMETRY_LOG_IDENTIFIER = 'Unhandled Exception Telemetry -->';
|
|
24
|
+
|
|
25
|
+
type ErrorDetails = {
|
|
26
|
+
column?: number;
|
|
27
|
+
filename?: string;
|
|
28
|
+
kind: 'error' | 'unhandledrejection' | 'resource_error';
|
|
29
|
+
line?: number;
|
|
30
|
+
message?: string;
|
|
31
|
+
name: string;
|
|
32
|
+
/** Uppercase DOM tag name, such as SCRIPT, LINK, or IMG; RESOURCE when unavailable. */
|
|
33
|
+
resourceType?: string;
|
|
34
|
+
resourceUrl?: string;
|
|
35
|
+
stack?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UnhandledExceptionEvent = {
|
|
39
|
+
schemaVersion: 1;
|
|
40
|
+
capturedAt: number;
|
|
41
|
+
eventId: string;
|
|
42
|
+
occurrenceCount: number;
|
|
43
|
+
common: {
|
|
44
|
+
appName: string;
|
|
45
|
+
appVersion?: string;
|
|
46
|
+
runtime: 'browser';
|
|
47
|
+
sdkVersion?: string;
|
|
48
|
+
};
|
|
49
|
+
error: ErrorDetails & {fingerprint: string};
|
|
50
|
+
metadata?: Record<string, unknown>;
|
|
51
|
+
metadataCaptureStatus?: 'invalid_type' | 'provider_error' | 'too_large';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type WebexForUnhandledExceptionTelemetry = {
|
|
55
|
+
canAuthorize?: boolean;
|
|
56
|
+
version?: string;
|
|
57
|
+
config?: {
|
|
58
|
+
appName?: string;
|
|
59
|
+
appVersion?: string;
|
|
60
|
+
sdkType?: string;
|
|
61
|
+
metrics?: {
|
|
62
|
+
unhandledExceptionTelemetry?: {
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
getMetadata?: () => Record<string, unknown> | undefined;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
internal?: {
|
|
69
|
+
metrics?: {
|
|
70
|
+
submitClientMetrics?: (name: string, properties: object, preLoginId?: string) => unknown;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
logger?: {
|
|
74
|
+
error?: (...args: unknown[]) => unknown;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function logTelemetryFailure(webex: WebexForUnhandledExceptionTelemetry, message: string): void {
|
|
79
|
+
try {
|
|
80
|
+
webex.logger?.error?.(TELEMETRY_LOG_IDENTIFIER, message);
|
|
81
|
+
} catch {
|
|
82
|
+
// Logging must not turn a telemetry failure into an application failure.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SDK-owned browser exception reporter with a single in-memory deduplication window.
|
|
88
|
+
*/
|
|
89
|
+
class UnhandledExceptionTelemetry {
|
|
90
|
+
private readonly pendingEvents = new Map<string, UnhandledExceptionEvent>();
|
|
91
|
+
private readonly eventTarget: Window;
|
|
92
|
+
private readonly errorListener = (event: ErrorEvent) => this.captureError(event);
|
|
93
|
+
private readonly preLoginId: string;
|
|
94
|
+
private readonly rejectionListener = (event: PromiseRejectionEvent) =>
|
|
95
|
+
this.captureRejection(event);
|
|
96
|
+
|
|
97
|
+
private readonly webex: WebexForUnhandledExceptionTelemetry;
|
|
98
|
+
private flushTimer?: number | NodeJS.Timeout;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Registers browser listeners after the SDK is ready.
|
|
102
|
+
* @param webex SDK instance used for configuration and submission.
|
|
103
|
+
* @param eventTarget Browser window.
|
|
104
|
+
*/
|
|
105
|
+
constructor(webex: WebexForUnhandledExceptionTelemetry, eventTarget: Window) {
|
|
106
|
+
this.webex = webex;
|
|
107
|
+
this.eventTarget = eventTarget;
|
|
108
|
+
this.preLoginId = uuid.v4();
|
|
109
|
+
eventTarget.addEventListener('error', this.errorListener, {capture: true});
|
|
110
|
+
eventTarget.addEventListener('unhandledrejection', this.rejectionListener, {capture: true});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Removes global listeners and either flushes or discards pending events.
|
|
115
|
+
* @param flushPending Whether to submit pending telemetry before teardown.
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
stop(flushPending: boolean): void {
|
|
119
|
+
this.eventTarget.removeEventListener('error', this.errorListener, {capture: true});
|
|
120
|
+
this.eventTarget.removeEventListener('unhandledrejection', this.rejectionListener, {
|
|
121
|
+
capture: true,
|
|
122
|
+
});
|
|
123
|
+
this.clearFlushTimer();
|
|
124
|
+
|
|
125
|
+
if (flushPending) {
|
|
126
|
+
this.pendingEvents.forEach((event) => this.submit(event));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.pendingEvents.clear();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private captureError(event: ErrorEvent): void {
|
|
133
|
+
try {
|
|
134
|
+
const resourceTarget = event.target;
|
|
135
|
+
const currentSrc = resourceTarget ? Reflect.get(resourceTarget, 'currentSrc') : undefined;
|
|
136
|
+
const src = resourceTarget ? Reflect.get(resourceTarget, 'src') : undefined;
|
|
137
|
+
const href = resourceTarget ? Reflect.get(resourceTarget, 'href') : undefined;
|
|
138
|
+
const rawResourceUrl =
|
|
139
|
+
(typeof currentSrc === 'string' && currentSrc.length > 0 && currentSrc) ||
|
|
140
|
+
(typeof src === 'string' && src.length > 0 && src) ||
|
|
141
|
+
(typeof href === 'string' && href.length > 0 && href) ||
|
|
142
|
+
undefined;
|
|
143
|
+
|
|
144
|
+
if (rawResourceUrl) {
|
|
145
|
+
const resourceUrl = sanitizeResourceUrl(rawResourceUrl);
|
|
146
|
+
|
|
147
|
+
if (!resourceUrl) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const resourceType = String(
|
|
152
|
+
(resourceTarget && Reflect.get(resourceTarget, 'tagName')) ?? 'RESOURCE'
|
|
153
|
+
).toUpperCase();
|
|
154
|
+
|
|
155
|
+
this.capture({
|
|
156
|
+
kind: 'resource_error',
|
|
157
|
+
message: `Failed to load ${resourceType} resource`,
|
|
158
|
+
name: 'ResourceError',
|
|
159
|
+
resourceType,
|
|
160
|
+
resourceUrl,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const {error} = event;
|
|
167
|
+
let message = 'Unknown uncaught error';
|
|
168
|
+
|
|
169
|
+
if (typeof error?.message === 'string') {
|
|
170
|
+
message = error.message;
|
|
171
|
+
} else if (typeof event.message === 'string') {
|
|
172
|
+
message = event.message;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.capture({
|
|
176
|
+
column: event.colno,
|
|
177
|
+
filename: removeUrlDetails(event.filename),
|
|
178
|
+
kind: 'error',
|
|
179
|
+
line: event.lineno,
|
|
180
|
+
message,
|
|
181
|
+
name: typeof error?.name === 'string' ? error.name : 'Error',
|
|
182
|
+
stack: typeof error?.stack === 'string' ? error.stack : undefined,
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
logTelemetryFailure(this.webex, 'Failed to extract an uncaught error.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private captureRejection(event: PromiseRejectionEvent): void {
|
|
190
|
+
try {
|
|
191
|
+
const {reason} = event;
|
|
192
|
+
|
|
193
|
+
this.capture({
|
|
194
|
+
kind: 'unhandledrejection',
|
|
195
|
+
message: typeof reason?.message === 'string' ? reason.message : stringifyReason(reason),
|
|
196
|
+
name: typeof reason?.name === 'string' ? reason.name : 'UnhandledRejection',
|
|
197
|
+
stack: typeof reason?.stack === 'string' ? reason.stack : undefined,
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
logTelemetryFailure(this.webex, 'Failed to extract an unhandled rejection.');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private capture(details: ErrorDetails): void {
|
|
205
|
+
try {
|
|
206
|
+
const error = {
|
|
207
|
+
...details,
|
|
208
|
+
name: truncate(removeUrlDetailsFromText(details.name), MAX_ERROR_NAME_LENGTH) ?? 'Error',
|
|
209
|
+
message: truncate(removeUrlDetailsFromText(details.message), MAX_ERROR_MESSAGE_LENGTH),
|
|
210
|
+
stack: truncate(removeUrlDetailsFromText(details.stack), MAX_STACK_LENGTH),
|
|
211
|
+
};
|
|
212
|
+
const fingerprint = createFingerprint(
|
|
213
|
+
[
|
|
214
|
+
error.kind,
|
|
215
|
+
error.name,
|
|
216
|
+
error.message,
|
|
217
|
+
error.stack,
|
|
218
|
+
error.filename,
|
|
219
|
+
error.line,
|
|
220
|
+
error.column,
|
|
221
|
+
error.resourceUrl,
|
|
222
|
+
].join('|')
|
|
223
|
+
);
|
|
224
|
+
const capturedAt = new Date().getTime();
|
|
225
|
+
const existingEvent = this.pendingEvents.get(fingerprint);
|
|
226
|
+
|
|
227
|
+
if (existingEvent) {
|
|
228
|
+
const isWithinDedupeWindow = capturedAt - existingEvent.capturedAt < DEDUPE_WINDOW_MS;
|
|
229
|
+
|
|
230
|
+
if (isWithinDedupeWindow) {
|
|
231
|
+
existingEvent.occurrenceCount += 1;
|
|
232
|
+
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A throttled timer can leave an expired event pending. Its completed window must be
|
|
237
|
+
// submitted before this fingerprint is replaced with a new event and a new window.
|
|
238
|
+
this.pendingEvents.delete(fingerprint);
|
|
239
|
+
this.submit(existingEvent);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const event: UnhandledExceptionEvent = {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
capturedAt,
|
|
245
|
+
eventId: uuid.v4(),
|
|
246
|
+
occurrenceCount: 1,
|
|
247
|
+
common: {
|
|
248
|
+
appName: this.webex.config?.appName ?? this.webex.config?.sdkType ?? 'webex-js-sdk',
|
|
249
|
+
appVersion: this.webex.config?.appVersion,
|
|
250
|
+
runtime: 'browser',
|
|
251
|
+
sdkVersion: this.webex.version,
|
|
252
|
+
},
|
|
253
|
+
error: {...error, fingerprint},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.addMetadata(event);
|
|
257
|
+
this.pendingEvents.set(fingerprint, event);
|
|
258
|
+
|
|
259
|
+
if (this.flushTimer === undefined) {
|
|
260
|
+
this.flushTimer = safeSetTimeout(() => this.flush(), DEDUPE_WINDOW_MS);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
logTelemetryFailure(this.webex, 'Failed to capture an exception.');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private addMetadata(event: UnhandledExceptionEvent): void {
|
|
268
|
+
const getMetadata = this.webex.config?.metrics?.unhandledExceptionTelemetry?.getMetadata;
|
|
269
|
+
|
|
270
|
+
if (!getMetadata) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const metadata = getMetadata();
|
|
276
|
+
|
|
277
|
+
if (metadata === undefined) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const serializedMetadata = JSON.stringify(metadata);
|
|
282
|
+
|
|
283
|
+
if (serializedMetadata === undefined || serializedMetadata.length > MAX_METADATA_LENGTH) {
|
|
284
|
+
event.metadataCaptureStatus =
|
|
285
|
+
serializedMetadata === undefined ? 'invalid_type' : 'too_large';
|
|
286
|
+
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const parsedMetadata = JSON.parse(serializedMetadata);
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
parsedMetadata === null ||
|
|
294
|
+
typeof parsedMetadata !== 'object' ||
|
|
295
|
+
Array.isArray(parsedMetadata)
|
|
296
|
+
) {
|
|
297
|
+
event.metadataCaptureStatus = 'invalid_type';
|
|
298
|
+
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
event.metadata = parsedMetadata;
|
|
303
|
+
} catch {
|
|
304
|
+
event.metadataCaptureStatus = 'provider_error';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private flush(): void {
|
|
309
|
+
const now = new Date().getTime();
|
|
310
|
+
let nextFlushInMs: number | undefined;
|
|
311
|
+
|
|
312
|
+
this.clearFlushTimer();
|
|
313
|
+
this.pendingEvents.forEach((event, fingerprint) => {
|
|
314
|
+
const remainingWindowMs = DEDUPE_WINDOW_MS - (now - event.capturedAt);
|
|
315
|
+
|
|
316
|
+
if (remainingWindowMs <= 0) {
|
|
317
|
+
this.pendingEvents.delete(fingerprint);
|
|
318
|
+
this.submit(event);
|
|
319
|
+
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
nextFlushInMs = Math.min(nextFlushInMs ?? remainingWindowMs, remainingWindowMs);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (nextFlushInMs !== undefined) {
|
|
327
|
+
this.flushTimer = safeSetTimeout(() => this.flush(), nextFlushInMs);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private submit(event: UnhandledExceptionEvent): void {
|
|
332
|
+
const submitClientMetrics = this.webex.internal?.metrics?.submitClientMetrics;
|
|
333
|
+
|
|
334
|
+
if (!submitClientMetrics) {
|
|
335
|
+
logTelemetryFailure(this.webex, 'submitClientMetrics is unavailable.');
|
|
336
|
+
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
Promise.resolve(
|
|
342
|
+
submitClientMetrics.call(
|
|
343
|
+
this.webex.internal?.metrics,
|
|
344
|
+
UNHANDLED_EXCEPTION_METRIC_NAME,
|
|
345
|
+
{
|
|
346
|
+
tags: {
|
|
347
|
+
app_name: event.common.appName,
|
|
348
|
+
exception_kind: event.error.kind,
|
|
349
|
+
runtime: event.common.runtime,
|
|
350
|
+
},
|
|
351
|
+
fields: {
|
|
352
|
+
captured_at: event.capturedAt,
|
|
353
|
+
error_fingerprint: event.error.fingerprint,
|
|
354
|
+
error_name: event.error.name,
|
|
355
|
+
event_id: event.eventId,
|
|
356
|
+
occurrence_count: event.occurrenceCount,
|
|
357
|
+
},
|
|
358
|
+
eventPayload: event,
|
|
359
|
+
},
|
|
360
|
+
this.webex.canAuthorize === true ? undefined : this.preLoginId
|
|
361
|
+
)
|
|
362
|
+
).catch(() => {
|
|
363
|
+
logTelemetryFailure(this.webex, 'Failed to submit exception telemetry.');
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
logTelemetryFailure(this.webex, 'Failed to submit exception telemetry.');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private clearFlushTimer(): void {
|
|
371
|
+
if (this.flushTimer !== undefined) {
|
|
372
|
+
clearTimeout(this.flushTimer);
|
|
373
|
+
this.flushTimer = undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let activeTelemetry: UnhandledExceptionTelemetry | undefined;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Starts browser exception telemetry after SDK readiness.
|
|
382
|
+
* @param webex Initialized SDK instance.
|
|
383
|
+
* @returns {void}
|
|
384
|
+
*/
|
|
385
|
+
export function startUnhandledExceptionTelemetry(webex: WebexForUnhandledExceptionTelemetry): void {
|
|
386
|
+
const shouldStart =
|
|
387
|
+
webex.config?.metrics?.unhandledExceptionTelemetry?.enabled === true &&
|
|
388
|
+
typeof window !== 'undefined' &&
|
|
389
|
+
typeof window.addEventListener === 'function';
|
|
390
|
+
|
|
391
|
+
activeTelemetry?.stop(shouldStart);
|
|
392
|
+
activeTelemetry = undefined;
|
|
393
|
+
|
|
394
|
+
if (!shouldStart) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
activeTelemetry = new UnhandledExceptionTelemetry(webex, window);
|
|
400
|
+
} catch {
|
|
401
|
+
logTelemetryFailure(webex, 'Failed to start exception telemetry.');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* eslint-disable require-jsdoc */
|
|
2
|
+
|
|
3
|
+
const MAX_RESOURCE_URL_LENGTH = 2_048;
|
|
4
|
+
|
|
5
|
+
export function truncate(value: string | undefined, maxLength: number): string | undefined {
|
|
6
|
+
return value?.slice(0, maxLength);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function removeUrlDetails(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const withoutQueryOrFragment = value.split(/[?#]/, 1)[0];
|
|
15
|
+
|
|
16
|
+
// Strip URL userinfo from the authority. For example,
|
|
17
|
+
// https://userinfo@host/path becomes https://host/path.
|
|
18
|
+
return withoutQueryOrFragment.replace(/^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/?#]*@/i, '$1');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Keep HTTP(S) and relative resource URLs, remove credentials/query/fragment details, and cap them.
|
|
22
|
+
// For example, https://host/app.js?token=x becomes https://host/app.js, while
|
|
23
|
+
// data:image/svg+xml,<svg>...</svg> is rejected because its scheme is not HTTP(S).
|
|
24
|
+
export function sanitizeResourceUrl(value: unknown): string | undefined {
|
|
25
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const url = value.trim();
|
|
30
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1]?.toLowerCase();
|
|
31
|
+
|
|
32
|
+
if (scheme && scheme !== 'http' && scheme !== 'https') {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return truncate(removeUrlDetails(url), MAX_RESOURCE_URL_LENGTH);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeUrlDetailsFromText(value: string | undefined): string | undefined {
|
|
40
|
+
// Redact non-HTTP(S) schemes and strip credentials, queries, and fragments from other URLs.
|
|
41
|
+
// Examples: data:text/plain,secret -> [redacted-url], https://host/api?token=x ->
|
|
42
|
+
// https://host/api, api/messages?token=x -> api/messages, and GET app.js?token=x -> GET app.js.
|
|
43
|
+
return (
|
|
44
|
+
value
|
|
45
|
+
// Handles root and dot-relative paths such as /api?token=x and ../app.js#fragment.
|
|
46
|
+
// These run before the scheme matcher so query values such as x:10 are not treated as URLs.
|
|
47
|
+
?.replace(
|
|
48
|
+
/(^|[\s("'=[{])((?:\/|\.\.?\/)[^\s)"'\]}]+)/g,
|
|
49
|
+
(_, prefix, url) => `${prefix}${removeUrlDetails(url) ?? url}`
|
|
50
|
+
)
|
|
51
|
+
.replace(
|
|
52
|
+
// Handles bare paths with a slash or file extension, such as api/messages?x=1 or app.js#x.
|
|
53
|
+
/(^|[\s("'=[{])((?:(?:[a-z0-9._~%-]+\/)+(?:[a-z0-9._~%-]+)?|[a-z0-9._~%-]+\.[a-z0-9._~%-]+)[?#][^\s)"'\]}]+)/gi,
|
|
54
|
+
(_, prefix, url) => `${prefix}${removeUrlDetails(url) ?? url}`
|
|
55
|
+
)
|
|
56
|
+
.replace(
|
|
57
|
+
// Handles extensionless single-segment paths after an HTTP method, such as GET api?token=x.
|
|
58
|
+
/\b(DELETE|GET|HEAD|OPTIONS|PATCH|POST|PUT)(\s+)([a-z0-9._~%-]+[?#][^\s)"'\]}]+)/gi,
|
|
59
|
+
(_, method, spacing, url) => `${method}${spacing}${removeUrlDetails(url) ?? url}`
|
|
60
|
+
)
|
|
61
|
+
.replace(
|
|
62
|
+
// Handles URLs with a scheme, including assigned values such as url=https://host/path?x=1.
|
|
63
|
+
// HTTP(S) details are stripped; data:, blob:, and other schemes are fully redacted.
|
|
64
|
+
/(^|[\s("'=[{,;])(([a-z][a-z0-9+.-]*):[^\s)"'\]}]+)/gi,
|
|
65
|
+
(_, prefix, url, scheme) =>
|
|
66
|
+
`${prefix}${
|
|
67
|
+
['http', 'https'].includes(scheme.toLowerCase())
|
|
68
|
+
? removeUrlDetails(url) ?? url
|
|
69
|
+
: '[redacted-url]'
|
|
70
|
+
}`
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createFingerprint(value: string): string {
|
|
76
|
+
// DJB2-style hash: multiply by 33, add each UTF-16 code unit, and constrain the result to 32 bits.
|
|
77
|
+
// It provides a small stable deduplication key; it is not intended for cryptographic use.
|
|
78
|
+
let hash = 5381;
|
|
79
|
+
|
|
80
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
81
|
+
hash = (hash * 33 + value.charCodeAt(index)) % 4_294_967_296;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return hash.toString(16).padStart(8, '0');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function stringifyReason(reason: unknown): string {
|
|
88
|
+
if (typeof reason === 'string') {
|
|
89
|
+
return reason;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(reason) ?? String(reason);
|
|
94
|
+
} catch {
|
|
95
|
+
try {
|
|
96
|
+
return String(reason);
|
|
97
|
+
} catch {
|
|
98
|
+
return 'Unserializable rejection reason';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {assert} from '@webex/test-helper-chai';
|
|
2
|
+
|
|
3
|
+
import {isAutomatedUser, isAutomatedUserAgent} from '../../../src/automated-user';
|
|
4
|
+
|
|
5
|
+
describe('automated user detection', () => {
|
|
6
|
+
[
|
|
7
|
+
{userAgent: 'SkypeUriPreview', expected: true},
|
|
8
|
+
{userAgent: 'skypeuripreview', expected: true},
|
|
9
|
+
{userAgent: 'Mozilla/5.0 (compatible; SkypeUriPreview/0.1)', expected: true},
|
|
10
|
+
{userAgent: 'Googlebot/2.1 (+http://www.google.com/bot.html)', expected: true},
|
|
11
|
+
{
|
|
12
|
+
userAgent:
|
|
13
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36',
|
|
14
|
+
expected: false,
|
|
15
|
+
},
|
|
16
|
+
{userAgent: undefined, expected: false},
|
|
17
|
+
].forEach(({userAgent, expected}) => {
|
|
18
|
+
it(`returns ${expected} for ${userAgent || 'an undefined user agent'}`, () => {
|
|
19
|
+
assert.equal(isAutomatedUserAgent(userAgent), expected);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns the cached classification when navigator changes', () => {
|
|
24
|
+
const cachedResult = isAutomatedUser();
|
|
25
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(global, 'navigator');
|
|
26
|
+
|
|
27
|
+
Object.defineProperty(global, 'navigator', {
|
|
28
|
+
value: cachedResult
|
|
29
|
+
? {userAgent: 'Mozilla/5.0', webdriver: false}
|
|
30
|
+
: {userAgent: 'SkypeUriPreview', webdriver: true},
|
|
31
|
+
configurable: true,
|
|
32
|
+
writable: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(isAutomatedUser(), cachedResult);
|
|
36
|
+
|
|
37
|
+
if (originalDescriptor) {
|
|
38
|
+
Object.defineProperty(global, 'navigator', originalDescriptor);
|
|
39
|
+
} else {
|
|
40
|
+
delete (global as any).navigator;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -177,6 +177,49 @@ describe('plugin-metrics', () => {
|
|
|
177
177
|
assert.lengthOf(webex.internal.metrics.batcher.queue, 0);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
it('rejects the deferred without reenqueuing when batcherRetryOnNetworkError is false', () => {
|
|
182
|
+
webex.config.metrics = {...config.metrics, batcherRetryOnNetworkError: false};
|
|
183
|
+
|
|
184
|
+
webex.request = function () {
|
|
185
|
+
// noop
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
sinon.stub(webex, 'request').callsFake((options) => {
|
|
189
|
+
options.headers = {
|
|
190
|
+
trackingid: 'test-no-retry',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return Promise.reject(
|
|
194
|
+
new WebexHttpError.NetworkOrCORSError({
|
|
195
|
+
statusCode: 0,
|
|
196
|
+
options,
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const promise = webex.internal.metrics.batcher.request({
|
|
202
|
+
key: 'testMetric',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return promiseTick(50)
|
|
206
|
+
.then(() => assert.lengthOf(webex.internal.metrics.batcher.queue, 1))
|
|
207
|
+
.then(() => clock.tick(config.metrics.batcherWait))
|
|
208
|
+
.then(() => assert.calledOnce(webex.request))
|
|
209
|
+
.then(() => promiseTick(50))
|
|
210
|
+
.then(() =>
|
|
211
|
+
promise.then(
|
|
212
|
+
() => {
|
|
213
|
+
assert.fail('promise should have been rejected');
|
|
214
|
+
},
|
|
215
|
+
(reason) => {
|
|
216
|
+
assert.instanceOf(reason, WebexHttpError.NetworkOrCORSError);
|
|
217
|
+
assert.lengthOf(webex.internal.metrics.batcher.queue, 0);
|
|
218
|
+
assert.calledOnce(webex.request);
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
});
|
|
180
223
|
});
|
|
181
224
|
});
|
|
182
225
|
});
|
|
@@ -142,9 +142,7 @@ describe('plugin-metrics', () => {
|
|
|
142
142
|
webex.internal.newMetrics.callDiagnosticLatencies.getDiffBetweenTimestamps = sinon
|
|
143
143
|
.stub()
|
|
144
144
|
.returns(10);
|
|
145
|
-
webex.internal.newMetrics.callDiagnosticLatencies.getU2CTime = sinon
|
|
146
|
-
.stub()
|
|
147
|
-
.returns(20);
|
|
145
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getU2CTime = sinon.stub().returns(20);
|
|
148
146
|
webex.internal.newMetrics.callDiagnosticLatencies.getReachabilityClustersReqResp = sinon
|
|
149
147
|
.stub()
|
|
150
148
|
.returns(10);
|
|
@@ -165,7 +163,7 @@ describe('plugin-metrics', () => {
|
|
|
165
163
|
registerWDMDeviceJMT: 10,
|
|
166
164
|
showInterstitialTime: 10,
|
|
167
165
|
getU2CTime: 20,
|
|
168
|
-
getReachabilityClustersReqResp: 10
|
|
166
|
+
getReachabilityClustersReqResp: 10,
|
|
169
167
|
},
|
|
170
168
|
});
|
|
171
169
|
assert.lengthOf(
|
|
@@ -189,12 +187,17 @@ describe('plugin-metrics', () => {
|
|
|
189
187
|
webex.internal.newMetrics.callDiagnosticLatencies.getDownloadTimeJMT = sinon
|
|
190
188
|
.stub()
|
|
191
189
|
.returns(100);
|
|
192
|
-
webex.internal.newMetrics.callDiagnosticLatencies.getClickToInterstitialWithUserDelay =
|
|
193
|
-
.stub()
|
|
194
|
-
.returns(43);
|
|
190
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getClickToInterstitialWithUserDelay =
|
|
191
|
+
sinon.stub().returns(43);
|
|
195
192
|
webex.internal.newMetrics.callDiagnosticLatencies.getTotalJMTWithUserDelay = sinon
|
|
196
193
|
.stub()
|
|
197
194
|
.returns(64);
|
|
195
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getInterstitialToJoinOK = sinon
|
|
196
|
+
.stub()
|
|
197
|
+
.returns(10);
|
|
198
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getTotalJMT = sinon
|
|
199
|
+
.stub()
|
|
200
|
+
.returns(20);
|
|
198
201
|
const promise = webex.internal.newMetrics.callDiagnosticMetrics.submitToCallDiagnostics(
|
|
199
202
|
//@ts-ignore
|
|
200
203
|
{event: {name: 'client.locus.join.response'}}
|
|
@@ -346,9 +349,15 @@ describe('plugin-metrics', () => {
|
|
|
346
349
|
webex.internal.newMetrics.callDiagnosticLatencies.getInterstitialToJoinOK = sinon
|
|
347
350
|
.stub()
|
|
348
351
|
.returns(7);
|
|
349
|
-
|
|
352
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getStayLobbyTime = sinon
|
|
353
|
+
.stub()
|
|
354
|
+
.returns(1);
|
|
355
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getStayLobbyTimeCappedBy = sinon
|
|
350
356
|
.stub()
|
|
351
357
|
.returns(1);
|
|
358
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getTotalMediaJMT = sinon
|
|
359
|
+
.stub()
|
|
360
|
+
.returns(44);
|
|
352
361
|
webex.internal.newMetrics.callDiagnosticLatencies.getTotalMediaJMTWithUserDelay = sinon
|
|
353
362
|
.stub()
|
|
354
363
|
.returns(43);
|
|
@@ -369,10 +378,9 @@ describe('plugin-metrics', () => {
|
|
|
369
378
|
assert.deepEqual(webex.request.getCalls()[0].args[0].body.metrics[0].eventPayload.event, {
|
|
370
379
|
name: 'client.media-engine.ready',
|
|
371
380
|
joinTimes: {
|
|
372
|
-
totalMediaJMT:
|
|
381
|
+
totalMediaJMT: 44,
|
|
373
382
|
interstitialToMediaOKJMT: 22,
|
|
374
|
-
callInitMediaEngineReady:
|
|
375
|
-
stayLobbyTime: 1,
|
|
383
|
+
callInitMediaEngineReady: 22,
|
|
376
384
|
totalMediaJMTWithUserDelay: 43,
|
|
377
385
|
totalJMTWithUserDelay: 64,
|
|
378
386
|
},
|
|
@@ -382,6 +390,34 @@ describe('plugin-metrics', () => {
|
|
|
382
390
|
0
|
|
383
391
|
);
|
|
384
392
|
});
|
|
393
|
+
|
|
394
|
+
it('appends the correct join times to the request for client.lobby.exited', async () => {
|
|
395
|
+
webex.internal.newMetrics.callDiagnosticLatencies.getStayLobbyTime = sinon
|
|
396
|
+
.stub()
|
|
397
|
+
.returns(10);
|
|
398
|
+
|
|
399
|
+
const promise = webex.internal.newMetrics.callDiagnosticMetrics.submitToCallDiagnostics(
|
|
400
|
+
//@ts-ignore
|
|
401
|
+
{event: {name: 'client.lobby.exited'}}
|
|
402
|
+
);
|
|
403
|
+
await flushPromises();
|
|
404
|
+
clock.tick(config.metrics.batcherWait);
|
|
405
|
+
|
|
406
|
+
await promise;
|
|
407
|
+
|
|
408
|
+
//@ts-ignore
|
|
409
|
+
assert.calledOnce(webex.request);
|
|
410
|
+
assert.deepEqual(webex.request.getCalls()[0].args[0].body.metrics[0].eventPayload.event, {
|
|
411
|
+
name: 'client.lobby.exited',
|
|
412
|
+
joinTimes: {
|
|
413
|
+
stayLobbyTime: 10,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
assert.lengthOf(
|
|
417
|
+
webex.internal.newMetrics.callDiagnosticMetrics.callDiagnosticEventsBatcher.queue,
|
|
418
|
+
0
|
|
419
|
+
);
|
|
420
|
+
});
|
|
385
421
|
});
|
|
386
422
|
|
|
387
423
|
describe('when the request fails', () => {
|