@stream-io/video-client 1.52.0 → 1.53.0

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 (52) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.browser.es.js +796 -51
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +796 -50
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +796 -51
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +5 -1
  9. package/dist/src/StreamVideoClient.d.ts +2 -0
  10. package/dist/src/coordinator/connection/client.d.ts +1 -0
  11. package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
  12. package/dist/src/errors/index.d.ts +1 -0
  13. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  15. package/dist/src/reporting/index.d.ts +1 -0
  16. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  17. package/dist/src/rtc/types.d.ts +24 -1
  18. package/dist/src/types.d.ts +5 -0
  19. package/package.json +1 -1
  20. package/src/Call.ts +184 -60
  21. package/src/StreamSfuClient.ts +3 -3
  22. package/src/StreamVideoClient.ts +18 -3
  23. package/src/__tests__/Call.autodrop.test.ts +4 -1
  24. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  25. package/src/__tests__/Call.publishing.test.ts +4 -1
  26. package/src/__tests__/Call.test.ts +23 -0
  27. package/src/coordinator/connection/client.ts +5 -0
  28. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  29. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  30. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  31. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  32. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
  33. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  34. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  35. package/src/errors/SfuTimeoutError.ts +7 -0
  36. package/src/errors/index.ts +1 -0
  37. package/src/events/__tests__/call.test.ts +2 -0
  38. package/src/events/__tests__/mutes.test.ts +4 -1
  39. package/src/events/call.ts +8 -0
  40. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  41. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  42. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  43. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  44. package/src/helpers/firstVideoFrame.ts +38 -0
  45. package/src/reporting/ClientEventReporter.ts +859 -0
  46. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  47. package/src/reporting/index.ts +1 -0
  48. package/src/rtc/BasePeerConnection.ts +30 -0
  49. package/src/rtc/Subscriber.ts +1 -0
  50. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  51. package/src/rtc/types.ts +34 -0
  52. package/src/types.ts +6 -0
package/dist/index.cjs.js CHANGED
@@ -6660,7 +6660,7 @@ const getSdkVersion = (sdk) => {
6660
6660
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6661
6661
  };
6662
6662
 
6663
- const version = "1.52.0";
6663
+ const version = "1.53.0";
6664
6664
  const [major, minor, patch] = version.split('.');
6665
6665
  let sdkInfo = {
6666
6666
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7754,7 +7754,7 @@ class BasePeerConnection {
7754
7754
  /**
7755
7755
  * Constructs a new `BasePeerConnection` instance.
7756
7756
  */
7757
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
7757
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
7758
7758
  this.iceHasEverConnected = false;
7759
7759
  this.isIceRestarting = false;
7760
7760
  this.isDisposed = false;
@@ -7908,6 +7908,10 @@ class BasePeerConnection {
7908
7908
  this.onConnectionStateChange = async () => {
7909
7909
  const state = this.pc.connectionState;
7910
7910
  this.logger.debug(`Connection state changed`, state);
7911
+ this.fireOnPeerConnectionStateChange({
7912
+ stateType: 'peerConnection',
7913
+ state,
7914
+ });
7911
7915
  if (this.tracer && (state === 'connected' || state === 'failed')) {
7912
7916
  try {
7913
7917
  const stats = await this.stats.get();
@@ -7930,8 +7934,20 @@ class BasePeerConnection {
7930
7934
  this.onIceConnectionStateChange = () => {
7931
7935
  const state = this.pc.iceConnectionState;
7932
7936
  this.logger.debug(`ICE connection state changed`, state);
7937
+ this.fireOnPeerConnectionStateChange({ stateType: 'ice', state });
7933
7938
  this.handleConnectionStateUpdate(state);
7934
7939
  };
7940
+ this.fireOnPeerConnectionStateChange = (event) => {
7941
+ try {
7942
+ this.onPeerConnectionStateChange?.({
7943
+ peerType: this.peerType,
7944
+ ...event,
7945
+ });
7946
+ }
7947
+ catch (err) {
7948
+ this.logger.warn('onPeerConnectionStateChange listener threw', err);
7949
+ }
7950
+ };
7935
7951
  this.handleConnectionStateUpdate = (state) => {
7936
7952
  const { callingState } = this.state;
7937
7953
  if (callingState === exports.CallingState.OFFLINE)
@@ -8046,6 +8062,8 @@ class BasePeerConnection {
8046
8062
  this.tag = tag;
8047
8063
  this.onReconnectionNeeded = onReconnectionNeeded;
8048
8064
  this.onIceConnected = onIceConnected;
8065
+ this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8066
+ this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8049
8067
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
8050
8068
  this.pc = this.createPeerConnection(connectionConfig);
8051
8069
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType, statsTimestampDriftThresholdMs);
@@ -8068,6 +8086,8 @@ class BasePeerConnection {
8068
8086
  this.preConnectStuckTimeout = undefined;
8069
8087
  this.onReconnectionNeeded = undefined;
8070
8088
  this.onIceConnected = undefined;
8089
+ this.onPeerConnectionStateChange = undefined;
8090
+ this.onRemoteTrackUnmute = undefined;
8071
8091
  this.isDisposed = true;
8072
8092
  this.detachEventHandlers();
8073
8093
  this.pc.close();
@@ -9057,6 +9077,7 @@ class Subscriber extends BasePeerConnection {
9057
9077
  track.addEventListener('unmute', () => {
9058
9078
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
9059
9079
  this.setRemoteTrackInterrupted(trackId, trackType, false);
9080
+ this.onRemoteTrackUnmute?.(trackType, track.id);
9060
9081
  });
9061
9082
  track.addEventListener('ended', () => {
9062
9083
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
@@ -9296,6 +9317,15 @@ class SfuJoinError extends Error {
9296
9317
  }
9297
9318
  }
9298
9319
 
9320
+ /**
9321
+ * An error thrown when a client-side SFU deadline (e.g., waiting for the
9322
+ * signaling WS to open or for the `joinResponse` to arrive) fires before
9323
+ * the awaited operation resolves. Allows consumers (e.g., the client event
9324
+ * reporter) to classify timeouts without relying on message wording.
9325
+ */
9326
+ class SfuTimeoutError extends Error {
9327
+ }
9328
+
9299
9329
  /**
9300
9330
  * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
9301
9331
  * to the underlying promise. The handler marks the rejection path as handled
@@ -9401,7 +9431,7 @@ class StreamSfuClient {
9401
9431
  timeoutId = setTimeout(() => {
9402
9432
  const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
9403
9433
  this.tracer?.trace('signal.timeout', message);
9404
- reject(new Error(message));
9434
+ reject(new SfuTimeoutError(message));
9405
9435
  }, this.joinResponseTimeout);
9406
9436
  }),
9407
9437
  ]));
@@ -9571,7 +9601,7 @@ class StreamSfuClient {
9571
9601
  cleanupJoinSubscriptions();
9572
9602
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
9573
9603
  this.tracer?.trace('joinRequestTimeout', message);
9574
- current.reject(new Error(message));
9604
+ current.reject(new SfuTimeoutError(message));
9575
9605
  }, this.joinResponseTimeout);
9576
9606
  const joinRequest = SfuRequest.create({
9577
9607
  requestPayload: {
@@ -9788,6 +9818,10 @@ const watchCallEnded = (call) => {
9788
9818
  const { callingState } = call.state;
9789
9819
  if (callingState !== exports.CallingState.IDLE &&
9790
9820
  callingState !== exports.CallingState.LEFT) {
9821
+ call.clientEventReporter.abort(call.cid, {
9822
+ code: 'BACKEND_LEAVE',
9823
+ reason: 'call.ended event received',
9824
+ });
9791
9825
  call
9792
9826
  .leave({ message: 'call.ended event received', reject: false })
9793
9827
  .catch((err) => {
@@ -9817,6 +9851,10 @@ const watchSfuCallEnded = (call) => {
9817
9851
  call.state.setEndedAt(new Date());
9818
9852
  const reason = CallEndedReason[e.reason];
9819
9853
  globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
9854
+ call.clientEventReporter.abort(call.cid, {
9855
+ code: 'BACKEND_LEAVE',
9856
+ reason: `callEnded received: ${reason}`,
9857
+ });
9820
9858
  await call.leave({ message: `callEnded received: ${reason}` });
9821
9859
  }
9822
9860
  catch (err) {
@@ -10969,6 +11007,40 @@ class DynascaleManager {
10969
11007
  }
10970
11008
  }
10971
11009
 
11010
+ /**
11011
+ * Invokes `onFirstFrame` once when the video element renders a frame.
11012
+ *
11013
+ * Uses `requestVideoFrameCallback` when available, falling back to `loadeddata`
11014
+ * for browsers that don't support it.
11015
+ */
11016
+ const createFirstVideoFrameDetector = (videoElement, onFirstFrame) => {
11017
+ let done = false;
11018
+ const notify = () => {
11019
+ if (done)
11020
+ return;
11021
+ done = true;
11022
+ onFirstFrame();
11023
+ };
11024
+ if (typeof videoElement.requestVideoFrameCallback === 'function') {
11025
+ const handle = videoElement.requestVideoFrameCallback(notify);
11026
+ return () => {
11027
+ done = true;
11028
+ videoElement.cancelVideoFrameCallback(handle);
11029
+ };
11030
+ }
11031
+ if (videoElement.readyState >= videoElement.HAVE_CURRENT_DATA) {
11032
+ queueMicrotask(notify);
11033
+ return () => {
11034
+ done = true;
11035
+ };
11036
+ }
11037
+ videoElement.addEventListener('loadeddata', notify, { once: true });
11038
+ return () => {
11039
+ done = true;
11040
+ videoElement.removeEventListener('loadeddata', notify);
11041
+ };
11042
+ };
11043
+
10972
11044
  const DEFAULT_THRESHOLD = 0.35;
10973
11045
  const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10974
11046
  videoTrack: exports.VisibilityState.UNKNOWN,
@@ -13812,7 +13884,7 @@ class Call {
13812
13884
  * Use the [`StreamVideoClient.call`](./StreamVideoClient.md/#call)
13813
13885
  * method to construct a `Call` instance.
13814
13886
  */
13815
- constructor({ type, id, streamClient, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13887
+ constructor({ type, id, streamClient, clientEventReporter, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13816
13888
  /**
13817
13889
  * The state of this call.
13818
13890
  */
@@ -14139,9 +14211,14 @@ class Call {
14139
14211
  this.sfuStatsReporter = undefined;
14140
14212
  this.lastStatsOptions = undefined;
14141
14213
  await this.subscriber?.dispose();
14214
+ this.clientEventReporter.abort(this.cid, {
14215
+ code: 'CLIENT_ABORTED',
14216
+ reason: leaveReason,
14217
+ });
14142
14218
  this.subscriber = undefined;
14143
14219
  await this.publisher?.dispose();
14144
14220
  this.publisher = undefined;
14221
+ this.clientEventReporter.unregisterCall(this.cid);
14145
14222
  await this.sfuClient?.leaveAndClose(leaveReason);
14146
14223
  this.sfuClient = undefined;
14147
14224
  this.trackSubscriptionManager.setSfuClient(undefined);
@@ -14353,6 +14430,13 @@ class Call {
14353
14430
  await callingX.joinCall(this, this.clientStore.calls);
14354
14431
  }
14355
14432
  await this.setup();
14433
+ this.clientEventReporter.registerCall(this.cid, {
14434
+ callType: this.type,
14435
+ callId: this.id,
14436
+ getCallSessionId: () => this.state.session?.id ?? '',
14437
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
14438
+ getUserSessionId: () => this.sfuClient?.sessionId ?? '',
14439
+ });
14356
14440
  this.joinResponseTimeout = joinResponseTimeout;
14357
14441
  this.rpcRequestTimeout = rpcRequestTimeout;
14358
14442
  // we will count the number of join failures per SFU.
@@ -14362,39 +14446,42 @@ class Call {
14362
14446
  const joinData = data;
14363
14447
  maxJoinRetries = Math.max(maxJoinRetries, 1);
14364
14448
  try {
14365
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14366
- try {
14367
- this.logger.trace(`Joining call (${attempt})`, this.cid);
14368
- await this.doJoin(data);
14369
- delete joinData.migrating_from;
14370
- delete joinData.migrating_from_list;
14371
- break;
14372
- }
14373
- catch (err) {
14374
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14375
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14376
- (err instanceof SfuJoinError && err.unrecoverable)) {
14377
- // if the error is unrecoverable, we should not retry as that signals
14378
- // that connectivity is good, but the coordinator doesn't allow the user
14379
- // to join the call due to some reason (e.g., ended call, expired token...)
14380
- throw err;
14381
- }
14382
- // immediately switch to a different SFU in case of recoverable join error
14383
- const switchSfu = err instanceof SfuJoinError &&
14384
- SfuJoinError.isJoinErrorCode(err.errorEvent);
14385
- const sfuId = this.credentials?.server.edge_name || '';
14386
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14387
- sfuJoinFailures.set(sfuId, failures);
14388
- if (switchSfu || failures >= 2) {
14389
- joinData.migrating_from = sfuId;
14390
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14449
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
14450
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14451
+ try {
14452
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
14453
+ await this.doJoin(data);
14454
+ delete joinData.migrating_from;
14455
+ delete joinData.migrating_from_list;
14456
+ return;
14391
14457
  }
14392
- if (attempt === maxJoinRetries - 1) {
14393
- throw err;
14458
+ catch (err) {
14459
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14460
+ if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14461
+ (err instanceof SfuJoinError && err.unrecoverable)) {
14462
+ throw err;
14463
+ }
14464
+ const switchSfu = err instanceof SfuJoinError &&
14465
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
14466
+ const sfuId = this.credentials?.server.edge_name;
14467
+ if (sfuId) {
14468
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14469
+ sfuJoinFailures.set(sfuId, failures);
14470
+ if (switchSfu || failures >= 2) {
14471
+ joinData.migrating_from = sfuId;
14472
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14473
+ if (attempt < maxJoinRetries - 1) {
14474
+ this.clientEventReporter.startCorrelation(this.cid, 'first-attempt');
14475
+ }
14476
+ }
14477
+ }
14478
+ if (attempt === maxJoinRetries - 1) {
14479
+ throw err;
14480
+ }
14394
14481
  }
14482
+ await sleep(retryInterval(attempt));
14395
14483
  }
14396
- await sleep(retryInterval(attempt));
14397
- }
14484
+ });
14398
14485
  }
14399
14486
  catch (error) {
14400
14487
  callingX?.endCall(this, 'error');
@@ -14423,7 +14510,7 @@ class Call {
14423
14510
  performingMigration ||
14424
14511
  data?.migrating_from) {
14425
14512
  try {
14426
- const joinResponse = await this.doJoinRequest(data);
14513
+ const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
14427
14514
  this.credentials = joinResponse.credentials;
14428
14515
  statsOptions = joinResponse.stats_options;
14429
14516
  this.lastStatsOptions = statsOptions;
@@ -14481,9 +14568,11 @@ class Call {
14481
14568
  const preferredSubscribeOptions = !isReconnecting
14482
14569
  ? this.getPreferredSubscribeOptions()
14483
14570
  : [];
14571
+ const unifiedSessionId = this.unifiedSessionId;
14572
+ const capabilities = Array.from(this.clientCapabilities);
14484
14573
  try {
14485
- const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
14486
- unifiedSessionId: this.unifiedSessionId,
14574
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await this.clientEventReporter.track(this.cid, 'WSJoin', () => sfuClient.join({
14575
+ unifiedSessionId,
14487
14576
  subscriberSdp,
14488
14577
  publisherSdp,
14489
14578
  clientDetails,
@@ -14491,9 +14580,9 @@ class Call {
14491
14580
  reconnectDetails,
14492
14581
  preferredPublishOptions,
14493
14582
  preferredSubscribeOptions,
14494
- capabilities: Array.from(this.clientCapabilities),
14583
+ capabilities,
14495
14584
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
14496
- });
14585
+ }));
14497
14586
  this.currentPublishOptions = publishOptions;
14498
14587
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
14499
14588
  if (callState) {
@@ -14705,6 +14794,16 @@ class Call {
14705
14794
  // "ICE never connected" failure budget can be cleared.
14706
14795
  this.iceFailuresWithoutConnect = 0;
14707
14796
  },
14797
+ onPeerConnectionStateChange: (event) => {
14798
+ this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
14799
+ },
14800
+ onRemoteTrackUnmute: (trackType, trackId) => {
14801
+ const reportable = trackType === TrackType.AUDIO ||
14802
+ (isReactNative() && trackType === TrackType.VIDEO);
14803
+ if (!reportable)
14804
+ return;
14805
+ this.clientEventReporter.reportFirstFrame(this.cid, trackType, trackId);
14806
+ },
14708
14807
  };
14709
14808
  this.subscriber = new Subscriber(basePeerConnectionOptions);
14710
14809
  // anonymous users can't publish anything hence, there is no need
@@ -14981,7 +15080,10 @@ class Call {
14981
15080
  const reconnectStartTime = Date.now();
14982
15081
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
14983
15082
  this.state.setCallingState(exports.CallingState.RECONNECTING);
14984
- await this.doJoin(this.joinCallData);
15083
+ const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
15084
+ ? 'network-available'
15085
+ : 'full-rejoin';
15086
+ await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
14985
15087
  await this.restorePublishedTracks();
14986
15088
  this.restoreSubscribedTracks();
14987
15089
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
@@ -15005,11 +15107,11 @@ class Call {
15005
15107
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
15006
15108
  try {
15007
15109
  const currentSfu = currentSfuClient.edgeName;
15008
- await this.doJoin({
15110
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
15009
15111
  ...this.joinCallData,
15010
15112
  migrating_from: currentSfu,
15011
15113
  migrating_from_list: [currentSfu],
15012
- });
15114
+ }));
15013
15115
  }
15014
15116
  finally {
15015
15117
  // cleanup the migration_from field after the migration is complete or failed
@@ -15045,11 +15147,22 @@ class Call {
15045
15147
  this.registerReconnectHandlers = () => {
15046
15148
  // handles the legacy "goAway" event
15047
15149
  const unregisterGoAway = this.on('goAway', () => {
15150
+ this.clientEventReporter.captureWsError(this.cid, {
15151
+ code: 'SFU_GO_AWAY',
15152
+ reason: 'SFU goAway received during WS join',
15153
+ });
15048
15154
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
15049
15155
  });
15050
15156
  // handles the "error" event, through which the SFU can request a reconnect
15051
15157
  const unregisterOnError = this.on('error', (e) => {
15052
15158
  const { reconnectStrategy: strategy, error } = e;
15159
+ if (!SfuJoinError.isJoinErrorCode(e)) {
15160
+ const code = error?.code ? ErrorCode[error.code] : undefined;
15161
+ this.clientEventReporter.captureWsError(this.cid, {
15162
+ code: code ?? 'SFU_ERROR',
15163
+ reason: error?.message || 'SFU error during WS join',
15164
+ });
15165
+ }
15053
15166
  // SFU_FULL is a join error, and when emitted, although it specifies a
15054
15167
  // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
15055
15168
  // This is now handled separately in the `call.join()` method.
@@ -15744,7 +15857,9 @@ class Call {
15744
15857
  this.leave({
15745
15858
  reject: true,
15746
15859
  reason: 'timeout',
15747
- message: `ringing timeout - ${this.isCreatedByMe ? 'no one accepted' : `user didn't interact with incoming call screen`}`,
15860
+ message: `ringing timeout - ${this.isCreatedByMe
15861
+ ? 'no one accepted'
15862
+ : `user didn't interact with incoming call screen`}`,
15748
15863
  }).catch((err) => {
15749
15864
  this.logger.error('Failed to drop call', err);
15750
15865
  });
@@ -15950,15 +16065,36 @@ class Call {
15950
16065
  * @param trackType the kind of video.
15951
16066
  */
15952
16067
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
15953
- const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
15954
- if (!unbind)
16068
+ const unbindDynascale = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16069
+ const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(videoElement, sessionId, trackType);
16070
+ if (!unbindDynascale && !stopFirstFrameDetector)
15955
16071
  return;
16072
+ const unbind = () => {
16073
+ stopFirstFrameDetector?.();
16074
+ unbindDynascale?.();
16075
+ };
15956
16076
  this.leaveCallHooks.add(unbind);
15957
16077
  return () => {
15958
16078
  this.leaveCallHooks.delete(unbind);
15959
16079
  unbind();
15960
16080
  };
15961
16081
  };
16082
+ this.bindFirstVideoFrameDetector = (videoElement, sessionId, trackType) => {
16083
+ if (trackType !== 'videoTrack')
16084
+ return;
16085
+ return createFirstVideoFrameDetector(videoElement, () => {
16086
+ this.reportFirstRenderedVideoFrame(sessionId);
16087
+ });
16088
+ };
16089
+ this.reportFirstRenderedVideoFrame = (sessionId) => {
16090
+ const participant = this.state.findParticipantBySessionId(sessionId);
16091
+ if (participant?.isLocalParticipant)
16092
+ return;
16093
+ const trackId = participant?.videoStream?.getVideoTracks()[0]?.id;
16094
+ if (!trackId)
16095
+ return;
16096
+ this.clientEventReporter.reportFirstFrame(this.cid, TrackType.VIDEO, trackId);
16097
+ };
15962
16098
  /**
15963
16099
  * Binds a DOM <audio> element to the given session id.
15964
16100
  *
@@ -16108,6 +16244,7 @@ class Call {
16108
16244
  this.ringingSubject = new rxjs.BehaviorSubject(ringing);
16109
16245
  this.watching = watching;
16110
16246
  this.streamClient = streamClient;
16247
+ this.clientEventReporter = clientEventReporter;
16111
16248
  this.clientStore = clientStore;
16112
16249
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
16113
16250
  this.logger = videoLoggerSystem.getLogger('Call');
@@ -17330,10 +17467,12 @@ class StreamClient {
17330
17467
  this.logger.info('StreamClient.connect: this.wsConnection.connect()');
17331
17468
  return await this.wsConnection.connect(this.defaultWSTimeout);
17332
17469
  };
17470
+ this.getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
17471
+ "1.53.0";
17333
17472
  this.getUserAgent = () => {
17334
17473
  if (!this.cachedUserAgent) {
17335
17474
  const { clientAppIdentifier = {} } = this.options;
17336
- const { sdkName = 'js', sdkVersion = "1.52.0", ...extras } = clientAppIdentifier;
17475
+ const { sdkName = 'js', sdkVersion = "1.53.0", ...extras } = clientAppIdentifier;
17337
17476
  this.cachedUserAgent = [
17338
17477
  `stream-video-${sdkName}-v${sdkVersion}`,
17339
17478
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -17510,6 +17649,602 @@ const createTokenOrProvider = (options) => {
17510
17649
  return token || tokenProvider;
17511
17650
  };
17512
17651
 
17652
+ const pcKey = (cid, role) => `${cid}:${role}`;
17653
+ class ClientEventReporter {
17654
+ constructor(options) {
17655
+ this.logger = videoLoggerSystem.getLogger('ClientEventReporter');
17656
+ this.callContexts = new Map();
17657
+ this.joinAttemptIds = new Map();
17658
+ this.joinReasons = new Map();
17659
+ this.coordinatorPairs = new Map();
17660
+ this.wsPairs = new Map();
17661
+ this.peerConnectionPairs = new Map();
17662
+ this.pcEverConnected = new Map();
17663
+ this.firstFrameReported = new Set();
17664
+ /**
17665
+ * Starts a new coordinator connection correlation scope.
17666
+ *
17667
+ * @param userId the id of the user being connected. Captured here because
17668
+ * the `CoordinatorWS` stage emits before the connection flow assigns
17669
+ * the user to the client, so it can't be read from the client yet.
17670
+ */
17671
+ this.startCoordinatorConnection = (userId) => {
17672
+ this.coordinatorConnectId = generateUUIDv4();
17673
+ this.coordinatorConnectUserId = userId;
17674
+ return this.coordinatorConnectId;
17675
+ };
17676
+ this.trackCoordinatorWs = async (op) => {
17677
+ this.beginCoordinatorWs();
17678
+ try {
17679
+ const result = await op();
17680
+ this.succeedCoordinatorWs();
17681
+ return result;
17682
+ }
17683
+ catch (err) {
17684
+ applyError(this.coordinatorWsPair, mapCoordinatorWsError(err));
17685
+ throw err;
17686
+ }
17687
+ };
17688
+ this.beginCoordinatorWs = () => {
17689
+ if (!this.coordinatorWsPair) {
17690
+ this.coordinatorWsPair = {
17691
+ sid: generateUUIDv4(),
17692
+ attempts: 0,
17693
+ startedAt: Date.now(),
17694
+ userIdSnapshot: this.coordinatorConnectUserId,
17695
+ };
17696
+ this.send({
17697
+ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair),
17698
+ event_type: 'initiated',
17699
+ });
17700
+ }
17701
+ this.coordinatorWsPair.attempts++;
17702
+ };
17703
+ this.succeedCoordinatorWs = () => {
17704
+ const pair = this.coordinatorWsPair;
17705
+ if (!pair)
17706
+ return;
17707
+ this.send({
17708
+ ...this.buildCoordinatorWsCommon(pair),
17709
+ event_type: 'completed',
17710
+ outcome: 'success',
17711
+ retry_count_attempt: pair.attempts - 1,
17712
+ elapsed_time: Date.now() - pair.startedAt,
17713
+ });
17714
+ this.coordinatorWsPair = undefined;
17715
+ };
17716
+ this.closeCoordinatorWs = () => {
17717
+ const pair = this.coordinatorWsPair;
17718
+ if (!pair || !pair.lastError) {
17719
+ this.coordinatorWsPair = undefined;
17720
+ return;
17721
+ }
17722
+ const { reason, code } = pair.lastError;
17723
+ this.send({
17724
+ ...this.buildCoordinatorWsCommon(pair),
17725
+ event_type: 'completed',
17726
+ outcome: 'failure',
17727
+ retry_count_attempt: pair.attempts - 1,
17728
+ elapsed_time: Date.now() - pair.startedAt,
17729
+ retry_failure_reason: reason,
17730
+ retry_failure_code: code,
17731
+ });
17732
+ this.coordinatorWsPair = undefined;
17733
+ };
17734
+ this.buildCoordinatorWsCommon = (pair) => ({
17735
+ user_id: pair.userIdSnapshot ?? this.streamClient.userID,
17736
+ stage: 'CoordinatorWS',
17737
+ stage_id: pair.sid,
17738
+ ...(this.coordinatorConnectId && {
17739
+ coordinator_connect_id: this.coordinatorConnectId,
17740
+ }),
17741
+ timestamp: new Date().toISOString(),
17742
+ user_agent: this.streamClient.getUserAgent(),
17743
+ sdk_version: this.streamClient.getSdkVersion(),
17744
+ });
17745
+ this.emitMediaPermission = (cid) => {
17746
+ if (isReactNative() || !this.callContexts.has(cid))
17747
+ return;
17748
+ const pair = {
17749
+ sid: generateUUIDv4(),
17750
+ attempts: 0,
17751
+ startedAt: Date.now(),
17752
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17753
+ };
17754
+ this.send({
17755
+ ...this.buildCommon(cid, 'MediaDevicePermission', pair),
17756
+ ...this.sessionIdField(cid),
17757
+ microphone_permission_status: readPermissionStatus(getAudioBrowserPermission()),
17758
+ camera_permission_status: readPermissionStatus(getVideoBrowserPermission()),
17759
+ event_type: 'initiated',
17760
+ });
17761
+ };
17762
+ this.registerCall = (cid, ctx) => {
17763
+ this.callContexts.set(cid, ctx);
17764
+ };
17765
+ this.unregisterCall = (cid) => {
17766
+ this.callContexts.delete(cid);
17767
+ this.joinAttemptIds.delete(cid);
17768
+ this.joinReasons.delete(cid);
17769
+ this.coordinatorPairs.delete(cid);
17770
+ this.wsPairs.delete(cid);
17771
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17772
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17773
+ for (const role of ['publish', 'subscribe']) {
17774
+ const key = pcKey(cid, role);
17775
+ this.peerConnectionPairs.delete(key);
17776
+ this.pcEverConnected.delete(key);
17777
+ }
17778
+ };
17779
+ this.startCorrelation = (cid, joinReason) => {
17780
+ try {
17781
+ this.closeCallPairs(cid);
17782
+ this.joinAttemptIds.set(cid, generateUUIDv4());
17783
+ this.joinReasons.set(cid, joinReason);
17784
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17785
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17786
+ this.emitJoinInitiated(cid);
17787
+ this.emitMediaPermission(cid);
17788
+ }
17789
+ catch (err) {
17790
+ this.logger.warn('Failed to start join correlation', err);
17791
+ }
17792
+ };
17793
+ this.withJoinLifecycle = async (cid, joinReason, op) => {
17794
+ this.startCorrelation(cid, joinReason);
17795
+ try {
17796
+ return await op();
17797
+ }
17798
+ catch (err) {
17799
+ this.closeCallPairs(cid);
17800
+ throw err;
17801
+ }
17802
+ };
17803
+ this.track = async (cid, stage, op) => {
17804
+ this.beginAttempt(cid, stage);
17805
+ try {
17806
+ const result = await op();
17807
+ this.succeedAttempt(cid, stage);
17808
+ return result;
17809
+ }
17810
+ catch (err) {
17811
+ this.applyStageError(cid, stage, err);
17812
+ throw err;
17813
+ }
17814
+ };
17815
+ this.reportFirstFrame = (cid, trackType, trackId) => {
17816
+ const stage = trackType === TrackType.VIDEO
17817
+ ? 'FirstVideoFrame'
17818
+ : trackType === TrackType.AUDIO
17819
+ ? 'FirstAudioFrame'
17820
+ : undefined;
17821
+ if (!stage)
17822
+ return;
17823
+ const key = `${cid}:${stage}`;
17824
+ if (this.firstFrameReported.has(key))
17825
+ return;
17826
+ this.firstFrameReported.add(key);
17827
+ const pair = {
17828
+ sid: generateUUIDv4(),
17829
+ attempts: 0,
17830
+ startedAt: Date.now(),
17831
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17832
+ };
17833
+ const resolvedSfuId = this.getSfuId(cid);
17834
+ this.send({
17835
+ ...this.buildCommon(cid, stage, pair),
17836
+ ...this.sessionIdField(cid),
17837
+ ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
17838
+ track_id: trackId,
17839
+ event_type: 'initiated',
17840
+ });
17841
+ };
17842
+ this.captureWsError = (cid, opts) => {
17843
+ const pair = this.wsPairs.get(cid);
17844
+ if (!pair)
17845
+ return;
17846
+ applyError(pair, { reason: opts.reason, code: opts.code });
17847
+ };
17848
+ this.close = (cid) => {
17849
+ this.closeCallPairs(cid);
17850
+ };
17851
+ this.abort = (cid, opts) => {
17852
+ try {
17853
+ const { code, reason } = opts;
17854
+ const stageError = { code, reason };
17855
+ applyErrorIfAbsent(this.coordinatorPairs.get(cid), stageError);
17856
+ applyErrorIfAbsent(this.wsPairs.get(cid), stageError);
17857
+ this.failCoordinator(cid);
17858
+ this.failWs(cid);
17859
+ this.emitPeerConnectionFailure(cid, 'publish', code, reason, 'NOT_CONNECTED');
17860
+ this.emitPeerConnectionFailure(cid, 'subscribe', code, reason, 'NOT_CONNECTED');
17861
+ }
17862
+ catch (err) {
17863
+ this.logger.warn('Failed to report abort', err);
17864
+ }
17865
+ };
17866
+ this.closeCallPairs = (cid) => {
17867
+ if (this.coordinatorPairs.get(cid))
17868
+ this.failCoordinator(cid);
17869
+ if (this.wsPairs.get(cid))
17870
+ this.failWs(cid);
17871
+ for (const role of ['publish', 'subscribe']) {
17872
+ this.emitPeerConnectionFailure(cid, role, 'CLIENT_ABORTED', 'superseded by a new join attempt', 'NOT_CONNECTED');
17873
+ }
17874
+ };
17875
+ this.emitJoinInitiated = (cid) => {
17876
+ const joinAttemptId = this.joinAttemptIds.get(cid);
17877
+ if (!joinAttemptId)
17878
+ return;
17879
+ const coordinatorConnectId = this.coordinatorConnectId;
17880
+ this.send({
17881
+ user_id: this.streamClient.userID,
17882
+ stage: 'JoinInitiated',
17883
+ join_attempt_id: joinAttemptId,
17884
+ ...(coordinatorConnectId && {
17885
+ coordinator_connect_id: coordinatorConnectId,
17886
+ }),
17887
+ timestamp: new Date().toISOString(),
17888
+ user_agent: this.streamClient.getUserAgent(),
17889
+ sdk_version: this.streamClient.getSdkVersion(),
17890
+ event_type: 'initiated',
17891
+ });
17892
+ };
17893
+ this.beginAttempt = (cid, stage) => {
17894
+ if (stage === 'CoordinatorJoin')
17895
+ this.beginCoordinatorAttempt(cid);
17896
+ else
17897
+ this.beginWsAttempt(cid);
17898
+ };
17899
+ this.succeedAttempt = (cid, stage) => {
17900
+ if (stage === 'CoordinatorJoin')
17901
+ this.succeedCoordinator(cid);
17902
+ else
17903
+ this.succeedWs(cid);
17904
+ };
17905
+ this.applyStageError = (cid, stage, err) => {
17906
+ const pair = stage === 'CoordinatorJoin'
17907
+ ? this.coordinatorPairs.get(cid)
17908
+ : this.wsPairs.get(cid);
17909
+ applyErrorIfAbsent(pair, stage === 'CoordinatorJoin'
17910
+ ? mapCoordinatorHttpError(err)
17911
+ : mapWsJoinError(err));
17912
+ };
17913
+ this.beginCoordinatorAttempt = (cid) => {
17914
+ let pair = this.coordinatorPairs.get(cid);
17915
+ if (!pair) {
17916
+ pair = {
17917
+ sid: generateUUIDv4(),
17918
+ attempts: 0,
17919
+ startedAt: Date.now(),
17920
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17921
+ joinReasonSnapshot: this.joinReasons.get(cid),
17922
+ };
17923
+ this.coordinatorPairs.set(cid, pair);
17924
+ this.send({
17925
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17926
+ ...(pair.joinReasonSnapshot && {
17927
+ join_reason: pair.joinReasonSnapshot,
17928
+ }),
17929
+ event_type: 'initiated',
17930
+ });
17931
+ }
17932
+ pair.lastError = undefined;
17933
+ pair.attempts++;
17934
+ };
17935
+ this.succeedCoordinator = (cid) => {
17936
+ const pair = this.coordinatorPairs.get(cid);
17937
+ if (!pair)
17938
+ return;
17939
+ this.send({
17940
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17941
+ ...this.sessionIdField(cid),
17942
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
17943
+ event_type: 'completed',
17944
+ outcome: 'success',
17945
+ retry_count_attempt: pair.attempts - 1,
17946
+ elapsed_time: Date.now() - pair.startedAt,
17947
+ });
17948
+ this.coordinatorPairs.delete(cid);
17949
+ };
17950
+ this.failCoordinator = (cid) => {
17951
+ const pair = this.coordinatorPairs.get(cid);
17952
+ if (!pair || !pair.lastError) {
17953
+ this.coordinatorPairs.delete(cid);
17954
+ return;
17955
+ }
17956
+ const { reason, code } = pair.lastError;
17957
+ this.send({
17958
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17959
+ ...this.sessionIdField(cid),
17960
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
17961
+ event_type: 'completed',
17962
+ outcome: 'failure',
17963
+ retry_count_attempt: pair.attempts - 1,
17964
+ elapsed_time: Date.now() - pair.startedAt,
17965
+ retry_failure_reason: reason,
17966
+ retry_failure_code: code,
17967
+ });
17968
+ this.coordinatorPairs.delete(cid);
17969
+ };
17970
+ this.beginWsAttempt = (cid) => {
17971
+ let pair = this.wsPairs.get(cid);
17972
+ if (!pair) {
17973
+ pair = {
17974
+ sid: generateUUIDv4(),
17975
+ attempts: 0,
17976
+ startedAt: Date.now(),
17977
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17978
+ };
17979
+ this.wsPairs.set(cid, pair);
17980
+ const sfuId = this.getSfuId(cid);
17981
+ this.send({
17982
+ ...this.buildCommon(cid, 'WSJoin', pair),
17983
+ ...this.sessionIdField(cid),
17984
+ ...(sfuId && { sfu_id: sfuId }),
17985
+ event_type: 'initiated',
17986
+ });
17987
+ }
17988
+ pair.lastError = undefined;
17989
+ pair.attempts++;
17990
+ };
17991
+ this.succeedWs = (cid) => {
17992
+ const pair = this.wsPairs.get(cid);
17993
+ if (!pair)
17994
+ return;
17995
+ const sfuId = this.getSfuId(cid);
17996
+ this.send({
17997
+ ...this.buildCommon(cid, 'WSJoin', pair),
17998
+ ...this.sessionIdField(cid),
17999
+ ...(sfuId && { sfu_id: sfuId }),
18000
+ event_type: 'completed',
18001
+ outcome: 'success',
18002
+ retry_count_attempt: pair.attempts - 1,
18003
+ elapsed_time: Date.now() - pair.startedAt,
18004
+ });
18005
+ this.wsPairs.delete(cid);
18006
+ };
18007
+ this.failWs = (cid) => {
18008
+ const pair = this.wsPairs.get(cid);
18009
+ if (!pair || !pair.lastError) {
18010
+ this.wsPairs.delete(cid);
18011
+ return;
18012
+ }
18013
+ const { reason, code } = pair.lastError;
18014
+ const sfuId = this.getSfuId(cid);
18015
+ this.send({
18016
+ ...this.buildCommon(cid, 'WSJoin', pair),
18017
+ ...this.sessionIdField(cid),
18018
+ event_type: 'completed',
18019
+ outcome: 'failure',
18020
+ retry_count_attempt: pair.attempts - 1,
18021
+ elapsed_time: Date.now() - pair.startedAt,
18022
+ ...(sfuId && { sfu_id: sfuId }),
18023
+ retry_failure_reason: reason,
18024
+ retry_failure_code: code,
18025
+ });
18026
+ this.wsPairs.delete(cid);
18027
+ };
18028
+ this.onPeerConnectionStateChange = (cid, event) => {
18029
+ const role = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
18030
+ if (event.stateType === 'ice' && event.state === 'failed') {
18031
+ this.emitPeerConnectionFailure(cid, role, 'ICE_CONNECTIVITY_FAILED', 'ICE connectivity checks failed', 'FAILED');
18032
+ return;
18033
+ }
18034
+ if (event.stateType === 'peerConnection' && event.state === 'failed') {
18035
+ this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed', 'CONNECTED');
18036
+ return;
18037
+ }
18038
+ if (event.stateType !== 'peerConnection')
18039
+ return;
18040
+ switch (event.state) {
18041
+ case 'connecting':
18042
+ if (this.peerConnectionPairs.has(pcKey(cid, role)))
18043
+ return;
18044
+ this.openPeerConnectionPair(cid, role);
18045
+ break;
18046
+ case 'connected':
18047
+ this.emitPeerConnectionSuccess(cid, role);
18048
+ this.pcEverConnected.set(pcKey(cid, role), true);
18049
+ break;
18050
+ }
18051
+ };
18052
+ this.openPeerConnectionPair = (cid, role) => {
18053
+ const key = pcKey(cid, role);
18054
+ const pair = {
18055
+ sid: generateUUIDv4(),
18056
+ attempts: 0,
18057
+ startedAt: Date.now(),
18058
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
18059
+ sfuId: this.getSfuId(cid),
18060
+ userSessionId: this.getUserSessionId(cid),
18061
+ wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
18062
+ };
18063
+ this.peerConnectionPairs.set(key, pair);
18064
+ this.send({
18065
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18066
+ ...this.sessionIdField(cid),
18067
+ peer_connection: role,
18068
+ was_previously_connected: pair.wasPreviouslyConnected,
18069
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18070
+ ...(pair.userSessionId && {
18071
+ user_session_id: pair.userSessionId,
18072
+ }),
18073
+ event_type: 'initiated',
18074
+ });
18075
+ };
18076
+ this.emitPeerConnectionSuccess = (cid, role) => {
18077
+ const key = pcKey(cid, role);
18078
+ const pair = this.peerConnectionPairs.get(key);
18079
+ if (!pair)
18080
+ return;
18081
+ this.send({
18082
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18083
+ ...this.sessionIdField(cid),
18084
+ peer_connection: role,
18085
+ was_previously_connected: pair.wasPreviouslyConnected,
18086
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18087
+ ...(pair.userSessionId && {
18088
+ user_session_id: pair.userSessionId,
18089
+ }),
18090
+ event_type: 'completed',
18091
+ outcome: 'success',
18092
+ retry_count_attempt: 0,
18093
+ elapsed_time: Date.now() - pair.startedAt,
18094
+ });
18095
+ this.peerConnectionPairs.delete(key);
18096
+ };
18097
+ this.emitPeerConnectionFailure = (cid, role, code, reason, iceState) => {
18098
+ const key = pcKey(cid, role);
18099
+ const pair = this.peerConnectionPairs.get(key);
18100
+ if (!pair)
18101
+ return;
18102
+ this.send({
18103
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18104
+ ...this.sessionIdField(cid),
18105
+ peer_connection: role,
18106
+ was_previously_connected: pair.wasPreviouslyConnected,
18107
+ ...(pair.userSessionId && {
18108
+ user_session_id: pair.userSessionId,
18109
+ }),
18110
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18111
+ event_type: 'completed',
18112
+ outcome: 'failure',
18113
+ retry_count_attempt: 0,
18114
+ elapsed_time: Date.now() - pair.startedAt,
18115
+ ice_state: iceState,
18116
+ retry_failure_reason: reason,
18117
+ retry_failure_code: code,
18118
+ });
18119
+ this.peerConnectionPairs.delete(key);
18120
+ };
18121
+ this.getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
18122
+ this.getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
18123
+ this.sessionIdField = (cid) => {
18124
+ const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
18125
+ return callSessionId ? { call_session_id: callSessionId } : {};
18126
+ };
18127
+ this.buildCommon = (cid, stage, pair) => {
18128
+ const ctx = this.callContexts.get(cid);
18129
+ const coordinatorConnectId = this.coordinatorConnectId;
18130
+ return {
18131
+ user_id: this.streamClient.userID,
18132
+ type: ctx?.callType ?? '',
18133
+ id: ctx?.callId ?? '',
18134
+ call_cid: cid,
18135
+ stage,
18136
+ stage_id: pair.sid,
18137
+ ...(pair.joinAttemptIdSnapshot && {
18138
+ join_attempt_id: pair.joinAttemptIdSnapshot,
18139
+ }),
18140
+ ...(coordinatorConnectId && {
18141
+ coordinator_connect_id: coordinatorConnectId,
18142
+ }),
18143
+ timestamp: new Date().toISOString(),
18144
+ user_agent: this.streamClient.getUserAgent(),
18145
+ sdk_version: this.streamClient.getSdkVersion(),
18146
+ };
18147
+ };
18148
+ this.send = (body) => {
18149
+ void this.sendWithRetry(body);
18150
+ };
18151
+ this.sendWithRetry = async (body) => {
18152
+ for (let attempt = 0; attempt < 5; attempt++) {
18153
+ try {
18154
+ await this.streamClient.doAxiosRequest('post', '/call_client_event', { events: [body] }, { publicEndpoint: true });
18155
+ return true;
18156
+ }
18157
+ catch (err) {
18158
+ const status = err?.response
18159
+ ?.status;
18160
+ if (typeof status === 'number' && status >= 400 && status < 500) {
18161
+ this.logger.debug(`Client event rejected (${status}), not retrying`, body.stage, body.event_type);
18162
+ return false;
18163
+ }
18164
+ if (attempt === 4) {
18165
+ this.logger.debug('Client event delivery failed after retries', body.stage, body.event_type, err);
18166
+ return false;
18167
+ }
18168
+ await sleep(retryInterval(attempt));
18169
+ }
18170
+ }
18171
+ return false;
18172
+ };
18173
+ this.streamClient = options.streamClient;
18174
+ }
18175
+ }
18176
+ const readPermissionStatus = (permission) => {
18177
+ const state = getCurrentValue(permission.asStateObservable());
18178
+ switch (state) {
18179
+ case 'granted':
18180
+ return 'GRANTED';
18181
+ case 'denied':
18182
+ return 'FAILED';
18183
+ case 'prompting':
18184
+ return 'INITIATED';
18185
+ case 'prompt':
18186
+ default:
18187
+ return 'NOT_INITIATED';
18188
+ }
18189
+ };
18190
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
18191
+ const applyError = (pair, next) => {
18192
+ if (!pair)
18193
+ return;
18194
+ pair.lastError = next;
18195
+ };
18196
+ const applyErrorIfAbsent = (pair, next) => {
18197
+ if (!pair || pair.lastError)
18198
+ return;
18199
+ pair.lastError = next;
18200
+ };
18201
+ const mapCoordinatorHttpError = (err) => {
18202
+ if (err instanceof ErrorFromResponse) {
18203
+ return {
18204
+ reason: err.message,
18205
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18206
+ };
18207
+ }
18208
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18209
+ };
18210
+ const mapCoordinatorWsError = (err) => {
18211
+ if (err instanceof ErrorFromResponse) {
18212
+ return {
18213
+ reason: err.message,
18214
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18215
+ };
18216
+ }
18217
+ if (err instanceof Error) {
18218
+ try {
18219
+ const parsed = JSON.parse(err.message);
18220
+ if (typeof parsed.isWSFailure === 'boolean') {
18221
+ return {
18222
+ reason: parsed.message || err.message,
18223
+ code: !parsed.isWSFailure && parsed.code
18224
+ ? String(parsed.code)
18225
+ : 'SERVER_ERROR',
18226
+ };
18227
+ }
18228
+ }
18229
+ catch { }
18230
+ }
18231
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18232
+ };
18233
+ const mapWsJoinError = (err) => {
18234
+ if (err instanceof SfuJoinError) {
18235
+ const sfuError = err.errorEvent.error;
18236
+ return {
18237
+ reason: sfuError?.message || err.message,
18238
+ code: (sfuError && ErrorCode[sfuError.code]) || 'SFU_ERROR',
18239
+ };
18240
+ }
18241
+ const reason = errorMessage(err);
18242
+ if (err instanceof SfuTimeoutError) {
18243
+ return { reason, code: 'REQUEST_TIMEOUT' };
18244
+ }
18245
+ return { reason, code: 'SFU_ERROR' };
18246
+ };
18247
+
17513
18248
  /**
17514
18249
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
17515
18250
  */
@@ -17573,6 +18308,7 @@ class StreamVideoClient {
17573
18308
  }
17574
18309
  call = new Call({
17575
18310
  streamClient: this.streamClient,
18311
+ clientEventReporter: this.clientEventReporter,
17576
18312
  type: e.call.type,
17577
18313
  id: e.call.id,
17578
18314
  members: e.members,
@@ -17642,6 +18378,8 @@ class StreamVideoClient {
17642
18378
  user.id = '!anon';
17643
18379
  return this.connectAnonymousUser(user, tokenOrProvider);
17644
18380
  }
18381
+ const reporter = this.clientEventReporter;
18382
+ reporter.startCoordinatorConnection(user.id);
17645
18383
  const connectUserResponse = await withoutConcurrency(this.connectionConcurrencyTag, async () => {
17646
18384
  const client = this.streamClient;
17647
18385
  const { onConnectUserError, persistUserOnConnectionFailure } = client.options;
@@ -17651,14 +18389,15 @@ class StreamVideoClient {
17651
18389
  for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
17652
18390
  try {
17653
18391
  this.logger.trace(`Connecting user (${attempt})`, user);
17654
- return user.type === 'guest'
17655
- ? await client.connectGuestUser(user)
17656
- : await client.connectUser(user, tokenOrProvider);
18392
+ return await reporter.trackCoordinatorWs(() => user.type === 'guest'
18393
+ ? client.connectGuestUser(user)
18394
+ : client.connectUser(user, tokenOrProvider));
17657
18395
  }
17658
18396
  catch (err) {
17659
18397
  this.logger.warn(`Failed to connect a user (${attempt})`, err);
17660
18398
  errorQueue.push(err);
17661
18399
  if (attempt === maxConnectUserRetries - 1) {
18400
+ reporter.closeCoordinatorWs();
17662
18401
  onConnectUserError?.(err, errorQueue);
17663
18402
  throw err;
17664
18403
  }
@@ -17736,6 +18475,7 @@ class StreamVideoClient {
17736
18475
  return (call ??
17737
18476
  new Call({
17738
18477
  streamClient: this.streamClient,
18478
+ clientEventReporter: this.clientEventReporter,
17739
18479
  id: id,
17740
18480
  type: type,
17741
18481
  clientStore: this.writeableStateStore,
@@ -17760,6 +18500,7 @@ class StreamVideoClient {
17760
18500
  for (const c of response.calls) {
17761
18501
  const call = new Call({
17762
18502
  streamClient: this.streamClient,
18503
+ clientEventReporter: this.clientEventReporter,
17763
18504
  id: c.call.id,
17764
18505
  type: c.call.type,
17765
18506
  members: c.members,
@@ -17867,6 +18608,7 @@ class StreamVideoClient {
17867
18608
  const [callType, callId] = call_cid.split(':');
17868
18609
  call = new Call({
17869
18610
  streamClient: this.streamClient,
18611
+ clientEventReporter: this.clientEventReporter,
17870
18612
  type: callType,
17871
18613
  id: callId,
17872
18614
  clientStore: this.writeableStateStore,
@@ -17907,6 +18649,9 @@ class StreamVideoClient {
17907
18649
  this.logger = videoLoggerSystem.getLogger('client');
17908
18650
  this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
17909
18651
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
18652
+ this.clientEventReporter = new ClientEventReporter({
18653
+ streamClient: this.streamClient,
18654
+ });
17910
18655
  this.writeableStateStore = new StreamVideoWriteableStateStore();
17911
18656
  this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(this.writeableStateStore);
17912
18657
  if (typeof apiKeyOrArgs !== 'string' && apiKeyOrArgs.user) {
@@ -18020,6 +18765,7 @@ exports.ScreenShareState = ScreenShareState;
18020
18765
  exports.SfuEvents = events;
18021
18766
  exports.SfuJoinError = SfuJoinError;
18022
18767
  exports.SfuModels = models;
18768
+ exports.SfuTimeoutError = SfuTimeoutError;
18023
18769
  exports.SpeakerManager = SpeakerManager;
18024
18770
  exports.SpeakerState = SpeakerState;
18025
18771
  exports.StartClosedCaptionsRequestLanguageEnum = StartClosedCaptionsRequestLanguageEnum;