@webex/internal-plugin-metrics 3.12.0-llmrefactor.1 → 3.12.0-llmrefactor.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/index.js.map +1 -1
  2. package/dist/metrics.js +1 -1
  3. package/dist/metrics.types.js.map +1 -1
  4. package/dist/new-metrics.js +44 -11
  5. package/dist/new-metrics.js.map +1 -1
  6. package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
  7. package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
  8. package/dist/privacy-and-security-permission-enricher/index.js +143 -0
  9. package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
  10. package/dist/privacy-and-security-permission-enricher/types.js +7 -0
  11. package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
  12. package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
  13. package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
  14. package/dist/types/index.d.ts +2 -2
  15. package/dist/types/metrics.types.d.ts +3 -0
  16. package/dist/types/new-metrics.d.ts +13 -1
  17. package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
  18. package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
  19. package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
  20. package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -0
  21. package/package.json +7 -7
  22. package/src/index.ts +2 -0
  23. package/src/metrics.types.ts +9 -0
  24. package/src/new-metrics.ts +41 -2
  25. package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
  26. package/src/privacy-and-security-permission-enricher/index.ts +142 -0
  27. package/src/privacy-and-security-permission-enricher/types.ts +21 -0
  28. package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
  29. package/test/unit/spec/new-metrics.ts +144 -48
  30. package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
@@ -0,0 +1,142 @@
1
+ import type {ClientEventPayload, PrivacyAndSecurityPermission} from '../metrics.types';
2
+ import {
3
+ CAMERA_AND_MICROPHONE_PERMISSION_EVENTS,
4
+ CONTENT_SHARE_PERMISSION_EVENTS,
5
+ FINAL_PERMISSION_EVENTS,
6
+ MEDIA_TX_PERMISSION_EVENTS,
7
+ NO_PERMISSION_ENRICHMENT,
8
+ } from './constants';
9
+ import type {
10
+ PermissionEnrichmentContext,
11
+ PermissionEnrichmentPolicy,
12
+ PermissionEnrichmentRule,
13
+ } from './types';
14
+ import {
15
+ getChangedPermission,
16
+ projectPrivacyAndSecurityPermission,
17
+ resolveContentShareResources,
18
+ resolveMediaResources,
19
+ } from './utils';
20
+
21
+ export const PERMISSION_ENRICHMENT_RULES = [
22
+ {
23
+ events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS,
24
+ resolve: () => ({resources: ['camera', 'microphone'], terminal: false}),
25
+ },
26
+ {
27
+ events: MEDIA_TX_PERMISSION_EVENTS,
28
+ resolve: (payload) => ({resources: resolveMediaResources(payload), terminal: false}),
29
+ },
30
+ {
31
+ events: CONTENT_SHARE_PERMISSION_EVENTS,
32
+ resolve: (payload) => ({
33
+ resources: resolveContentShareResources(payload),
34
+ terminal: false,
35
+ }),
36
+ },
37
+ {
38
+ events: FINAL_PERMISSION_EVENTS,
39
+ resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}),
40
+ },
41
+ ] satisfies readonly PermissionEnrichmentRule[];
42
+
43
+ const resolvePermissionEnrichmentPolicy = (
44
+ name: PermissionEnrichmentContext['name'],
45
+ payload?: ClientEventPayload
46
+ ): PermissionEnrichmentPolicy =>
47
+ PERMISSION_ENRICHMENT_RULES.find(({events}) => events.has(name))?.resolve(payload) ??
48
+ NO_PERMISSION_ENRICHMENT;
49
+
50
+ /**
51
+ * Enriches eligible client events with relevant browser permission changes.
52
+ */
53
+ export default class PrivacyAndSecurityPermissionEnricher {
54
+ private permission?: PrivacyAndSecurityPermission;
55
+
56
+ private lastReported = new Map<string, PrivacyAndSecurityPermission>();
57
+
58
+ private readonly onEnrichmentError: (error: unknown) => void;
59
+
60
+ /**
61
+ * Creates a permission enricher.
62
+ * @param {Function} onEnrichmentError permission enrichment error handler
63
+ */
64
+ constructor(onEnrichmentError: (error: unknown) => void) {
65
+ this.onEnrichmentError = onEnrichmentError;
66
+ }
67
+
68
+ /**
69
+ * Stores the latest normalized browser permission state.
70
+ * @param {PrivacyAndSecurityPermission} permission permission snapshot
71
+ * @returns {void}
72
+ */
73
+ public setPermission(permission: PrivacyAndSecurityPermission): void {
74
+ this.permission = projectPrivacyAndSecurityPermission(permission, [
75
+ 'camera',
76
+ 'microphone',
77
+ 'contentShare',
78
+ ]);
79
+ }
80
+
81
+ /**
82
+ * Returns the original payload or a copy enriched with relevant permission changes.
83
+ * @param {PermissionEnrichmentContext} context event context and permission history scope
84
+ * @returns {ClientEventPayload | undefined}
85
+ */
86
+ public enrich({
87
+ name,
88
+ payload,
89
+ scope,
90
+ }: PermissionEnrichmentContext): ClientEventPayload | undefined {
91
+ const policy = resolvePermissionEnrichmentPolicy(name, payload);
92
+
93
+ try {
94
+ if (payload?.privacyAndSecurityPermission !== undefined) {
95
+ // An explicitly supplied permission payload is authoritative for this event.
96
+ if (!policy.terminal) {
97
+ this.lastReported.set(scope, {
98
+ ...this.lastReported.get(scope),
99
+ ...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission),
100
+ });
101
+ }
102
+
103
+ return payload;
104
+ }
105
+
106
+ if (policy.resources.length === 0) {
107
+ return payload;
108
+ }
109
+
110
+ const projectedPermission = this.permission
111
+ ? projectPrivacyAndSecurityPermission(this.permission, policy.resources)
112
+ : undefined;
113
+
114
+ if (!projectedPermission) {
115
+ return payload;
116
+ }
117
+
118
+ if (policy.terminal) {
119
+ return {...payload, privacyAndSecurityPermission: projectedPermission};
120
+ }
121
+
122
+ const lastReported = this.lastReported.get(scope) ?? {};
123
+ const changedPermission = getChangedPermission(projectedPermission, lastReported);
124
+
125
+ if (!changedPermission) {
126
+ return payload;
127
+ }
128
+
129
+ this.lastReported.set(scope, {...lastReported, ...changedPermission});
130
+
131
+ return {...payload, privacyAndSecurityPermission: changedPermission};
132
+ } catch (error) {
133
+ this.onEnrichmentError(error);
134
+
135
+ return payload;
136
+ } finally {
137
+ if (policy.terminal) {
138
+ this.lastReported.delete(scope);
139
+ }
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,21 @@
1
+ import type {
2
+ ClientEvent,
3
+ ClientEventPayload,
4
+ PrivacyAndSecurityPermissionResource,
5
+ } from '../metrics.types';
6
+
7
+ export type PermissionEnrichmentPolicy = {
8
+ resources: readonly PrivacyAndSecurityPermissionResource[];
9
+ terminal: boolean;
10
+ };
11
+
12
+ export type PermissionEnrichmentRule = {
13
+ events: ReadonlySet<ClientEvent['name']>;
14
+ resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy;
15
+ };
16
+
17
+ export type PermissionEnrichmentContext = {
18
+ name: ClientEvent['name'];
19
+ payload?: ClientEventPayload;
20
+ scope: string;
21
+ };
@@ -0,0 +1,82 @@
1
+ import {isEqual} from 'lodash';
2
+
3
+ import type {
4
+ ClientEventPayload,
5
+ PrivacyAndSecurityPermission,
6
+ PrivacyAndSecurityPermissionResource,
7
+ } from '../metrics.types';
8
+
9
+ export const resolveMediaResources = (
10
+ payload?: ClientEventPayload
11
+ ): PrivacyAndSecurityPermissionResource[] => {
12
+ switch (payload?.mediaType) {
13
+ case 'audio':
14
+ return ['microphone'];
15
+ case 'video':
16
+ return ['camera'];
17
+ case 'share':
18
+ return ['contentShare'];
19
+ default:
20
+ return [];
21
+ }
22
+ };
23
+
24
+ export const resolveContentShareResources = (
25
+ payload?: ClientEventPayload
26
+ ): PrivacyAndSecurityPermissionResource[] => {
27
+ if (payload?.mediaType !== 'share') {
28
+ return [];
29
+ }
30
+
31
+ return ['contentShare'];
32
+ };
33
+
34
+ const copyPermissionState = (
35
+ source: PrivacyAndSecurityPermission,
36
+ target: PrivacyAndSecurityPermission,
37
+ resource: PrivacyAndSecurityPermissionResource
38
+ ): void => {
39
+ switch (resource) {
40
+ case 'camera':
41
+ if (source.camera) {
42
+ target.camera = {...source.camera};
43
+ }
44
+ break;
45
+ case 'microphone':
46
+ if (source.microphone) {
47
+ target.microphone = {...source.microphone};
48
+ }
49
+ break;
50
+ case 'contentShare':
51
+ if (source.contentShare) {
52
+ target.contentShare = {...source.contentShare};
53
+ }
54
+ break;
55
+ default:
56
+ break;
57
+ }
58
+ };
59
+
60
+ export const projectPrivacyAndSecurityPermission = (
61
+ permission: PrivacyAndSecurityPermission,
62
+ resources: readonly PrivacyAndSecurityPermissionResource[]
63
+ ): PrivacyAndSecurityPermission | undefined => {
64
+ const projectedPermission: PrivacyAndSecurityPermission = {};
65
+
66
+ resources.forEach((resource) => {
67
+ copyPermissionState(permission, projectedPermission, resource);
68
+ });
69
+
70
+ return Object.keys(projectedPermission).length > 0 ? projectedPermission : undefined;
71
+ };
72
+
73
+ export const getChangedPermission = (
74
+ current: PrivacyAndSecurityPermission,
75
+ previous: PrivacyAndSecurityPermission
76
+ ): PrivacyAndSecurityPermission | undefined => {
77
+ const changedResources = (Object.keys(current) as PrivacyAndSecurityPermissionResource[]).filter(
78
+ (resource) => !isEqual(current[resource], previous[resource])
79
+ );
80
+
81
+ return projectPrivacyAndSecurityPermission(current, changedResources);
82
+ };
@@ -9,19 +9,23 @@ import MockWebex from '@webex/test-helper-mock-webex';
9
9
  import sinon from 'sinon';
10
10
 
11
11
  describe('internal-plugin-metrics', () => {
12
-
13
- const mockWebex = () => new MockWebex({
14
- children: {
15
- newMetrics: NewMetrics,
16
- },
17
- meetings: {
18
- },
19
- request: sinon.stub().resolves({}),
20
- logger: {
21
- log: sinon.stub(),
22
- error: sinon.stub(),
23
- }
24
- });
12
+ const mockWebex = () =>
13
+ new MockWebex({
14
+ children: {
15
+ newMetrics: NewMetrics,
16
+ },
17
+ meetings: {
18
+ getBasicMeetingInformation: sinon.stub().callsFake((meetingId) => ({
19
+ id: meetingId,
20
+ correlationId: `correlation-${meetingId}`,
21
+ })),
22
+ },
23
+ request: sinon.stub().resolves({}),
24
+ logger: {
25
+ log: sinon.stub(),
26
+ error: sinon.stub(),
27
+ },
28
+ });
25
29
 
26
30
  describe('check submitClientEvent, submitFeatureEvent when webex is not ready', () => {
27
31
  let webex;
@@ -50,12 +54,14 @@ describe('internal-plugin-metrics', () => {
50
54
  payload: {
51
55
  meetingSummaryInfo: {
52
56
  featureName: 'syncSystemMuteStatus',
53
- featureActions: [{
54
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
55
- actionId: '14200',
56
- isInitialValue: false,
57
- clickCount: '1'
58
- }]
57
+ featureActions: [
58
+ {
59
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
60
+ actionId: '14200',
61
+ isInitialValue: false,
62
+ clickCount: '1',
63
+ },
64
+ ],
59
65
  },
60
66
  },
61
67
  });
@@ -68,7 +74,6 @@ describe('internal-plugin-metrics', () => {
68
74
 
69
75
  describe('new-metrics contstructor', () => {
70
76
  it('checks callDiagnosticLatencies is defined before ready emit', () => {
71
-
72
77
  const webex = mockWebex();
73
78
 
74
79
  assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies);
@@ -124,7 +129,7 @@ describe('internal-plugin-metrics', () => {
124
129
 
125
130
  afterEach(() => {
126
131
  sinon.restore();
127
- })
132
+ });
128
133
 
129
134
  it('lazy metrics backend initialization when checking if backend ready', () => {
130
135
  assert.isUndefined(webex.internal.newMetrics.behavioralMetrics);
@@ -135,10 +140,10 @@ describe('internal-plugin-metrics', () => {
135
140
  webex.internal.newMetrics.isReadyToSubmitOperationalEvents();
136
141
  assert.isDefined(webex.internal.newMetrics.operationalMetrics);
137
142
 
138
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
143
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
139
144
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
140
145
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
141
- })
146
+ });
142
147
 
143
148
  it('returns the automated user classification', () => {
144
149
  assert.strictEqual(
@@ -148,7 +153,7 @@ describe('internal-plugin-metrics', () => {
148
153
  });
149
154
 
150
155
  it('passes the table through to the business metrics', () => {
151
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
156
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
152
157
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
153
158
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
154
159
  webex.internal.newMetrics.businessMetrics.submitBusinessEvent = sinon.stub();
@@ -156,14 +161,14 @@ describe('internal-plugin-metrics', () => {
156
161
  name: 'foobar',
157
162
  payload: {},
158
163
  table: 'test',
159
- metadata: { foo: 'bar' },
164
+ metadata: {foo: 'bar'},
160
165
  });
161
166
 
162
167
  assert.calledWith(webex.internal.newMetrics.businessMetrics.submitBusinessEvent, {
163
168
  name: 'foobar',
164
169
  payload: {},
165
170
  table: 'test',
166
- metadata: { foo: 'bar' },
171
+ metadata: {foo: 'bar'},
167
172
  });
168
173
  });
169
174
 
@@ -187,6 +192,87 @@ describe('internal-plugin-metrics', () => {
187
192
  });
188
193
  });
189
194
 
195
+ describe('privacy and security permission enrichment', () => {
196
+ const permission = {
197
+ camera: {status: 'GRANTED' as const},
198
+ microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const},
199
+ contentShare: {status: 'REQUESTING' as const},
200
+ };
201
+
202
+ it('enriches an eligible client event through the public metrics API', () => {
203
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
204
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
205
+
206
+ const submittedPayload =
207
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]
208
+ .payload;
209
+
210
+ assert.deepEqual(submittedPayload.privacyAndSecurityPermission, {
211
+ camera: permission.camera,
212
+ microphone: permission.microphone,
213
+ });
214
+ });
215
+
216
+ it('uses the meeting correlation id to preserve history across identifier transitions', () => {
217
+ webex.meetings.getBasicMeetingInformation
218
+ .withArgs('meeting-1')
219
+ .returns({id: 'meeting-1', correlationId: 'correlation-1'});
220
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
221
+
222
+ webex.internal.newMetrics.submitClientEvent({
223
+ name: 'client.call.initiated',
224
+ options: {correlationId: 'correlation-1'},
225
+ });
226
+ webex.internal.newMetrics.submitClientEvent({
227
+ name: 'client.ice.end',
228
+ payload: {},
229
+ options: {meetingId: 'meeting-1'},
230
+ });
231
+
232
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
233
+
234
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
235
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
236
+ });
237
+
238
+ it('uses the default scope when no correlation id can be resolved', () => {
239
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
240
+
241
+ webex.internal.newMetrics.submitClientEvent({
242
+ name: 'client.call.initiated',
243
+ options: {sessionCorrelationId: 'session-1'},
244
+ });
245
+ webex.internal.newMetrics.submitClientEvent({
246
+ name: 'client.ice.end',
247
+ payload: {},
248
+ options: {sessionCorrelationId: 'session-2'},
249
+ });
250
+
251
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
252
+
253
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
254
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
255
+ });
256
+
257
+ it('captures the permission snapshot before a delayed event is queued', () => {
258
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
259
+ webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true});
260
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
261
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission({
262
+ camera: {status: 'DENIED' as const},
263
+ });
264
+
265
+ const submission =
266
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0];
267
+
268
+ assert.isTrue(submission.delaySubmitEvent);
269
+ assert.deepEqual(submission.payload.privacyAndSecurityPermission, {
270
+ camera: permission.camera,
271
+ microphone: permission.microphone,
272
+ });
273
+ });
274
+ });
275
+
190
276
  it('submits feature Event successfully', () => {
191
277
  webex.internal.newMetrics.submitFeatureEvent({
192
278
  name: 'client.feature.meeting.summary',
@@ -196,12 +282,14 @@ describe('internal-plugin-metrics', () => {
196
282
  payload: {
197
283
  meetingSummaryInfo: {
198
284
  featureName: 'syncSystemMuteStatus',
199
- featureActions: [{
200
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
201
- actionId: '14200',
202
- isInitialValue: false,
203
- clickCount: '1'
204
- }]
285
+ featureActions: [
286
+ {
287
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
288
+ actionId: '14200',
289
+ isInitialValue: false,
290
+ clickCount: '1',
291
+ },
292
+ ],
205
293
  },
206
294
  },
207
295
  });
@@ -215,12 +303,14 @@ describe('internal-plugin-metrics', () => {
215
303
  payload: {
216
304
  meetingSummaryInfo: {
217
305
  featureName: 'syncSystemMuteStatus',
218
- featureActions: [{
219
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
220
- actionId: '14200',
221
- isInitialValue: false,
222
- clickCount: '1'
223
- }]
306
+ featureActions: [
307
+ {
308
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
309
+ actionId: '14200',
310
+ isInitialValue: false,
311
+ clickCount: '1',
312
+ },
313
+ ],
224
314
  },
225
315
  },
226
316
  options: {meetingId: '123'},
@@ -281,9 +371,9 @@ describe('internal-plugin-metrics', () => {
281
371
  method: 'POST',
282
372
  api: 'metrics',
283
373
  resource: 'clientmetrics',
284
- headers: { 'x-prelogin-userid': 'my-id' },
374
+ headers: {'x-prelogin-userid': 'my-id'},
285
375
  body: {},
286
- qs: { alias: true },
376
+ qs: {alias: true},
287
377
  });
288
378
  assert.calledWith(
289
379
  webex.logger.log,
@@ -292,8 +382,8 @@ describe('internal-plugin-metrics', () => {
292
382
  });
293
383
 
294
384
  it('handles failed request correctly', async () => {
295
- webex.request.rejects(new Error("test error"));
296
- sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError')
385
+ webex.request.rejects(new Error('test error'));
386
+ sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError');
297
387
  try {
298
388
  await webex.internal.newMetrics.clientMetricsAliasUser({event: 'test'}, 'my-id');
299
389
  } catch (err) {
@@ -375,19 +465,26 @@ describe('internal-plugin-metrics', () => {
375
465
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true);
376
466
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {});
377
467
 
378
- webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}});
468
+ webex.internal.newMetrics.setDelaySubmitClientEvents({
469
+ shouldDelay: false,
470
+ overrides: {foo: 'bar'},
471
+ });
379
472
 
380
- assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents);
381
- assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'});
473
+ assert.calledOnce(
474
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents
475
+ );
476
+ assert.calledWith(
477
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents,
478
+ {foo: 'bar'}
479
+ );
382
480
 
383
481
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false);
384
482
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'});
385
483
  });
386
484
 
387
485
  it('should not fail when called before webex is ready', () => {
388
-
389
486
  // Create mock
390
- webex = mockWebex()
487
+ webex = mockWebex();
391
488
 
392
489
  webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
393
490
  webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub();
@@ -402,7 +499,6 @@ describe('internal-plugin-metrics', () => {
402
499
  webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false});
403
500
  // Webex is ready
404
501
  webex.emit('ready');
405
-
406
502
  });
407
503
  });
408
504
  });