@webex/plugin-meetings 3.3.1-next.2 → 3.3.1-next.21

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 (56) hide show
  1. package/dist/breakouts/breakout.js +1 -1
  2. package/dist/breakouts/index.js +7 -2
  3. package/dist/breakouts/index.js.map +1 -1
  4. package/dist/interpretation/index.js +1 -1
  5. package/dist/interpretation/siLanguage.js +1 -1
  6. package/dist/media/MediaConnectionAwaiter.js +50 -13
  7. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  8. package/dist/mediaQualityMetrics/config.js +16 -6
  9. package/dist/mediaQualityMetrics/config.js.map +1 -1
  10. package/dist/meeting/connectionStateHandler.js +67 -0
  11. package/dist/meeting/connectionStateHandler.js.map +1 -0
  12. package/dist/meeting/index.js +98 -46
  13. package/dist/meeting/index.js.map +1 -1
  14. package/dist/metrics/constants.js +2 -1
  15. package/dist/metrics/constants.js.map +1 -1
  16. package/dist/metrics/index.js +57 -0
  17. package/dist/metrics/index.js.map +1 -1
  18. package/dist/reachability/clusterReachability.js +108 -53
  19. package/dist/reachability/clusterReachability.js.map +1 -1
  20. package/dist/reachability/index.js +415 -56
  21. package/dist/reachability/index.js.map +1 -1
  22. package/dist/statsAnalyzer/index.js +81 -27
  23. package/dist/statsAnalyzer/index.js.map +1 -1
  24. package/dist/statsAnalyzer/mqaUtil.js +36 -10
  25. package/dist/statsAnalyzer/mqaUtil.js.map +1 -1
  26. package/dist/types/media/MediaConnectionAwaiter.d.ts +18 -4
  27. package/dist/types/mediaQualityMetrics/config.d.ts +11 -0
  28. package/dist/types/meeting/connectionStateHandler.d.ts +30 -0
  29. package/dist/types/meeting/index.d.ts +2 -0
  30. package/dist/types/metrics/constants.d.ts +1 -0
  31. package/dist/types/metrics/index.d.ts +15 -0
  32. package/dist/types/reachability/clusterReachability.d.ts +31 -3
  33. package/dist/types/reachability/index.d.ts +93 -2
  34. package/dist/types/statsAnalyzer/index.d.ts +15 -6
  35. package/dist/types/statsAnalyzer/mqaUtil.d.ts +17 -4
  36. package/dist/webinar/index.js +1 -1
  37. package/package.json +23 -22
  38. package/src/breakouts/index.ts +7 -1
  39. package/src/media/MediaConnectionAwaiter.ts +66 -11
  40. package/src/mediaQualityMetrics/config.ts +14 -3
  41. package/src/meeting/connectionStateHandler.ts +65 -0
  42. package/src/meeting/index.ts +72 -14
  43. package/src/metrics/constants.ts +1 -0
  44. package/src/metrics/index.ts +44 -0
  45. package/src/reachability/clusterReachability.ts +86 -25
  46. package/src/reachability/index.ts +316 -27
  47. package/src/statsAnalyzer/index.ts +85 -24
  48. package/src/statsAnalyzer/mqaUtil.ts +55 -7
  49. package/test/unit/spec/breakouts/index.ts +51 -32
  50. package/test/unit/spec/media/MediaConnectionAwaiter.ts +90 -32
  51. package/test/unit/spec/meeting/connectionStateHandler.ts +102 -0
  52. package/test/unit/spec/meeting/index.js +158 -36
  53. package/test/unit/spec/metrics/index.js +126 -0
  54. package/test/unit/spec/reachability/clusterReachability.ts +116 -22
  55. package/test/unit/spec/reachability/index.ts +1153 -84
  56. package/test/unit/spec/stats-analyzer/index.js +647 -319
@@ -153,6 +153,7 @@ import RecordingController from '../recording-controller';
153
153
  import ControlsOptionsManager from '../controls-options-manager';
154
154
  import PermissionError from '../common/errors/permission';
155
155
  import {LocusMediaRequest} from './locusMediaRequest';
156
+ import {ConnectionStateHandler, ConnectionStateEvent} from './connectionStateHandler';
156
157
 
157
158
  const logRequest = (request: any, {logText = ''}) => {
158
159
  LoggerProxy.logger.info(`${logText} - sending request`);
@@ -680,6 +681,8 @@ export default class Meeting extends StatelessWebexPlugin {
680
681
  private sdpResponseTimer?: ReturnType<typeof setTimeout>;
681
682
  private hasMediaConnectionConnectedAtLeastOnce: boolean;
682
683
  private joinWithMediaRetryInfo?: {isRetry: boolean; prevJoinResponse?: any};
684
+ private connectionStateHandler?: ConnectionStateHandler;
685
+ private iceCandidateErrors: Map<string, number>;
683
686
 
684
687
  /**
685
688
  * @param {Object} attrs
@@ -1470,6 +1473,24 @@ export default class Meeting extends StatelessWebexPlugin {
1470
1473
  * @memberof Meeting
1471
1474
  */
1472
1475
  this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
1476
+
1477
+ /**
1478
+ * Connection state handler
1479
+ * @instance
1480
+ * @type {ConnectionStateHandler}
1481
+ * @private
1482
+ * @memberof Meeting
1483
+ */
1484
+ this.connectionStateHandler = undefined;
1485
+
1486
+ /**
1487
+ * ICE Candidates errors map
1488
+ * @instance
1489
+ * @type {Map<[number, string], number>}
1490
+ * @private
1491
+ * @memberof Meeting
1492
+ */
1493
+ this.iceCandidateErrors = new Map();
1473
1494
  }
1474
1495
 
1475
1496
  /**
@@ -5233,8 +5254,13 @@ export default class Meeting extends StatelessWebexPlugin {
5233
5254
 
5234
5255
  // @ts-ignore - Fix type
5235
5256
  if (this.webex.internal.llm.isConnected()) {
5236
- // @ts-ignore - Fix type
5237
- if (url === this.webex.internal.llm.getLocusUrl() && isJoined) {
5257
+ if (
5258
+ // @ts-ignore - Fix type
5259
+ url === this.webex.internal.llm.getLocusUrl() &&
5260
+ // @ts-ignore - Fix type
5261
+ datachannelUrl === this.webex.internal.llm.getDatachannelUrl() &&
5262
+ isJoined
5263
+ ) {
5238
5264
  return undefined;
5239
5265
  }
5240
5266
  // @ts-ignore - Fix type
@@ -5846,7 +5872,11 @@ export default class Meeting extends StatelessWebexPlugin {
5846
5872
  }
5847
5873
  });
5848
5874
 
5849
- this.mediaProperties.webrtcMediaConnection.on(Event.CONNECTION_STATE_CHANGED, (event) => {
5875
+ this.connectionStateHandler = new ConnectionStateHandler(
5876
+ this.mediaProperties.webrtcMediaConnection
5877
+ );
5878
+
5879
+ this.connectionStateHandler.on(ConnectionStateEvent.stateChanged, (event) => {
5850
5880
  const connectionFailed = () => {
5851
5881
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.CONNECTION_FAILURE, {
5852
5882
  correlation_id: this.correlationId,
@@ -5990,6 +6020,32 @@ export default class Meeting extends StatelessWebexPlugin {
5990
6020
  );
5991
6021
  }
5992
6022
  );
6023
+
6024
+ this.iceCandidateErrors.clear();
6025
+ this.mediaProperties.webrtcMediaConnection.on(Event.ICE_CANDIDATE_ERROR, (event) => {
6026
+ const {errorCode} = event.error;
6027
+ let {errorText} = event.error;
6028
+
6029
+ if (
6030
+ errorCode === 600 &&
6031
+ errorText === 'Address not associated with the desired network interface.'
6032
+ ) {
6033
+ return;
6034
+ }
6035
+
6036
+ if (errorText.endsWith('.')) {
6037
+ errorText = errorText.slice(0, -1);
6038
+ }
6039
+
6040
+ errorText = errorText.toLowerCase();
6041
+ errorText = errorText.replace(/ /g, '_');
6042
+
6043
+ const error = `${errorCode}_${errorText}`;
6044
+
6045
+ const count = this.iceCandidateErrors.get(error) || 0;
6046
+
6047
+ this.iceCandidateErrors.set(error, count + 1);
6048
+ });
5993
6049
  };
5994
6050
 
5995
6051
  /**
@@ -6264,6 +6320,8 @@ export default class Meeting extends StatelessWebexPlugin {
6264
6320
  try {
6265
6321
  await this.mediaProperties.waitForMediaConnectionConnected();
6266
6322
  } catch (error) {
6323
+ const {iceConnected} = error;
6324
+
6267
6325
  if (!this.hasMediaConnectionConnectedAtLeastOnce) {
6268
6326
  // Only send CA event for join flow if we haven't successfully connected media yet
6269
6327
  // @ts-ignore
@@ -6283,12 +6341,7 @@ export default class Meeting extends StatelessWebexPlugin {
6283
6341
  this.mediaProperties.webrtcMediaConnection?.mediaConnection?.pc
6284
6342
  ?.signalingState ||
6285
6343
  'unknown',
6286
- iceConnectionState:
6287
- this.mediaProperties.webrtcMediaConnection?.multistreamConnection?.pc?.pc
6288
- ?.iceConnectionState ||
6289
- this.mediaProperties.webrtcMediaConnection?.mediaConnection?.pc
6290
- ?.iceConnectionState ||
6291
- 'unknown',
6344
+ iceConnected,
6292
6345
  turnServerUsed: this.turnServerUsed,
6293
6346
  }),
6294
6347
  }
@@ -6317,12 +6370,13 @@ export default class Meeting extends StatelessWebexPlugin {
6317
6370
  if (this.config.stats.enableStatsAnalyzer) {
6318
6371
  // @ts-ignore - config coming from registerPlugin
6319
6372
  this.networkQualityMonitor = new NetworkQualityMonitor(this.config.stats);
6320
- this.statsAnalyzer = new StatsAnalyzer(
6373
+ this.statsAnalyzer = new StatsAnalyzer({
6321
6374
  // @ts-ignore - config coming from registerPlugin
6322
- this.config.stats,
6323
- (ssrc: number) => this.receiveSlotManager.findReceiveSlotBySsrc(ssrc),
6324
- this.networkQualityMonitor
6325
- );
6375
+ config: this.config.stats,
6376
+ receiveSlotCallback: (ssrc: number) => this.receiveSlotManager.findReceiveSlotBySsrc(ssrc),
6377
+ networkQualityMonitor: this.networkQualityMonitor,
6378
+ isMultistream: this.isMultistream,
6379
+ });
6326
6380
  this.setupStatsAnalyzerEventHandlers();
6327
6381
  this.networkQualityMonitor.on(
6328
6382
  EVENT_TRIGGERS.NETWORK_QUALITY,
@@ -6830,6 +6884,8 @@ export default class Meeting extends StatelessWebexPlugin {
6830
6884
  const {selectedCandidatePairChanges, numTransports} =
6831
6885
  await this.mediaProperties.getCurrentConnectionInfo();
6832
6886
 
6887
+ const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
6888
+
6833
6889
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_FAILURE, {
6834
6890
  correlation_id: this.correlationId,
6835
6891
  locus_id: this.locusUrl.split('/').pop(),
@@ -6859,6 +6915,7 @@ export default class Meeting extends StatelessWebexPlugin {
6859
6915
  this.mediaProperties.webrtcMediaConnection?.mediaConnection?.pc?.iceConnectionState ||
6860
6916
  'unknown',
6861
6917
  ...reachabilityMetrics,
6918
+ ...iceCandidateErrors,
6862
6919
  });
6863
6920
 
6864
6921
  await this.cleanUpOnAddMediaFailure();
@@ -7896,6 +7953,7 @@ export default class Meeting extends StatelessWebexPlugin {
7896
7953
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE, {
7897
7954
  correlationId: this.correlationId,
7898
7955
  muted,
7956
+ encoderImplementation: this.statsAnalyzer?.shareVideoEncoderImplementation,
7899
7957
  });
7900
7958
  };
7901
7959
 
@@ -69,6 +69,7 @@ const BEHAVIORAL_METRICS = {
69
69
  ROAP_OFFER_TO_ANSWER_LATENCY: 'js_sdk_roap_offer_to_answer_latency',
70
70
  ROAP_HTTP_RESPONSE_MISSING: 'js_sdk_roap_http_response_missing',
71
71
  TURN_DISCOVERY_REQUIRES_OK: 'js_sdk_turn_discovery_requires_ok',
72
+ REACHABILITY_COMPLETED: 'js_sdk_reachability_completed',
72
73
  };
73
74
 
74
75
  export {BEHAVIORAL_METRICS as default};
@@ -65,6 +65,50 @@ class Metrics {
65
65
  tags: metricTags,
66
66
  });
67
67
  }
68
+
69
+ /**
70
+ * Flattens an object into one that has no nested properties. Each level of nesting is represented
71
+ * by "_" in the flattened object property names.
72
+ * This function is needed, because Amplitude doesn't allow passing nested objects as metricFields.
73
+ * Use this function for metricFields before calling sendBehavioralMetric() if you want to send
74
+ * nested objects in your metrics.
75
+ *
76
+ * If the function is called with a literal, it returns an object with a single property "value"
77
+ * and the literal value in it.
78
+ *
79
+ * @param {any} payload object you want to flatten
80
+ * @param {string} prefix string prefix prepended to any property names in flatten object
81
+ * @returns {Object}
82
+ */
83
+ prepareMetricFields(payload: any = {}, prefix = '') {
84
+ let output = {};
85
+
86
+ if (Array.isArray(payload)) {
87
+ payload.forEach((item, index) => {
88
+ const propName = prefix.length > 0 ? `${prefix}_${index}` : `${index}`;
89
+
90
+ output = {...output, ...this.prepareMetricFields(item, propName)};
91
+ });
92
+
93
+ return output;
94
+ }
95
+
96
+ if (typeof payload !== 'object' || payload === null) {
97
+ if (prefix.length > 0) {
98
+ return {[prefix]: payload};
99
+ }
100
+
101
+ return {value: payload};
102
+ }
103
+
104
+ Object.entries(payload).forEach(([key, value]) => {
105
+ const propName = prefix.length > 0 ? `${prefix}_${key}` : key;
106
+
107
+ output = {...output, ...this.prepareMetricFields(value, propName)};
108
+ });
109
+
110
+ return output;
111
+ }
68
112
  }
69
113
 
70
114
  // Export Metrics singleton ---------------------------------------------------
@@ -3,11 +3,9 @@ import {Defer} from '@webex/common';
3
3
  import LoggerProxy from '../common/logs/logger-proxy';
4
4
  import {ClusterNode} from './request';
5
5
  import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util';
6
+ import EventsScope from '../common/events/events-scope';
6
7
 
7
- import {ICE_GATHERING_STATE, CONNECTION_STATE} from '../constants';
8
-
9
- const DEFAULT_TIMEOUT = 3000;
10
- const VIDEO_MESH_TIMEOUT = 1000;
8
+ import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants';
11
9
 
12
10
  // result for a specific transport protocol (like udp or tcp)
13
11
  export type TransportResult = {
@@ -23,10 +21,32 @@ export type ClusterReachabilityResult = {
23
21
  xtls: TransportResult;
24
22
  };
25
23
 
24
+ // data for the Events.resultReady event
25
+ export type ResultEventData = {
26
+ protocol: 'udp' | 'tcp' | 'xtls';
27
+ result: 'reachable' | 'unreachable' | 'untested';
28
+ latencyInMilliseconds: number; // amount of time it took to get the ICE candidate
29
+ clientMediaIPs?: string[];
30
+ };
31
+
32
+ // data for the Events.clientMediaIpsUpdated event
33
+ export type ClientMediaIpsUpdatedEventData = {
34
+ protocol: 'udp' | 'tcp' | 'xtls';
35
+ clientMediaIPs: string[];
36
+ };
37
+
38
+ export const Events = {
39
+ resultReady: 'resultReady', // emitted when a cluster is reached successfully using specific protocol
40
+ clientMediaIpsUpdated: 'clientMediaIpsUpdated', // emitted when more public IPs are found after resultReady was already sent for a given protocol
41
+ } as const;
42
+
43
+ export type Events = Enum<typeof Events>;
44
+
26
45
  /**
27
46
  * A class that handles reachability checks for a single cluster.
47
+ * It emits events from Events enum
28
48
  */
29
- export class ClusterReachability {
49
+ export class ClusterReachability extends EventsScope {
30
50
  private numUdpUrls: number;
31
51
  private numTcpUrls: number;
32
52
  private numXTlsUrls: number;
@@ -43,6 +63,7 @@ export class ClusterReachability {
43
63
  * @param {ClusterNode} clusterInfo information about the media cluster
44
64
  */
45
65
  constructor(name: string, clusterInfo: ClusterNode) {
66
+ super();
46
67
  this.name = name;
47
68
  this.isVideoMesh = clusterInfo.isVideoMesh;
48
69
  this.numUdpUrls = clusterInfo.udp.length;
@@ -162,23 +183,54 @@ export class ClusterReachability {
162
183
  this.defer.resolve();
163
184
  }
164
185
 
186
+ /**
187
+ * Aborts the cluster reachability checks by closing the peer connection
188
+ *
189
+ * @returns {void}
190
+ */
191
+ public abort() {
192
+ const {CLOSED} = CONNECTION_STATE;
193
+
194
+ if (this.pc.connectionState !== CLOSED) {
195
+ this.closePeerConnection();
196
+ this.finishReachabilityCheck();
197
+ }
198
+ }
199
+
165
200
  /**
166
201
  * Adds public IP (client media IPs)
167
202
  * @param {string} protocol
168
203
  * @param {string} publicIP
169
204
  * @returns {void}
170
205
  */
171
- private addPublicIP(protocol: 'udp' | 'tcp', publicIP?: string | null) {
206
+ private addPublicIP(protocol: 'udp' | 'tcp' | 'xtls', publicIP?: string | null) {
172
207
  const result = this.result[protocol];
173
208
 
174
209
  if (publicIP) {
210
+ let ipAdded = false;
211
+
175
212
  if (result.clientMediaIPs) {
176
213
  if (!result.clientMediaIPs.includes(publicIP)) {
177
214
  result.clientMediaIPs.push(publicIP);
215
+ ipAdded = true;
178
216
  }
179
217
  } else {
180
218
  result.clientMediaIPs = [publicIP];
219
+ ipAdded = true;
181
220
  }
221
+
222
+ if (ipAdded)
223
+ this.emit(
224
+ {
225
+ file: 'clusterReachability',
226
+ function: 'addPublicIP',
227
+ },
228
+ Events.clientMediaIpsUpdated,
229
+ {
230
+ protocol,
231
+ clientMediaIPs: result.clientMediaIPs,
232
+ }
233
+ );
182
234
  }
183
235
  }
184
236
 
@@ -211,22 +263,43 @@ export class ClusterReachability {
211
263
  }
212
264
 
213
265
  /**
214
- * Stores the latency in the result for the given protocol and marks it as reachable
266
+ * Saves the latency in the result for the given protocol and marks it as reachable,
267
+ * emits the "resultReady" event if this is the first result for that protocol,
268
+ * emits the "clientMediaIpsUpdated" event if we already had a result and only found
269
+ * a new client IP
215
270
  *
216
271
  * @param {string} protocol
217
272
  * @param {number} latency
273
+ * @param {string|null} [publicIp]
218
274
  * @returns {void}
219
275
  */
220
- private storeLatencyResult(protocol: 'udp' | 'tcp' | 'xtls', latency: number) {
276
+ private saveResult(protocol: 'udp' | 'tcp' | 'xtls', latency: number, publicIp?: string | null) {
221
277
  const result = this.result[protocol];
222
278
 
223
279
  if (result.latencyInMilliseconds === undefined) {
224
280
  LoggerProxy.logger.log(
225
281
  // @ts-ignore
226
- `Reachability:index#storeLatencyResult --> Successfully reached ${this.name} over ${protocol}: ${latency}ms`
282
+ `Reachability:index#saveResult --> Successfully reached ${this.name} over ${protocol}: ${latency}ms`
227
283
  );
228
284
  result.latencyInMilliseconds = latency;
229
285
  result.result = 'reachable';
286
+ if (publicIp) {
287
+ result.clientMediaIPs = [publicIp];
288
+ }
289
+
290
+ this.emit(
291
+ {
292
+ file: 'clusterReachability',
293
+ function: 'saveResult',
294
+ },
295
+ Events.resultReady,
296
+ {
297
+ protocol,
298
+ ...result,
299
+ }
300
+ );
301
+ } else {
302
+ this.addPublicIP(protocol, publicIp);
230
303
  }
231
304
  }
232
305
 
@@ -243,15 +316,16 @@ export class ClusterReachability {
243
316
  RELAY: 'relay',
244
317
  };
245
318
 
319
+ const latencyInMilliseconds = this.getElapsedTime();
320
+
246
321
  if (e.candidate) {
247
322
  if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
248
- this.storeLatencyResult('udp', this.getElapsedTime());
249
- this.addPublicIP('udp', e.candidate.address);
323
+ this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
250
324
  }
251
325
 
252
326
  if (e.candidate.type === CANDIDATE_TYPES.RELAY) {
253
327
  const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp';
254
- this.storeLatencyResult(protocol, this.getElapsedTime());
328
+ this.saveResult(protocol, latencyInMilliseconds);
255
329
  // we don't add public IP for TCP, because in the case of relay candidates
256
330
  // e.candidate.address is the TURN server address, not the client's public IP
257
331
  }
@@ -314,22 +388,9 @@ export class ClusterReachability {
314
388
  * @returns {Promise} promise that's resolved once reachability checks for this cluster are completed or timeout is reached
315
389
  */
316
390
  private gatherIceCandidates() {
317
- const timeout = this.isVideoMesh ? VIDEO_MESH_TIMEOUT : DEFAULT_TIMEOUT;
318
-
319
391
  this.registerIceGatheringStateChangeListener();
320
392
  this.registerIceCandidateListener();
321
393
 
322
- // Set maximum timeout
323
- setTimeout(() => {
324
- const {CLOSED} = CONNECTION_STATE;
325
-
326
- // Close any open peerConnections
327
- if (this.pc.connectionState !== CLOSED) {
328
- this.closePeerConnection();
329
- this.finishReachabilityCheck();
330
- }
331
- }, timeout);
332
-
333
394
  return this.defer.promise;
334
395
  }
335
396
  }