@webex/internal-plugin-metrics 3.12.0-next.5 → 3.12.0-next.50

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.
Files changed (84) hide show
  1. package/README.md +274 -0
  2. package/dist/automated-user.js +19 -0
  3. package/dist/automated-user.js.map +1 -0
  4. package/dist/batcher.js +3 -0
  5. package/dist/batcher.js.map +1 -1
  6. package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js +635 -62
  7. package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js.map +1 -1
  8. package/dist/call-diagnostic/call-diagnostic-metrics.js +188 -78
  9. package/dist/call-diagnostic/call-diagnostic-metrics.js.map +1 -1
  10. package/dist/call-diagnostic/call-diagnostic-metrics.util.js +5 -0
  11. package/dist/call-diagnostic/call-diagnostic-metrics.util.js.map +1 -1
  12. package/dist/call-diagnostic/config.js +16 -3
  13. package/dist/call-diagnostic/config.js.map +1 -1
  14. package/dist/config.js +8 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/index.js +25 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/metrics.js +118 -5
  19. package/dist/metrics.js.map +1 -1
  20. package/dist/metrics.types.js +4 -0
  21. package/dist/metrics.types.js.map +1 -1
  22. package/dist/network-telemetry.js +610 -0
  23. package/dist/network-telemetry.js.map +1 -0
  24. package/dist/new-metrics.js +63 -17
  25. package/dist/new-metrics.js.map +1 -1
  26. package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
  27. package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
  28. package/dist/privacy-and-security-permission-enricher/index.js +143 -0
  29. package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
  30. package/dist/privacy-and-security-permission-enricher/types.js +7 -0
  31. package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
  32. package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
  33. package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
  34. package/dist/types/automated-user.d.ts +2 -0
  35. package/dist/types/call-diagnostic/call-diagnostic-metrics-latencies.d.ts +208 -5
  36. package/dist/types/call-diagnostic/call-diagnostic-metrics.d.ts +73 -14
  37. package/dist/types/call-diagnostic/config.d.ts +4 -0
  38. package/dist/types/config.d.ts +9 -0
  39. package/dist/types/index.d.ts +5 -3
  40. package/dist/types/metrics.types.d.ts +7 -2
  41. package/dist/types/network-telemetry.d.ts +108 -0
  42. package/dist/types/new-metrics.d.ts +17 -1
  43. package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
  44. package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
  45. package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
  46. package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -0
  47. package/dist/types/unhandled-exception-telemetry/index.d.ts +60 -0
  48. package/dist/types/unhandled-exception-telemetry/utils.d.ts +6 -0
  49. package/dist/unhandled-exception-telemetry/index.js +330 -0
  50. package/dist/unhandled-exception-telemetry/index.js.map +1 -0
  51. package/dist/unhandled-exception-telemetry/utils.js +105 -0
  52. package/dist/unhandled-exception-telemetry/utils.js.map +1 -0
  53. package/package.json +12 -11
  54. package/src/automated-user.ts +16 -0
  55. package/src/batcher.js +4 -0
  56. package/src/call-diagnostic/call-diagnostic-metrics-latencies.ts +781 -71
  57. package/src/call-diagnostic/call-diagnostic-metrics.ts +119 -16
  58. package/src/call-diagnostic/call-diagnostic-metrics.util.ts +5 -0
  59. package/src/call-diagnostic/config.ts +14 -0
  60. package/src/config.js +8 -0
  61. package/src/index.ts +16 -0
  62. package/src/metrics.js +123 -5
  63. package/src/metrics.types.ts +25 -3
  64. package/src/network-telemetry.ts +757 -0
  65. package/src/new-metrics.ts +56 -6
  66. package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
  67. package/src/privacy-and-security-permission-enricher/index.ts +142 -0
  68. package/src/privacy-and-security-permission-enricher/types.ts +21 -0
  69. package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
  70. package/src/unhandled-exception-telemetry/index.ts +403 -0
  71. package/src/unhandled-exception-telemetry/utils.ts +101 -0
  72. package/test/unit/spec/automated-user.ts +43 -0
  73. package/test/unit/spec/batcher.js +43 -0
  74. package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts +14 -2
  75. package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-latencies.ts +1424 -293
  76. package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +906 -159
  77. package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts +27 -0
  78. package/test/unit/spec/metrics.js +14 -5
  79. package/test/unit/spec/network-telemetry.ts +610 -0
  80. package/test/unit/spec/new-metrics.ts +203 -50
  81. package/test/unit/spec/prelogin-metrics-batcher.ts +71 -36
  82. package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
  83. package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
  84. package/test/unit/spec/unhandled-exception-telemetry.ts +688 -0
@@ -460,6 +460,33 @@ describe('internal-plugin-metrics', () => {
460
460
  });
461
461
  });
462
462
 
463
+ it('logs a Milestone entry when event name is present', () => {
464
+ const logSpy = sinon.spy(webex.logger, 'log');
465
+ const eventName = 'client.call.initiated';
466
+ const eventPayload = {event: {name: eventName}};
467
+
468
+ prepareDiagnosticMetricItem(webex, {eventPayload, type: ['diagnostic-event']});
469
+
470
+ assert.isTrue(
471
+ logSpy.calledWith('Milestone,CallDiagnostic', eventName),
472
+ 'expected logger.log to be called with Milestone,CallDiagnostic and the event name'
473
+ );
474
+ sinon.restore();
475
+ });
476
+
477
+ it('does not log a Milestone entry when event name is absent', () => {
478
+ const logSpy = sinon.spy(webex.logger, 'log');
479
+ const eventPayload = {event: {}};
480
+
481
+ prepareDiagnosticMetricItem(webex, {eventPayload, type: ['diagnostic-event']});
482
+
483
+ assert.isFalse(
484
+ logSpy.calledWith('Milestone,CallDiagnostic', sinon.match.any),
485
+ 'expected logger.log not to be called with Milestone,CallDiagnostic when event name is absent'
486
+ );
487
+ sinon.restore();
488
+ });
489
+
463
490
  it('sets buildType and upgradeChannel correctly', () => {
464
491
  const item: any = {
465
492
  eventPayload: {
@@ -13,7 +13,7 @@ import {BrowserDetection} from '@webex/common';
13
13
  const {getOSVersion} = BrowserDetection();
14
14
 
15
15
  //@ts-ignore
16
- global.window = {location: {hostname: 'whatever'}};
16
+ global.window = {location: {hostname: 'whatever', origin: 'https://whatever'}};
17
17
  function promiseTick(count) {
18
18
  let promise = Promise.resolve();
19
19
 
@@ -30,6 +30,7 @@ describe('plugin-metrics', () => {
30
30
  let webex;
31
31
  let metrics;
32
32
  let clock;
33
+ let previousWindow;
33
34
 
34
35
  const eventName = 'test_event';
35
36
  const mockPayload = {
@@ -67,10 +68,15 @@ describe('plugin-metrics', () => {
67
68
 
68
69
  beforeEach(() => {
69
70
  clock = FakeTimers.install({now: 0});
71
+ previousWindow = global.window;
72
+ // Other test files also replace the shared browser mock. Restore the
73
+ // location needed to verify that app_url prefers the browser origin.
74
+ global.window = {location: {hostname: 'whatever', origin: 'https://whatever'}};
70
75
  });
71
76
 
72
77
  afterEach(() => {
73
78
  clock.uninstall();
79
+ global.window = previousWindow;
74
80
  });
75
81
 
76
82
  beforeEach(() => {
@@ -107,10 +113,10 @@ describe('plugin-metrics', () => {
107
113
  webex.meetings = {
108
114
  config: {
109
115
  metrics: {
110
- clientVersion: '43.0.105'
111
- }
112
- }
113
- }
116
+ clientVersion: '43.0.105',
117
+ },
118
+ },
119
+ };
114
120
 
115
121
  sinon.spy(webex, 'request');
116
122
  sinon.spy(metrics, 'aliasUser');
@@ -186,6 +192,9 @@ describe('plugin-metrics', () => {
186
192
  },
187
193
  metricName: 'test',
188
194
  tags: {
195
+ app_name: 'appName',
196
+ app_url: 'https://whatever',
197
+ app_version: 'appVersion',
189
198
  appVersion: '43.0.105',
190
199
  browser: '',
191
200
  domain: 'whatever',
@@ -0,0 +1,610 @@
1
+ import {assert} from '@webex/test-helper-chai';
2
+ import MockWebex from '@webex/test-helper-mock-webex';
3
+ import sinon from 'sinon';
4
+
5
+ import config from '../../../src/config';
6
+ import Metrics from '../../../src/metrics';
7
+ import {
8
+ createNetworkErrorMetric,
9
+ getOperation,
10
+ NETWORK_REQUEST_SUMMARY_METRIC,
11
+ NETWORK_TELEMETRY_INTERVAL_MS,
12
+ shouldTrackNetworkRequest,
13
+ } from '../../../src/network-telemetry';
14
+
15
+ describe('network telemetry', function () {
16
+ let clock: sinon.SinonFakeTimers;
17
+
18
+ function makeWebex(
19
+ networkTelemetryEnabled = true,
20
+ networkTelemetryIntervalMs = config.metrics.networkTelemetry.intervalMs
21
+ ) {
22
+ const warn = sinon.stub();
23
+ const webex = new MockWebex({
24
+ children: {
25
+ metrics: Metrics,
26
+ },
27
+ logger: {warn},
28
+ config: {
29
+ metrics: {
30
+ networkTelemetry: {
31
+ enabled: networkTelemetryEnabled,
32
+ intervalMs: networkTelemetryIntervalMs,
33
+ },
34
+ },
35
+ },
36
+ });
37
+
38
+ webex.config.metrics = {
39
+ ...config.metrics,
40
+ networkTelemetry: {
41
+ ...config.metrics.networkTelemetry,
42
+ enabled: networkTelemetryEnabled,
43
+ intervalMs: networkTelemetryIntervalMs,
44
+ },
45
+ };
46
+
47
+ return {warn, webex};
48
+ }
49
+
50
+ function makeTrackingId(_value: unknown, index: number) {
51
+ return `tracking-${index}`;
52
+ }
53
+
54
+ beforeEach(function () {
55
+ clock = sinon.useFakeTimers();
56
+ });
57
+
58
+ afterEach(function () {
59
+ clock.restore();
60
+ sinon.restore();
61
+ });
62
+
63
+ it('submits the requested ten-minute aggregate shape', function () {
64
+ const {webex} = makeWebex();
65
+ const submitClientMetrics = sinon
66
+ .stub(webex.internal.metrics, 'submitClientMetrics')
67
+ .resolves();
68
+ const successfulOptions = {
69
+ headers: {trackingid: 'tracking-success'},
70
+ method: 'get',
71
+ resource: 'rooms',
72
+ service: 'Hydra',
73
+ };
74
+ const failedResponseOptions = {
75
+ headers: {trackingid: 'tracking-response'},
76
+ method: 'post',
77
+ resource: 'rooms/Y2lzY29zcGFyazovL3VzL1JPT00/messages?personId=secret',
78
+ service: 'Hydra',
79
+ };
80
+ const failedRequestOptions = {
81
+ headers: {trackingid: 'tracking-request'},
82
+ method: 'get',
83
+ resource: 'people/12345',
84
+ service: 'Identity',
85
+ };
86
+
87
+ webex.emit('request:start', successfulOptions);
88
+ webex.emit('request:success', successfulOptions, {statusCode: 200});
89
+ webex.emit('request:start', failedResponseOptions);
90
+ webex.emit('request:failure', failedResponseOptions, {
91
+ body: {errorCode: 'SERVICE_UNAVAILABLE', message: 'Service unavailable'},
92
+ name: 'ServiceUnavailable',
93
+ statusCode: 503,
94
+ });
95
+ webex.emit('request:start', failedRequestOptions);
96
+ webex.emit('request:failure', failedRequestOptions, {
97
+ body: {code: 'ETIMEDOUT', message: 'Socket timed out'},
98
+ name: 'NetworkOrCORSError',
99
+ statusCode: 0,
100
+ });
101
+
102
+ webex.emit('request:start', {service: 'metrics', resource: 'clientmetrics'});
103
+ webex.emit('request:success', {service: 'metrics', resource: 'clientmetrics'});
104
+ webex.emit('request:failure', {service: 'unifiedTelemetry'}, {statusCode: 503});
105
+
106
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS - 1);
107
+ assert.notCalled(submitClientMetrics);
108
+
109
+ clock.tick(1);
110
+
111
+ assert.calledOnceWithExactly(submitClientMetrics, NETWORK_REQUEST_SUMMARY_METRIC, {
112
+ type: 'operational',
113
+ tags: {},
114
+ fields: {
115
+ totalSendRequest: 3,
116
+ totalFailedRequest: 1,
117
+ totalRecvdResponse: 2,
118
+ totalFailedResponse: 1,
119
+ },
120
+ eventPayload: {
121
+ metricsSummary: {
122
+ totalSendRequest: 3,
123
+ totalFailedRequest: 1,
124
+ totalRecvdResponse: 2,
125
+ totalFailedResponse: 1,
126
+ },
127
+ metrics: [
128
+ {
129
+ host: 'hydra',
130
+ endPoint: 'rooms',
131
+ countSendRequest: 1,
132
+ countFailedRequest: 0,
133
+ countRecvdResponse: 1,
134
+ countFailedResponse: 0,
135
+ averageNetworkDurationMs: 0,
136
+ maxNetworkDurationP90Ms: 0,
137
+ maxNetworkDurationP99Ms: 0,
138
+ },
139
+ {
140
+ host: 'hydra',
141
+ endPoint: 'rooms/:id/messages',
142
+ countSendRequest: 1,
143
+ countFailedRequest: 0,
144
+ countRecvdResponse: 1,
145
+ countFailedResponse: 1,
146
+ averageNetworkDurationMs: 0,
147
+ maxNetworkDurationP90Ms: 0,
148
+ maxNetworkDurationP99Ms: 0,
149
+ },
150
+ {
151
+ host: 'identity',
152
+ endPoint: 'people/:id',
153
+ countSendRequest: 1,
154
+ countFailedRequest: 1,
155
+ countRecvdResponse: 0,
156
+ countFailedResponse: 0,
157
+ averageNetworkDurationMs: 0,
158
+ maxNetworkDurationP90Ms: 0,
159
+ maxNetworkDurationP99Ms: 0,
160
+ },
161
+ ],
162
+ errorMetrics: [
163
+ {
164
+ host: 'hydra',
165
+ endPoint: 'rooms/:id/messages',
166
+ statusCode: 503,
167
+ errorCode: 'SERVICE_UNAVAILABLE',
168
+ method: 'POST',
169
+ errorType: 'server_error',
170
+ errorMessage: 'Service unavailable',
171
+ trackingIds: ['tracking-response'],
172
+ countError: 1,
173
+ },
174
+ {
175
+ host: 'identity',
176
+ endPoint: 'people/:id',
177
+ statusCode: 0,
178
+ errorCode: 'ETIMEDOUT',
179
+ method: 'GET',
180
+ errorType: 'timeout',
181
+ errorMessage: 'Socket timed out',
182
+ trackingIds: ['tracking-request'],
183
+ countError: 1,
184
+ },
185
+ ],
186
+ errorMetricsSummary: {
187
+ 0: {count: 1},
188
+ 503: {count: 1},
189
+ },
190
+ },
191
+ });
192
+ });
193
+
194
+ it('aggregates identical errors and caps unique tracking IDs at ten', function () {
195
+ const {webex} = makeWebex();
196
+ const submitClientMetrics = sinon
197
+ .stub(webex.internal.metrics, 'submitClientMetrics')
198
+ .resolves();
199
+
200
+ Array.from({length: 12}, makeTrackingId).forEach(function emitFailure(trackingid) {
201
+ webex.emit(
202
+ 'request:failure',
203
+ {
204
+ headers: {trackingid},
205
+ method: 'get',
206
+ resource: 'rooms',
207
+ service: 'hydra',
208
+ },
209
+ {
210
+ body: {errorCode: 'NOT_FOUND', message: 'Room not found'},
211
+ name: 'NotFound',
212
+ statusCode: 404,
213
+ }
214
+ );
215
+ });
216
+
217
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
218
+
219
+ const telemetry = submitClientMetrics.firstCall.args[1].eventPayload;
220
+
221
+ assert.deepEqual(telemetry.metricsSummary, {
222
+ totalSendRequest: 0,
223
+ totalFailedRequest: 0,
224
+ totalRecvdResponse: 12,
225
+ totalFailedResponse: 12,
226
+ });
227
+ assert.deepEqual(telemetry.metrics, [
228
+ {
229
+ host: 'hydra',
230
+ endPoint: 'rooms',
231
+ countSendRequest: 0,
232
+ countFailedRequest: 0,
233
+ countRecvdResponse: 12,
234
+ countFailedResponse: 12,
235
+ averageNetworkDurationMs: 0,
236
+ maxNetworkDurationP90Ms: 0,
237
+ maxNetworkDurationP99Ms: 0,
238
+ },
239
+ ]);
240
+ assert.equal(telemetry.errorMetrics[0].countError, 12);
241
+ assert.deepEqual(
242
+ telemetry.errorMetrics[0].trackingIds,
243
+ Array.from({length: 10}, makeTrackingId)
244
+ );
245
+ assert.deepEqual(telemetry.errorMetricsSummary, {404: {count: 12}});
246
+ });
247
+
248
+ it('resets all aggregate sections for the next ten-minute window', function () {
249
+ const {webex} = makeWebex();
250
+ const submitClientMetrics = sinon
251
+ .stub(webex.internal.metrics, 'submitClientMetrics')
252
+ .resolves();
253
+
254
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
255
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
256
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
257
+
258
+ assert.calledTwice(submitClientMetrics);
259
+ assert.deepEqual(submitClientMetrics.secondCall.args, [
260
+ NETWORK_REQUEST_SUMMARY_METRIC,
261
+ {
262
+ type: 'operational',
263
+ tags: {},
264
+ fields: {
265
+ totalSendRequest: 0,
266
+ totalFailedRequest: 0,
267
+ totalRecvdResponse: 0,
268
+ totalFailedResponse: 0,
269
+ },
270
+ eventPayload: {
271
+ metricsSummary: {
272
+ totalSendRequest: 0,
273
+ totalFailedRequest: 0,
274
+ totalRecvdResponse: 0,
275
+ totalFailedResponse: 0,
276
+ },
277
+ metrics: [],
278
+ errorMetrics: [],
279
+ errorMetricsSummary: {},
280
+ },
281
+ },
282
+ ]);
283
+ });
284
+
285
+ it('uses the configured interval for periodic submission', function () {
286
+ const {webex} = makeWebex(true, 1_000);
287
+ const submitClientMetrics = sinon
288
+ .stub(webex.internal.metrics, 'submitClientMetrics')
289
+ .resolves();
290
+
291
+ clock.tick(999);
292
+ assert.notCalled(submitClientMetrics);
293
+
294
+ clock.tick(1);
295
+
296
+ assert.calledOnce(submitClientMetrics);
297
+ });
298
+
299
+ it('flushes the current summary immediately and keeps collecting', async function () {
300
+ const {webex} = makeWebex();
301
+ const submitClientMetrics = sinon
302
+ .stub(webex.internal.metrics, 'submitClientMetrics')
303
+ .resolves();
304
+
305
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
306
+
307
+ await webex.internal.metrics.flushNetworkTelemetry();
308
+
309
+ assert.calledOnce(submitClientMetrics);
310
+
311
+ webex.emit('request:start', {service: 'identity', resource: 'people'});
312
+ await webex.internal.metrics.flushNetworkTelemetry();
313
+
314
+ assert.calledTwice(submitClientMetrics);
315
+ assert.equal(submitClientMetrics.secondCall.args[1].eventPayload.metrics[0].host, 'identity');
316
+ });
317
+
318
+ it('flushes the current summary and stops collecting during cleanup', async function () {
319
+ const {webex} = makeWebex();
320
+ const submitClientMetrics = sinon
321
+ .stub(webex.internal.metrics, 'submitClientMetrics')
322
+ .resolves();
323
+
324
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
325
+ await webex.internal.metrics.stopNetworkTelemetry();
326
+ webex.emit('request:start', {service: 'identity', resource: 'people'});
327
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS * 2);
328
+
329
+ assert.calledOnce(submitClientMetrics);
330
+ assert.deepEqual(submitClientMetrics.firstCall.args[1].eventPayload.metrics, [
331
+ {
332
+ host: 'hydra',
333
+ endPoint: 'rooms',
334
+ countSendRequest: 1,
335
+ countFailedRequest: 0,
336
+ countRecvdResponse: 0,
337
+ countFailedResponse: 0,
338
+ averageNetworkDurationMs: 0,
339
+ maxNetworkDurationP90Ms: 0,
340
+ maxNetworkDurationP99Ms: 0,
341
+ },
342
+ ]);
343
+ });
344
+
345
+ it('waits for a periodic submission that is already in flight before stopping', async function () {
346
+ const {webex} = makeWebex();
347
+ let resolveSubmission: () => void = () => {};
348
+ const pendingSubmission = new Promise<void>((resolve) => {
349
+ resolveSubmission = resolve;
350
+ });
351
+ const submitClientMetrics = sinon
352
+ .stub(webex.internal.metrics, 'submitClientMetrics')
353
+ .returns(pendingSubmission);
354
+
355
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
356
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
357
+
358
+ const stopPromise = webex.internal.metrics.stopNetworkTelemetry();
359
+ let stopSettled = false;
360
+ void stopPromise.then(() => {
361
+ stopSettled = true;
362
+ });
363
+
364
+ await Promise.resolve();
365
+ assert.isFalse(stopSettled);
366
+
367
+ resolveSubmission();
368
+ await stopPromise;
369
+
370
+ assert.isTrue(stopSettled);
371
+ assert.calledOnce(submitClientMetrics);
372
+ });
373
+
374
+ it('waits for a manual flush that is already in flight before stopping', async function () {
375
+ const {webex} = makeWebex();
376
+ let resolveSubmission: () => void = () => {};
377
+ const pendingSubmission = new Promise<void>((resolve) => {
378
+ resolveSubmission = resolve;
379
+ });
380
+ const submitClientMetrics = sinon
381
+ .stub(webex.internal.metrics, 'submitClientMetrics')
382
+ .returns(pendingSubmission);
383
+
384
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
385
+ const flushPromise = webex.internal.metrics.flushNetworkTelemetry();
386
+ const stopPromise = webex.internal.metrics.stopNetworkTelemetry();
387
+ let stopSettled = false;
388
+ void stopPromise.then(() => {
389
+ stopSettled = true;
390
+ });
391
+
392
+ await Promise.resolve();
393
+ assert.isFalse(stopSettled);
394
+
395
+ resolveSubmission();
396
+ await Promise.all([flushPromise, stopPromise]);
397
+
398
+ assert.isTrue(stopSettled);
399
+ assert.calledOnce(submitClientMetrics);
400
+ });
401
+
402
+ it('waits for all overlapping submissions before stopping', async function () {
403
+ const {webex} = makeWebex();
404
+ const resolveSubmissions: Array<() => void> = [];
405
+ const submitClientMetrics = sinon
406
+ .stub(webex.internal.metrics, 'submitClientMetrics')
407
+ .callsFake(
408
+ () =>
409
+ new Promise<void>((resolve) => {
410
+ resolveSubmissions.push(resolve);
411
+ })
412
+ );
413
+
414
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
415
+ const firstFlushPromise = webex.internal.metrics.flushNetworkTelemetry();
416
+ webex.emit('request:start', {service: 'identity', resource: 'people'});
417
+ const secondFlushPromise = webex.internal.metrics.flushNetworkTelemetry();
418
+ const stopPromise = webex.internal.metrics.stopNetworkTelemetry();
419
+ let stopSettled = false;
420
+ void stopPromise.then(() => {
421
+ stopSettled = true;
422
+ });
423
+
424
+ await Promise.resolve();
425
+ assert.equal(submitClientMetrics.callCount, 2);
426
+ assert.isFalse(stopSettled);
427
+
428
+ resolveSubmissions[0]();
429
+ await Promise.resolve();
430
+ assert.isFalse(stopSettled);
431
+
432
+ resolveSubmissions[1]();
433
+ await Promise.all([firstFlushPromise, secondFlushPromise, stopPromise]);
434
+
435
+ assert.isTrue(stopSettled);
436
+ });
437
+
438
+ it('does not initialize network telemetry unless it is enabled', function () {
439
+ const {webex} = makeWebex(false);
440
+ const submitClientMetrics = sinon.stub(webex.internal.metrics, 'submitClientMetrics');
441
+
442
+ webex.emit('request:start', {service: 'hydra', resource: 'rooms'});
443
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
444
+
445
+ assert.isFalse(config.metrics.networkTelemetry.enabled);
446
+ assert.notCalled(submitClientMetrics);
447
+ });
448
+
449
+ it('reports average, p90, and p99 network duration for completed requests', function () {
450
+ const {webex} = makeWebex();
451
+ const submitClientMetrics = sinon
452
+ .stub(webex.internal.metrics, 'submitClientMetrics')
453
+ .resolves();
454
+ Array.from({length: 10}, function emitRequest(_, index) {
455
+ const options = {
456
+ service: 'hydra',
457
+ resource: 'rooms',
458
+ $timings: {networkStart: 0, networkEnd: (index + 1) * 100},
459
+ };
460
+
461
+ webex.emit('request:start', options);
462
+ webex.emit('request:success', options, {statusCode: 200});
463
+ });
464
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
465
+
466
+ assert.deepEqual(submitClientMetrics.firstCall.args[1].eventPayload.metrics, [
467
+ {
468
+ host: 'hydra',
469
+ endPoint: 'rooms',
470
+ countSendRequest: 10,
471
+ countFailedRequest: 0,
472
+ countRecvdResponse: 10,
473
+ countFailedResponse: 0,
474
+ averageNetworkDurationMs: 550,
475
+ maxNetworkDurationP90Ms: 900,
476
+ maxNetworkDurationP99Ms: 1000,
477
+ },
478
+ ]);
479
+ });
480
+
481
+ it('contains asynchronous summary submission failures', async function () {
482
+ const {warn, webex} = makeWebex();
483
+
484
+ sinon
485
+ .stub(webex.internal.metrics, 'submitClientMetrics')
486
+ .rejects(new Error('metrics unavailable'));
487
+
488
+ clock.tick(NETWORK_TELEMETRY_INTERVAL_MS);
489
+ await Promise.resolve();
490
+
491
+ assert.calledOnceWithExactly(
492
+ warn,
493
+ 'metrics: failed to submit network request summary telemetry'
494
+ );
495
+ });
496
+
497
+ [
498
+ {
499
+ name: 'the metrics service',
500
+ options: {service: 'metrics'},
501
+ },
502
+ {
503
+ name: 'the legacy metrics API option',
504
+ options: {api: 'metrics'},
505
+ },
506
+ {
507
+ name: 'the unified telemetry service',
508
+ options: {service: 'unifiedTelemetry'},
509
+ },
510
+ ].forEach(function addExclusionTest({name, options}) {
511
+ it(`does not track ${name}`, function () {
512
+ assert.isFalse(shouldTrackNetworkRequest(options));
513
+ });
514
+ });
515
+
516
+ [
517
+ {
518
+ description: 'AbortError as aborted',
519
+ reason: {name: 'AbortError'},
520
+ errorType: 'aborted',
521
+ },
522
+ {
523
+ description: 'HTTP 408 as timeout',
524
+ reason: {name: 'RequestTimeout', statusCode: 408},
525
+ errorType: 'timeout',
526
+ },
527
+ {
528
+ description: 'a wrapped ETIMEDOUT transport error as timeout',
529
+ reason: {
530
+ name: 'NetworkOrCORSError',
531
+ statusCode: 0,
532
+ body: {code: 'ETIMEDOUT'},
533
+ },
534
+ errorType: 'timeout',
535
+ },
536
+ {
537
+ description: 'elapsed request timeout metadata as timeout',
538
+ options: {
539
+ service: 'hydra',
540
+ timeout: 300,
541
+ $timings: {networkStart: 100, networkEnd: 400},
542
+ },
543
+ reason: {name: 'NetworkOrCORSError', statusCode: 0},
544
+ errorType: 'timeout',
545
+ },
546
+ {
547
+ description: 'HTTP 429 as rate_limited',
548
+ reason: {statusCode: 429},
549
+ errorType: 'rate_limited',
550
+ },
551
+ {
552
+ description: 'HTTP 404 as client_error',
553
+ reason: {statusCode: 404},
554
+ errorType: 'client_error',
555
+ },
556
+ {
557
+ description: 'HTTP 502 as server_error',
558
+ reason: {statusCode: 502},
559
+ errorType: 'server_error',
560
+ },
561
+ {
562
+ description: 'a status-zero transport failure as network_error',
563
+ reason: {name: 'NetworkOrCORSError', statusCode: 0},
564
+ errorType: 'network_error',
565
+ },
566
+ ].forEach(function addErrorTypeTest({
567
+ description,
568
+ options = {service: 'hydra'},
569
+ reason,
570
+ errorType,
571
+ }) {
572
+ it(`classifies ${description}`, function () {
573
+ const errorMetric = createNetworkErrorMetric(options, reason);
574
+
575
+ assert.equal(errorMetric.errorType, errorType);
576
+ });
577
+ });
578
+
579
+ it('uses a sanitized URI host and endpoint for direct-URI requests', function () {
580
+ const errorMetric = createNetworkErrorMetric(
581
+ {
582
+ method: 'CUSTOM',
583
+ uri: 'https://user:password@customer.example.test/v1/rooms/65f81b6e-19dc-4a99-9175-59e9b34a5d42?secret=true#private',
584
+ },
585
+ {}
586
+ );
587
+
588
+ assert.equal(errorMetric.host, 'customer.example.test');
589
+ assert.equal(errorMetric.endPoint, 'v1/rooms/:id');
590
+ assert.equal(errorMetric.method, 'CUSTOM');
591
+ });
592
+
593
+ it('uses a string response body as the error message', function () {
594
+ const errorMetric = createNetworkErrorMetric(
595
+ {service: 'hydra'},
596
+ {body: 'The service is unavailable'}
597
+ );
598
+
599
+ assert.equal(errorMetric.errorMessage, 'The service is unavailable');
600
+ });
601
+
602
+ it('redacts identifiers and query parameters from resource endpoints', function () {
603
+ assert.equal(
604
+ getOperation('v1/rooms/65f81b6e-19dc-4a99-9175-59e9b34a5d42/messages?personId=secret'),
605
+ 'v1/rooms/:id/messages'
606
+ );
607
+ assert.equal(getOperation('search/orgid/12345/entities'), 'search/orgid/:id/entities');
608
+ assert.equal(getOperation('reports/a%2Fb/download'), 'reports/:id/download');
609
+ });
610
+ });