@stream-io/video-client 1.52.1-beta.0 → 1.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.browser.es.js +819 -123
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +819 -122
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +819 -123
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +6 -14
  9. package/dist/src/StreamVideoClient.d.ts +2 -0
  10. package/dist/src/coordinator/connection/client.d.ts +1 -0
  11. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  12. package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
  13. package/dist/src/errors/index.d.ts +1 -0
  14. package/dist/src/gen/google/protobuf/struct.d.ts +1 -3
  15. package/dist/src/gen/google/protobuf/timestamp.d.ts +1 -3
  16. package/dist/src/gen/video/sfu/event/events.d.ts +1 -22
  17. package/dist/src/gen/video/sfu/models/models.d.ts +0 -4
  18. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +2 -23
  19. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  20. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  21. package/dist/src/reporting/index.d.ts +1 -0
  22. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  23. package/dist/src/rtc/Publisher.d.ts +1 -4
  24. package/dist/src/rtc/Subscriber.d.ts +0 -7
  25. package/dist/src/rtc/types.d.ts +24 -1
  26. package/dist/src/types.d.ts +16 -0
  27. package/package.json +1 -1
  28. package/src/Call.ts +185 -106
  29. package/src/StreamSfuClient.ts +3 -3
  30. package/src/StreamVideoClient.ts +18 -3
  31. package/src/__tests__/Call.autodrop.test.ts +4 -1
  32. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  33. package/src/__tests__/Call.publishing.test.ts +4 -1
  34. package/src/__tests__/Call.test.ts +23 -0
  35. package/src/coordinator/connection/client.ts +5 -0
  36. package/src/devices/MicrophoneManager.ts +16 -0
  37. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  38. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  39. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  41. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +78 -2
  42. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  43. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  44. package/src/errors/SfuTimeoutError.ts +7 -0
  45. package/src/errors/index.ts +1 -0
  46. package/src/events/__tests__/call.test.ts +2 -0
  47. package/src/events/__tests__/mutes.test.ts +4 -1
  48. package/src/events/call.ts +8 -0
  49. package/src/gen/google/protobuf/struct.ts +12 -7
  50. package/src/gen/google/protobuf/timestamp.ts +7 -6
  51. package/src/gen/video/sfu/event/events.ts +25 -23
  52. package/src/gen/video/sfu/models/models.ts +1 -11
  53. package/src/gen/video/sfu/signal_rpc/signal.client.ts +29 -25
  54. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  55. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  56. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  57. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  58. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  59. package/src/helpers/client-details.ts +1 -1
  60. package/src/helpers/firstVideoFrame.ts +38 -0
  61. package/src/reporting/ClientEventReporter.ts +864 -0
  62. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  63. package/src/reporting/index.ts +1 -0
  64. package/src/rtc/BasePeerConnection.ts +30 -0
  65. package/src/rtc/Publisher.ts +0 -4
  66. package/src/rtc/Subscriber.ts +2 -28
  67. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  68. package/src/rtc/types.ts +34 -0
  69. package/src/types.ts +18 -0
package/dist/index.cjs.js CHANGED
@@ -527,7 +527,6 @@ class ErrorFromResponse extends Error {
527
527
  }
528
528
  }
529
529
 
530
- /* eslint-disable */
531
530
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
532
531
  // @generated from protobuf file "google/protobuf/struct.proto" (package "google.protobuf", syntax proto3)
533
532
  // tslint:disable
@@ -793,7 +792,6 @@ class ListValue$Type extends runtime.MessageType {
793
792
  */
794
793
  const ListValue = new ListValue$Type();
795
794
 
796
- /* eslint-disable */
797
795
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
798
796
  // @generated from protobuf file "google/protobuf/timestamp.proto" (package "google.protobuf", syntax proto3)
799
797
  // tslint:disable
@@ -1860,12 +1858,6 @@ class TrackInfo$Type extends runtime.MessageType {
1860
1858
  kind: 'scalar',
1861
1859
  T: 5 /*ScalarType.INT32*/,
1862
1860
  },
1863
- {
1864
- no: 13,
1865
- name: 'self_sub_audio_video',
1866
- kind: 'scalar',
1867
- T: 8 /*ScalarType.BOOL*/,
1868
- },
1869
1861
  ]);
1870
1862
  }
1871
1863
  }
@@ -6668,7 +6660,7 @@ const getSdkVersion = (sdk) => {
6668
6660
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6669
6661
  };
6670
6662
 
6671
- const version = "1.52.1-beta.0";
6663
+ const version = "1.53.1";
6672
6664
  const [major, minor, patch] = version.split('.');
6673
6665
  let sdkInfo = {
6674
6666
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6812,7 +6804,7 @@ const getClientDetails = async () => {
6812
6804
  .join(' '),
6813
6805
  version: '',
6814
6806
  },
6815
- webrtcVersion: webRtcInfo?.version || '',
6807
+ webrtcVersion: browserVersion,
6816
6808
  };
6817
6809
  };
6818
6810
 
@@ -7762,7 +7754,7 @@ class BasePeerConnection {
7762
7754
  /**
7763
7755
  * Constructs a new `BasePeerConnection` instance.
7764
7756
  */
7765
- 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, }) {
7766
7758
  this.iceHasEverConnected = false;
7767
7759
  this.isIceRestarting = false;
7768
7760
  this.isDisposed = false;
@@ -7916,6 +7908,10 @@ class BasePeerConnection {
7916
7908
  this.onConnectionStateChange = async () => {
7917
7909
  const state = this.pc.connectionState;
7918
7910
  this.logger.debug(`Connection state changed`, state);
7911
+ this.fireOnPeerConnectionStateChange({
7912
+ stateType: 'peerConnection',
7913
+ state,
7914
+ });
7919
7915
  if (this.tracer && (state === 'connected' || state === 'failed')) {
7920
7916
  try {
7921
7917
  const stats = await this.stats.get();
@@ -7938,8 +7934,20 @@ class BasePeerConnection {
7938
7934
  this.onIceConnectionStateChange = () => {
7939
7935
  const state = this.pc.iceConnectionState;
7940
7936
  this.logger.debug(`ICE connection state changed`, state);
7937
+ this.fireOnPeerConnectionStateChange({ stateType: 'ice', state });
7941
7938
  this.handleConnectionStateUpdate(state);
7942
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
+ };
7943
7951
  this.handleConnectionStateUpdate = (state) => {
7944
7952
  const { callingState } = this.state;
7945
7953
  if (callingState === exports.CallingState.OFFLINE)
@@ -8054,6 +8062,8 @@ class BasePeerConnection {
8054
8062
  this.tag = tag;
8055
8063
  this.onReconnectionNeeded = onReconnectionNeeded;
8056
8064
  this.onIceConnected = onIceConnected;
8065
+ this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8066
+ this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8057
8067
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
8058
8068
  this.pc = this.createPeerConnection(connectionConfig);
8059
8069
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType, statsTimestampDriftThresholdMs);
@@ -8076,6 +8086,8 @@ class BasePeerConnection {
8076
8086
  this.preConnectStuckTimeout = undefined;
8077
8087
  this.onReconnectionNeeded = undefined;
8078
8088
  this.onIceConnected = undefined;
8089
+ this.onPeerConnectionStateChange = undefined;
8090
+ this.onRemoteTrackUnmute = undefined;
8079
8091
  this.isDisposed = true;
8080
8092
  this.detachEventHandlers();
8081
8093
  this.pc.close();
@@ -8436,7 +8448,7 @@ class Publisher extends BasePeerConnection {
8436
8448
  /**
8437
8449
  * Constructs a new `Publisher` instance.
8438
8450
  */
8439
- constructor(baseOptions, publishOptions, opts = {}) {
8451
+ constructor(baseOptions, publishOptions) {
8440
8452
  super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
8441
8453
  this.transceiverCache = new TransceiverCache();
8442
8454
  this.clonedTracks = new Set();
@@ -8857,7 +8869,6 @@ class Publisher extends BasePeerConnection {
8857
8869
  muted: !isTrackLive,
8858
8870
  codec: publishOption.codec,
8859
8871
  publishOptionId: publishOption.id,
8860
- selfSubAudioVideo: this.selfSubEnabled,
8861
8872
  };
8862
8873
  };
8863
8874
  this.cloneTrack = (track) => {
@@ -8938,7 +8949,6 @@ class Publisher extends BasePeerConnection {
8938
8949
  });
8939
8950
  };
8940
8951
  this.publishOptions = publishOptions;
8941
- this.selfSubEnabled = opts.selfSubEnabled ?? false;
8942
8952
  this.on('iceRestart', (iceRestart) => {
8943
8953
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
8944
8954
  return;
@@ -9020,13 +9030,6 @@ class Subscriber extends BasePeerConnection {
9020
9030
  */
9021
9031
  constructor(opts) {
9022
9032
  super(PeerType.SUBSCRIBER, opts);
9023
- /**
9024
- * Remote streams received from the SFU. For a self-sub case
9025
- * we need to be able to distinguish between the local capture stream.
9026
- * The map will never contain local streams so we can safely use it to
9027
- * check if the stream is remote and dispose it when needed.
9028
- */
9029
- this.trackedStreams = new WeakSet();
9030
9033
  /**
9031
9034
  * Restarts the ICE connection and renegotiates with the SFU.
9032
9035
  */
@@ -9061,7 +9064,6 @@ class Subscriber extends BasePeerConnection {
9061
9064
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
9062
9065
  const [trackId, rawTrackType] = primaryStream.id.split(':');
9063
9066
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
9064
- const isSelfSub = !!participantToUpdate?.isLocalParticipant;
9065
9067
  this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
9066
9068
  const trackType = toTrackType(rawTrackType);
9067
9069
  if (!trackType) {
@@ -9075,6 +9077,7 @@ class Subscriber extends BasePeerConnection {
9075
9077
  track.addEventListener('unmute', () => {
9076
9078
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
9077
9079
  this.setRemoteTrackInterrupted(trackId, trackType, false);
9080
+ this.onRemoteTrackUnmute?.(trackType, track.id);
9078
9081
  });
9079
9082
  track.addEventListener('ended', () => {
9080
9083
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
@@ -9085,9 +9088,6 @@ class Subscriber extends BasePeerConnection {
9085
9088
  this.setRemoteTrackInterrupted(trackId, trackType, true);
9086
9089
  }
9087
9090
  this.trackIdToTrackType.set(track.id, trackType);
9088
- if (isSelfSub) {
9089
- this.trackedStreams.add(primaryStream);
9090
- }
9091
9091
  if (!participantToUpdate) {
9092
9092
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
9093
9093
  this.state.registerOrphanedTrack({
@@ -9103,12 +9103,6 @@ class Subscriber extends BasePeerConnection {
9103
9103
  this.logger.error(`Unknown track type: ${rawTrackType}`);
9104
9104
  return;
9105
9105
  }
9106
- // Self-sub loopback audio routes to the speaker by default, which
9107
- // would echo the local user's voice. Default-mute here; consumers
9108
- // (the loopback recording hook) re-enable explicitly when needed.
9109
- if (isSelfSub && e.track.kind === 'audio') {
9110
- e.track.enabled = false;
9111
- }
9112
9106
  // get the previous stream to dispose it later
9113
9107
  // usually this happens during migration, when the stream is replaced
9114
9108
  // with a new one but the old one is still in the state
@@ -9117,12 +9111,8 @@ class Subscriber extends BasePeerConnection {
9117
9111
  this.state.updateParticipant(participantToUpdate.sessionId, {
9118
9112
  [streamKindProp]: primaryStream,
9119
9113
  });
9114
+ // now, dispose the previous stream if it exists
9120
9115
  if (previousStream) {
9121
- if (isSelfSub && !this.trackedStreams.has(previousStream)) {
9122
- // this is the local capture stream, we don't want to dispose it
9123
- this.logger.debug(`[onTrack]: Skipping cleanup of previous ${e.track.kind} stream for userId: ${participantToUpdate.userId} because it is not tracked`);
9124
- return;
9125
- }
9126
9116
  this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
9127
9117
  previousStream.getTracks().forEach((t) => {
9128
9118
  t.stop();
@@ -9327,6 +9317,15 @@ class SfuJoinError extends Error {
9327
9317
  }
9328
9318
  }
9329
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
+
9330
9329
  /**
9331
9330
  * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
9332
9331
  * to the underlying promise. The handler marks the rejection path as handled
@@ -9432,7 +9431,7 @@ class StreamSfuClient {
9432
9431
  timeoutId = setTimeout(() => {
9433
9432
  const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
9434
9433
  this.tracer?.trace('signal.timeout', message);
9435
- reject(new Error(message));
9434
+ reject(new SfuTimeoutError(message));
9436
9435
  }, this.joinResponseTimeout);
9437
9436
  }),
9438
9437
  ]));
@@ -9602,7 +9601,7 @@ class StreamSfuClient {
9602
9601
  cleanupJoinSubscriptions();
9603
9602
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
9604
9603
  this.tracer?.trace('joinRequestTimeout', message);
9605
- current.reject(new Error(message));
9604
+ current.reject(new SfuTimeoutError(message));
9606
9605
  }, this.joinResponseTimeout);
9607
9606
  const joinRequest = SfuRequest.create({
9608
9607
  requestPayload: {
@@ -9819,6 +9818,10 @@ const watchCallEnded = (call) => {
9819
9818
  const { callingState } = call.state;
9820
9819
  if (callingState !== exports.CallingState.IDLE &&
9821
9820
  callingState !== exports.CallingState.LEFT) {
9821
+ call.clientEventReporter.abort(call.cid, {
9822
+ code: 'BACKEND_LEAVE',
9823
+ reason: 'call.ended event received',
9824
+ });
9822
9825
  call
9823
9826
  .leave({ message: 'call.ended event received', reject: false })
9824
9827
  .catch((err) => {
@@ -9848,6 +9851,10 @@ const watchSfuCallEnded = (call) => {
9848
9851
  call.state.setEndedAt(new Date());
9849
9852
  const reason = CallEndedReason[e.reason];
9850
9853
  globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
9854
+ call.clientEventReporter.abort(call.cid, {
9855
+ code: 'BACKEND_LEAVE',
9856
+ reason: `callEnded received: ${reason}`,
9857
+ });
9851
9858
  await call.leave({ message: `callEnded received: ${reason}` });
9852
9859
  }
9853
9860
  catch (err) {
@@ -11000,6 +11007,40 @@ class DynascaleManager {
11000
11007
  }
11001
11008
  }
11002
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
+
11003
11044
  const DEFAULT_THRESHOLD = 0.35;
11004
11045
  const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
11005
11046
  videoTrack: exports.VisibilityState.UNKNOWN,
@@ -13118,6 +13159,7 @@ class MicrophoneManager extends AudioDeviceManager {
13118
13159
  ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
13119
13160
  try {
13120
13161
  if (callingState === exports.CallingState.LEFT) {
13162
+ this.setMutedRecordingPrepared(false);
13121
13163
  await this.stopSpeakingWhileMutedDetection();
13122
13164
  }
13123
13165
  if (callingState !== exports.CallingState.JOINED)
@@ -13127,13 +13169,16 @@ class MicrophoneManager extends AudioDeviceManager {
13127
13169
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
13128
13170
  const hasPermission = await this.hasPermission(permissionState);
13129
13171
  if (hasPermission && status !== 'enabled') {
13172
+ this.setMutedRecordingPrepared(true);
13130
13173
  await this.startSpeakingWhileMutedDetection(deviceId);
13131
13174
  }
13132
13175
  else {
13176
+ this.setMutedRecordingPrepared(false);
13133
13177
  await this.stopSpeakingWhileMutedDetection();
13134
13178
  }
13135
13179
  }
13136
13180
  else {
13181
+ this.setMutedRecordingPrepared(false);
13137
13182
  await this.stopSpeakingWhileMutedDetection();
13138
13183
  }
13139
13184
  }
@@ -13451,6 +13496,16 @@ class MicrophoneManager extends AudioDeviceManager {
13451
13496
  this.logger.warn('Failed to stop speaking while muted detector', err);
13452
13497
  });
13453
13498
  }
13499
+ /**
13500
+ * iOS-only: keep the mic-input chain prepared while muted
13501
+ * so the `AVAudioEngine` stays full-duplex and remote audio renders on a
13502
+ * muted join.
13503
+ */
13504
+ setMutedRecordingPrepared(enabled) {
13505
+ if (!isReactNative())
13506
+ return;
13507
+ globalThis.streamRNVideoSDK?.callManager.setMutedRecordingPrepared?.(enabled);
13508
+ }
13454
13509
  async hasPermission(permissionState) {
13455
13510
  if (!isReactNative())
13456
13511
  return permissionState === 'granted';
@@ -13843,7 +13898,7 @@ class Call {
13843
13898
  * Use the [`StreamVideoClient.call`](./StreamVideoClient.md/#call)
13844
13899
  * method to construct a `Call` instance.
13845
13900
  */
13846
- constructor({ type, id, streamClient, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13901
+ constructor({ type, id, streamClient, clientEventReporter, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13847
13902
  /**
13848
13903
  * The state of this call.
13849
13904
  */
@@ -13880,7 +13935,6 @@ class Call {
13880
13935
  // maintain the order of publishing tracks to restore them after a reconnection
13881
13936
  // it shouldn't contain duplicates
13882
13937
  this.trackPublishOrder = [];
13883
- this.selfSubEnabled = false;
13884
13938
  this.hasJoinedOnce = false;
13885
13939
  this.deviceSettingsAppliedOnce = false;
13886
13940
  this.initialized = false;
@@ -14171,9 +14225,14 @@ class Call {
14171
14225
  this.sfuStatsReporter = undefined;
14172
14226
  this.lastStatsOptions = undefined;
14173
14227
  await this.subscriber?.dispose();
14228
+ this.clientEventReporter.abort(this.cid, {
14229
+ code: 'CLIENT_ABORTED',
14230
+ reason: leaveReason,
14231
+ });
14174
14232
  this.subscriber = undefined;
14175
14233
  await this.publisher?.dispose();
14176
14234
  this.publisher = undefined;
14235
+ this.clientEventReporter.unregisterCall(this.cid);
14177
14236
  await this.sfuClient?.leaveAndClose(leaveReason);
14178
14237
  this.sfuClient = undefined;
14179
14238
  this.trackSubscriptionManager.setSfuClient(undefined);
@@ -14225,30 +14284,6 @@ class Call {
14225
14284
  await Promise.all(stopOnLeavePromises);
14226
14285
  });
14227
14286
  };
14228
- /**
14229
- * The largest video publish dimension across the current publish options.
14230
- *
14231
- * @internal
14232
- */
14233
- this.getMaxVideoPublishDimension = () => {
14234
- if (!this.currentPublishOptions)
14235
- return undefined;
14236
- let maxDimension;
14237
- let maxArea = 0;
14238
- for (const opt of this.currentPublishOptions) {
14239
- if (opt.trackType !== TrackType.VIDEO)
14240
- continue;
14241
- const dim = opt.videoDimension;
14242
- if (!dim || !dim.width || !dim.height)
14243
- continue;
14244
- const area = dim.width * dim.height;
14245
- if (area > maxArea) {
14246
- maxDimension = dim;
14247
- maxArea = area;
14248
- }
14249
- }
14250
- return maxDimension;
14251
- };
14252
14287
  /**
14253
14288
  * Update from the call response from the "call.ring" event
14254
14289
  * @internal
@@ -14395,7 +14430,7 @@ class Call {
14395
14430
  *
14396
14431
  * @returns a promise which resolves once the call join-flow has finished.
14397
14432
  */
14398
- this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, selfSubEnabled = false, ...data } = {}) => {
14433
+ this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
14399
14434
  const callingState = this.state.callingState;
14400
14435
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
14401
14436
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -14403,15 +14438,19 @@ class Call {
14403
14438
  if (data?.ring) {
14404
14439
  this.ringingSubject.next(true);
14405
14440
  }
14406
- // we need this to be set before the callingx.joinCall() is
14407
- // called to avoid registering the test call in the CallKit/Telecom
14408
- this.selfSubEnabled = selfSubEnabled;
14409
14441
  const callingX = globalThis.streamRNVideoSDK?.callingX;
14410
14442
  if (callingX) {
14411
14443
  // for Android/iOS, we need to start the call in the callingx library as soon as possible
14412
14444
  await callingX.joinCall(this, this.clientStore.calls);
14413
14445
  }
14414
14446
  await this.setup();
14447
+ this.clientEventReporter.registerCall(this.cid, {
14448
+ callType: this.type,
14449
+ callId: this.id,
14450
+ getCallSessionId: () => this.state.session?.id ?? '',
14451
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
14452
+ getUserSessionId: () => this.sfuClient?.sessionId ?? '',
14453
+ });
14415
14454
  this.joinResponseTimeout = joinResponseTimeout;
14416
14455
  this.rpcRequestTimeout = rpcRequestTimeout;
14417
14456
  // we will count the number of join failures per SFU.
@@ -14421,39 +14460,42 @@ class Call {
14421
14460
  const joinData = data;
14422
14461
  maxJoinRetries = Math.max(maxJoinRetries, 1);
14423
14462
  try {
14424
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14425
- try {
14426
- this.logger.trace(`Joining call (${attempt})`, this.cid);
14427
- await this.doJoin(data);
14428
- delete joinData.migrating_from;
14429
- delete joinData.migrating_from_list;
14430
- break;
14431
- }
14432
- catch (err) {
14433
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14434
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14435
- (err instanceof SfuJoinError && err.unrecoverable)) {
14436
- // if the error is unrecoverable, we should not retry as that signals
14437
- // that connectivity is good, but the coordinator doesn't allow the user
14438
- // to join the call due to some reason (e.g., ended call, expired token...)
14439
- throw err;
14440
- }
14441
- // immediately switch to a different SFU in case of recoverable join error
14442
- const switchSfu = err instanceof SfuJoinError &&
14443
- SfuJoinError.isJoinErrorCode(err.errorEvent);
14444
- const sfuId = this.credentials?.server.edge_name || '';
14445
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14446
- sfuJoinFailures.set(sfuId, failures);
14447
- if (switchSfu || failures >= 2) {
14448
- joinData.migrating_from = sfuId;
14449
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14463
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
14464
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14465
+ try {
14466
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
14467
+ await this.doJoin(data);
14468
+ delete joinData.migrating_from;
14469
+ delete joinData.migrating_from_list;
14470
+ return;
14450
14471
  }
14451
- if (attempt === maxJoinRetries - 1) {
14452
- throw err;
14472
+ catch (err) {
14473
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14474
+ if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14475
+ (err instanceof SfuJoinError && err.unrecoverable)) {
14476
+ throw err;
14477
+ }
14478
+ const switchSfu = err instanceof SfuJoinError &&
14479
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
14480
+ const sfuId = this.credentials?.server.edge_name;
14481
+ if (sfuId) {
14482
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14483
+ sfuJoinFailures.set(sfuId, failures);
14484
+ if (switchSfu || failures >= 2) {
14485
+ joinData.migrating_from = sfuId;
14486
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14487
+ if (attempt < maxJoinRetries - 1) {
14488
+ this.clientEventReporter.startCorrelation(this.cid, 'first-attempt');
14489
+ }
14490
+ }
14491
+ }
14492
+ if (attempt === maxJoinRetries - 1) {
14493
+ throw err;
14494
+ }
14453
14495
  }
14496
+ await sleep(retryInterval(attempt));
14454
14497
  }
14455
- await sleep(retryInterval(attempt));
14456
- }
14498
+ });
14457
14499
  }
14458
14500
  catch (error) {
14459
14501
  callingX?.endCall(this, 'error');
@@ -14482,7 +14524,7 @@ class Call {
14482
14524
  performingMigration ||
14483
14525
  data?.migrating_from) {
14484
14526
  try {
14485
- const joinResponse = await this.doJoinRequest(data);
14527
+ const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
14486
14528
  this.credentials = joinResponse.credentials;
14487
14529
  statsOptions = joinResponse.stats_options;
14488
14530
  this.lastStatsOptions = statsOptions;
@@ -14540,9 +14582,11 @@ class Call {
14540
14582
  const preferredSubscribeOptions = !isReconnecting
14541
14583
  ? this.getPreferredSubscribeOptions()
14542
14584
  : [];
14585
+ const unifiedSessionId = this.unifiedSessionId;
14586
+ const capabilities = Array.from(this.clientCapabilities);
14543
14587
  try {
14544
- const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
14545
- unifiedSessionId: this.unifiedSessionId,
14588
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await this.clientEventReporter.track(this.cid, 'WSJoin', () => sfuClient.join({
14589
+ unifiedSessionId,
14546
14590
  subscriberSdp,
14547
14591
  publisherSdp,
14548
14592
  clientDetails,
@@ -14550,9 +14594,9 @@ class Call {
14550
14594
  reconnectDetails,
14551
14595
  preferredPublishOptions,
14552
14596
  preferredSubscribeOptions,
14553
- capabilities: Array.from(this.clientCapabilities),
14597
+ capabilities,
14554
14598
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
14555
- });
14599
+ }));
14556
14600
  this.currentPublishOptions = publishOptions;
14557
14601
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
14558
14602
  if (callState) {
@@ -14764,6 +14808,16 @@ class Call {
14764
14808
  // "ICE never connected" failure budget can be cleared.
14765
14809
  this.iceFailuresWithoutConnect = 0;
14766
14810
  },
14811
+ onPeerConnectionStateChange: (event) => {
14812
+ this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
14813
+ },
14814
+ onRemoteTrackUnmute: (trackType, trackId) => {
14815
+ const reportable = trackType === TrackType.AUDIO ||
14816
+ (isReactNative() && trackType === TrackType.VIDEO);
14817
+ if (!reportable)
14818
+ return;
14819
+ this.clientEventReporter.reportFirstFrame(this.cid, trackType, trackId);
14820
+ },
14767
14821
  };
14768
14822
  this.subscriber = new Subscriber(basePeerConnectionOptions);
14769
14823
  // anonymous users can't publish anything hence, there is no need
@@ -14773,9 +14827,7 @@ class Call {
14773
14827
  if (closePreviousInstances && this.publisher) {
14774
14828
  await this.publisher.dispose();
14775
14829
  }
14776
- this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
14777
- selfSubEnabled: this.selfSubEnabled,
14778
- });
14830
+ this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
14779
14831
  }
14780
14832
  this.statsReporter?.stop();
14781
14833
  if (this.statsReportingIntervalInMs > 0) {
@@ -15042,7 +15094,10 @@ class Call {
15042
15094
  const reconnectStartTime = Date.now();
15043
15095
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
15044
15096
  this.state.setCallingState(exports.CallingState.RECONNECTING);
15045
- await this.doJoin(this.joinCallData);
15097
+ const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
15098
+ ? 'network-available'
15099
+ : 'full-rejoin';
15100
+ await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
15046
15101
  await this.restorePublishedTracks();
15047
15102
  this.restoreSubscribedTracks();
15048
15103
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
@@ -15066,11 +15121,11 @@ class Call {
15066
15121
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
15067
15122
  try {
15068
15123
  const currentSfu = currentSfuClient.edgeName;
15069
- await this.doJoin({
15124
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
15070
15125
  ...this.joinCallData,
15071
15126
  migrating_from: currentSfu,
15072
15127
  migrating_from_list: [currentSfu],
15073
- });
15128
+ }));
15074
15129
  }
15075
15130
  finally {
15076
15131
  // cleanup the migration_from field after the migration is complete or failed
@@ -15106,11 +15161,22 @@ class Call {
15106
15161
  this.registerReconnectHandlers = () => {
15107
15162
  // handles the legacy "goAway" event
15108
15163
  const unregisterGoAway = this.on('goAway', () => {
15164
+ this.clientEventReporter.captureWsError(this.cid, {
15165
+ code: 'SFU_GO_AWAY',
15166
+ reason: 'SFU goAway received during WS join',
15167
+ });
15109
15168
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
15110
15169
  });
15111
15170
  // handles the "error" event, through which the SFU can request a reconnect
15112
15171
  const unregisterOnError = this.on('error', (e) => {
15113
15172
  const { reconnectStrategy: strategy, error } = e;
15173
+ if (!SfuJoinError.isJoinErrorCode(e)) {
15174
+ const code = error?.code ? ErrorCode[error.code] : undefined;
15175
+ this.clientEventReporter.captureWsError(this.cid, {
15176
+ code: code ?? 'SFU_ERROR',
15177
+ reason: error?.message || 'SFU error during WS join',
15178
+ });
15179
+ }
15114
15180
  // SFU_FULL is a join error, and when emitted, although it specifies a
15115
15181
  // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
15116
15182
  // This is now handled separately in the `call.join()` method.
@@ -15805,7 +15871,9 @@ class Call {
15805
15871
  this.leave({
15806
15872
  reject: true,
15807
15873
  reason: 'timeout',
15808
- message: `ringing timeout - ${this.isCreatedByMe ? 'no one accepted' : `user didn't interact with incoming call screen`}`,
15874
+ message: `ringing timeout - ${this.isCreatedByMe
15875
+ ? 'no one accepted'
15876
+ : `user didn't interact with incoming call screen`}`,
15809
15877
  }).catch((err) => {
15810
15878
  this.logger.error('Failed to drop call', err);
15811
15879
  });
@@ -16011,15 +16079,36 @@ class Call {
16011
16079
  * @param trackType the kind of video.
16012
16080
  */
16013
16081
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
16014
- const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16015
- if (!unbind)
16082
+ const unbindDynascale = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16083
+ const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(videoElement, sessionId, trackType);
16084
+ if (!unbindDynascale && !stopFirstFrameDetector)
16016
16085
  return;
16086
+ const unbind = () => {
16087
+ stopFirstFrameDetector?.();
16088
+ unbindDynascale?.();
16089
+ };
16017
16090
  this.leaveCallHooks.add(unbind);
16018
16091
  return () => {
16019
16092
  this.leaveCallHooks.delete(unbind);
16020
16093
  unbind();
16021
16094
  };
16022
16095
  };
16096
+ this.bindFirstVideoFrameDetector = (videoElement, sessionId, trackType) => {
16097
+ if (trackType !== 'videoTrack')
16098
+ return;
16099
+ return createFirstVideoFrameDetector(videoElement, () => {
16100
+ this.reportFirstRenderedVideoFrame(sessionId);
16101
+ });
16102
+ };
16103
+ this.reportFirstRenderedVideoFrame = (sessionId) => {
16104
+ const participant = this.state.findParticipantBySessionId(sessionId);
16105
+ if (participant?.isLocalParticipant)
16106
+ return;
16107
+ const trackId = participant?.videoStream?.getVideoTracks()[0]?.id;
16108
+ if (!trackId)
16109
+ return;
16110
+ this.clientEventReporter.reportFirstFrame(this.cid, TrackType.VIDEO, trackId);
16111
+ };
16023
16112
  /**
16024
16113
  * Binds a DOM <audio> element to the given session id.
16025
16114
  *
@@ -16169,6 +16258,7 @@ class Call {
16169
16258
  this.ringingSubject = new rxjs.BehaviorSubject(ringing);
16170
16259
  this.watching = watching;
16171
16260
  this.streamClient = streamClient;
16261
+ this.clientEventReporter = clientEventReporter;
16172
16262
  this.clientStore = clientStore;
16173
16263
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
16174
16264
  this.logger = videoLoggerSystem.getLogger('Call');
@@ -16205,12 +16295,6 @@ class Call {
16205
16295
  get currentUserId() {
16206
16296
  return this.clientStore.connectedUser?.id;
16207
16297
  }
16208
- /**
16209
- * A flag indicating whether self-subscription is enabled for the call.
16210
- */
16211
- get isSelfSubEnabled() {
16212
- return this.selfSubEnabled;
16213
- }
16214
16298
  /**
16215
16299
  * A flag indicating whether the call was created by the current user.
16216
16300
  */
@@ -17397,10 +17481,12 @@ class StreamClient {
17397
17481
  this.logger.info('StreamClient.connect: this.wsConnection.connect()');
17398
17482
  return await this.wsConnection.connect(this.defaultWSTimeout);
17399
17483
  };
17484
+ this.getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
17485
+ "1.53.1";
17400
17486
  this.getUserAgent = () => {
17401
17487
  if (!this.cachedUserAgent) {
17402
17488
  const { clientAppIdentifier = {} } = this.options;
17403
- const { sdkName = 'js', sdkVersion = "1.52.1-beta.0", ...extras } = clientAppIdentifier;
17489
+ const { sdkName = 'js', sdkVersion = "1.53.1", ...extras } = clientAppIdentifier;
17404
17490
  this.cachedUserAgent = [
17405
17491
  `stream-video-${sdkName}-v${sdkVersion}`,
17406
17492
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -17577,6 +17663,606 @@ const createTokenOrProvider = (options) => {
17577
17663
  return token || tokenProvider;
17578
17664
  };
17579
17665
 
17666
+ const pcKey = (cid, role) => `${cid}:${role}`;
17667
+ class ClientEventReporter {
17668
+ constructor(options) {
17669
+ this.logger = videoLoggerSystem.getLogger('ClientEventReporter');
17670
+ this.callContexts = new Map();
17671
+ this.joinAttemptIds = new Map();
17672
+ this.joinReasons = new Map();
17673
+ this.coordinatorPairs = new Map();
17674
+ this.wsPairs = new Map();
17675
+ this.peerConnectionPairs = new Map();
17676
+ this.pcEverConnected = new Map();
17677
+ this.firstFrameReported = new Set();
17678
+ /**
17679
+ * Starts a new coordinator connection correlation scope.
17680
+ *
17681
+ * @param userId the id of the user being connected. Captured here because
17682
+ * the `CoordinatorWS` stage emits before the connection flow assigns
17683
+ * the user to the client, so it can't be read from the client yet.
17684
+ */
17685
+ this.startCoordinatorConnection = (userId) => {
17686
+ this.coordinatorConnectId = generateUUIDv4();
17687
+ this.coordinatorConnectUserId = userId;
17688
+ return this.coordinatorConnectId;
17689
+ };
17690
+ this.trackCoordinatorWs = async (op) => {
17691
+ this.beginCoordinatorWs();
17692
+ try {
17693
+ const result = await op();
17694
+ this.succeedCoordinatorWs();
17695
+ return result;
17696
+ }
17697
+ catch (err) {
17698
+ applyError(this.coordinatorWsPair, mapCoordinatorWsError(err));
17699
+ throw err;
17700
+ }
17701
+ };
17702
+ this.beginCoordinatorWs = () => {
17703
+ if (!this.coordinatorWsPair) {
17704
+ this.coordinatorWsPair = {
17705
+ sid: generateUUIDv4(),
17706
+ attempts: 0,
17707
+ startedAt: Date.now(),
17708
+ userIdSnapshot: this.coordinatorConnectUserId,
17709
+ };
17710
+ this.send({
17711
+ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair),
17712
+ event_type: 'initiated',
17713
+ });
17714
+ }
17715
+ this.coordinatorWsPair.attempts++;
17716
+ };
17717
+ this.succeedCoordinatorWs = () => {
17718
+ const pair = this.coordinatorWsPair;
17719
+ if (!pair)
17720
+ return;
17721
+ this.send({
17722
+ ...this.buildCoordinatorWsCommon(pair),
17723
+ event_type: 'completed',
17724
+ outcome: 'success',
17725
+ retry_count_attempt: pair.attempts - 1,
17726
+ elapsed_time: Date.now() - pair.startedAt,
17727
+ });
17728
+ this.coordinatorWsPair = undefined;
17729
+ };
17730
+ this.closeCoordinatorWs = () => {
17731
+ const pair = this.coordinatorWsPair;
17732
+ if (!pair || !pair.lastError) {
17733
+ this.coordinatorWsPair = undefined;
17734
+ return;
17735
+ }
17736
+ const { reason, code } = pair.lastError;
17737
+ this.send({
17738
+ ...this.buildCoordinatorWsCommon(pair),
17739
+ event_type: 'completed',
17740
+ outcome: 'failure',
17741
+ retry_count_attempt: pair.attempts - 1,
17742
+ elapsed_time: Date.now() - pair.startedAt,
17743
+ retry_failure_reason: reason,
17744
+ retry_failure_code: code,
17745
+ });
17746
+ this.coordinatorWsPair = undefined;
17747
+ };
17748
+ this.buildCoordinatorWsCommon = (pair) => ({
17749
+ user_id: pair.userIdSnapshot ?? this.streamClient.userID,
17750
+ stage: 'CoordinatorWS',
17751
+ stage_id: pair.sid,
17752
+ ...(this.coordinatorConnectId && {
17753
+ coordinator_connect_id: this.coordinatorConnectId,
17754
+ }),
17755
+ timestamp: new Date().toISOString(),
17756
+ user_agent: this.streamClient.getUserAgent(),
17757
+ sdk_version: this.streamClient.getSdkVersion(),
17758
+ });
17759
+ this.emitMediaPermission = (cid) => {
17760
+ if (isReactNative() || !this.callContexts.has(cid))
17761
+ return;
17762
+ const pair = {
17763
+ sid: generateUUIDv4(),
17764
+ attempts: 0,
17765
+ startedAt: Date.now(),
17766
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17767
+ };
17768
+ this.send({
17769
+ ...this.buildCommon(cid, 'MediaDevicePermission', pair),
17770
+ ...this.sessionIdField(cid),
17771
+ microphone_permission_status: readPermissionStatus(getAudioBrowserPermission()),
17772
+ camera_permission_status: readPermissionStatus(getVideoBrowserPermission()),
17773
+ event_type: 'initiated',
17774
+ });
17775
+ };
17776
+ this.registerCall = (cid, ctx) => {
17777
+ this.callContexts.set(cid, ctx);
17778
+ };
17779
+ this.unregisterCall = (cid) => {
17780
+ this.callContexts.delete(cid);
17781
+ this.joinAttemptIds.delete(cid);
17782
+ this.joinReasons.delete(cid);
17783
+ this.coordinatorPairs.delete(cid);
17784
+ this.wsPairs.delete(cid);
17785
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17786
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17787
+ for (const role of ['publish', 'subscribe']) {
17788
+ const key = pcKey(cid, role);
17789
+ this.peerConnectionPairs.delete(key);
17790
+ this.pcEverConnected.delete(key);
17791
+ }
17792
+ };
17793
+ this.startCorrelation = (cid, joinReason) => {
17794
+ try {
17795
+ this.closeCallPairs(cid);
17796
+ this.joinAttemptIds.set(cid, generateUUIDv4());
17797
+ this.joinReasons.set(cid, joinReason);
17798
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17799
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17800
+ this.emitJoinInitiated(cid);
17801
+ this.emitMediaPermission(cid);
17802
+ }
17803
+ catch (err) {
17804
+ this.logger.warn('Failed to start join correlation', err);
17805
+ }
17806
+ };
17807
+ this.withJoinLifecycle = async (cid, joinReason, op) => {
17808
+ this.startCorrelation(cid, joinReason);
17809
+ try {
17810
+ return await op();
17811
+ }
17812
+ catch (err) {
17813
+ this.closeCallPairs(cid);
17814
+ throw err;
17815
+ }
17816
+ };
17817
+ this.track = async (cid, stage, op) => {
17818
+ this.beginAttempt(cid, stage);
17819
+ try {
17820
+ const result = await op();
17821
+ this.succeedAttempt(cid, stage);
17822
+ return result;
17823
+ }
17824
+ catch (err) {
17825
+ this.applyStageError(cid, stage, err);
17826
+ throw err;
17827
+ }
17828
+ };
17829
+ this.reportFirstFrame = (cid, trackType, trackId) => {
17830
+ const stage = trackType === TrackType.VIDEO
17831
+ ? 'FirstVideoFrame'
17832
+ : trackType === TrackType.AUDIO
17833
+ ? 'FirstAudioFrame'
17834
+ : undefined;
17835
+ if (!stage)
17836
+ return;
17837
+ const key = `${cid}:${stage}`;
17838
+ if (this.firstFrameReported.has(key))
17839
+ return;
17840
+ this.firstFrameReported.add(key);
17841
+ const pair = {
17842
+ sid: generateUUIDv4(),
17843
+ attempts: 0,
17844
+ startedAt: Date.now(),
17845
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17846
+ };
17847
+ const resolvedSfuId = this.getSfuId(cid);
17848
+ this.send({
17849
+ ...this.buildCommon(cid, stage, pair),
17850
+ ...this.sessionIdField(cid),
17851
+ ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
17852
+ track_id: trackId,
17853
+ event_type: 'initiated',
17854
+ });
17855
+ };
17856
+ this.captureWsError = (cid, opts) => {
17857
+ const pair = this.wsPairs.get(cid);
17858
+ if (!pair)
17859
+ return;
17860
+ applyError(pair, { reason: opts.reason, code: opts.code });
17861
+ };
17862
+ this.close = (cid) => {
17863
+ this.closeCallPairs(cid);
17864
+ };
17865
+ this.abort = (cid, opts) => {
17866
+ try {
17867
+ const { code, reason } = opts;
17868
+ const stageError = { code, reason };
17869
+ applyErrorIfAbsent(this.coordinatorPairs.get(cid), stageError);
17870
+ applyErrorIfAbsent(this.wsPairs.get(cid), stageError);
17871
+ this.failCoordinator(cid);
17872
+ this.failWs(cid);
17873
+ this.emitPeerConnectionFailure(cid, 'publish', code, reason, 'NOT_CONNECTED');
17874
+ this.emitPeerConnectionFailure(cid, 'subscribe', code, reason, 'NOT_CONNECTED');
17875
+ }
17876
+ catch (err) {
17877
+ this.logger.warn('Failed to report abort', err);
17878
+ }
17879
+ };
17880
+ this.closeCallPairs = (cid) => {
17881
+ if (this.coordinatorPairs.get(cid))
17882
+ this.failCoordinator(cid);
17883
+ if (this.wsPairs.get(cid))
17884
+ this.failWs(cid);
17885
+ for (const role of ['publish', 'subscribe']) {
17886
+ this.emitPeerConnectionFailure(cid, role, 'CLIENT_ABORTED', 'superseded by a new join attempt', 'NOT_CONNECTED');
17887
+ }
17888
+ };
17889
+ this.emitJoinInitiated = (cid) => {
17890
+ const joinAttemptId = this.joinAttemptIds.get(cid);
17891
+ if (!joinAttemptId)
17892
+ return;
17893
+ const coordinatorConnectId = this.coordinatorConnectId;
17894
+ const ctx = this.callContexts.get(cid);
17895
+ this.send({
17896
+ user_id: this.streamClient.userID,
17897
+ type: ctx?.callType,
17898
+ id: ctx?.callId,
17899
+ call_cid: cid,
17900
+ stage: 'JoinInitiated',
17901
+ join_attempt_id: joinAttemptId,
17902
+ ...(coordinatorConnectId && {
17903
+ coordinator_connect_id: coordinatorConnectId,
17904
+ }),
17905
+ timestamp: new Date().toISOString(),
17906
+ user_agent: this.streamClient.getUserAgent(),
17907
+ sdk_version: this.streamClient.getSdkVersion(),
17908
+ event_type: 'initiated',
17909
+ });
17910
+ };
17911
+ this.beginAttempt = (cid, stage) => {
17912
+ if (stage === 'CoordinatorJoin')
17913
+ this.beginCoordinatorAttempt(cid);
17914
+ else
17915
+ this.beginWsAttempt(cid);
17916
+ };
17917
+ this.succeedAttempt = (cid, stage) => {
17918
+ if (stage === 'CoordinatorJoin')
17919
+ this.succeedCoordinator(cid);
17920
+ else
17921
+ this.succeedWs(cid);
17922
+ };
17923
+ this.applyStageError = (cid, stage, err) => {
17924
+ const pair = stage === 'CoordinatorJoin'
17925
+ ? this.coordinatorPairs.get(cid)
17926
+ : this.wsPairs.get(cid);
17927
+ applyErrorIfAbsent(pair, stage === 'CoordinatorJoin'
17928
+ ? mapCoordinatorHttpError(err)
17929
+ : mapWsJoinError(err));
17930
+ };
17931
+ this.beginCoordinatorAttempt = (cid) => {
17932
+ let pair = this.coordinatorPairs.get(cid);
17933
+ if (!pair) {
17934
+ pair = {
17935
+ sid: generateUUIDv4(),
17936
+ attempts: 0,
17937
+ startedAt: Date.now(),
17938
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17939
+ joinReasonSnapshot: this.joinReasons.get(cid),
17940
+ };
17941
+ this.coordinatorPairs.set(cid, pair);
17942
+ this.send({
17943
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17944
+ ...(pair.joinReasonSnapshot && {
17945
+ join_reason: pair.joinReasonSnapshot,
17946
+ }),
17947
+ event_type: 'initiated',
17948
+ });
17949
+ }
17950
+ pair.lastError = undefined;
17951
+ pair.attempts++;
17952
+ };
17953
+ this.succeedCoordinator = (cid) => {
17954
+ const pair = this.coordinatorPairs.get(cid);
17955
+ if (!pair)
17956
+ return;
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: 'success',
17963
+ retry_count_attempt: pair.attempts - 1,
17964
+ elapsed_time: Date.now() - pair.startedAt,
17965
+ });
17966
+ this.coordinatorPairs.delete(cid);
17967
+ };
17968
+ this.failCoordinator = (cid) => {
17969
+ const pair = this.coordinatorPairs.get(cid);
17970
+ if (!pair || !pair.lastError) {
17971
+ this.coordinatorPairs.delete(cid);
17972
+ return;
17973
+ }
17974
+ const { reason, code } = pair.lastError;
17975
+ this.send({
17976
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17977
+ ...this.sessionIdField(cid),
17978
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
17979
+ event_type: 'completed',
17980
+ outcome: 'failure',
17981
+ retry_count_attempt: pair.attempts - 1,
17982
+ elapsed_time: Date.now() - pair.startedAt,
17983
+ retry_failure_reason: reason,
17984
+ retry_failure_code: code,
17985
+ });
17986
+ this.coordinatorPairs.delete(cid);
17987
+ };
17988
+ this.beginWsAttempt = (cid) => {
17989
+ let pair = this.wsPairs.get(cid);
17990
+ if (!pair) {
17991
+ pair = {
17992
+ sid: generateUUIDv4(),
17993
+ attempts: 0,
17994
+ startedAt: Date.now(),
17995
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17996
+ };
17997
+ this.wsPairs.set(cid, pair);
17998
+ const sfuId = this.getSfuId(cid);
17999
+ this.send({
18000
+ ...this.buildCommon(cid, 'WSJoin', pair),
18001
+ ...this.sessionIdField(cid),
18002
+ ...(sfuId && { sfu_id: sfuId }),
18003
+ event_type: 'initiated',
18004
+ });
18005
+ }
18006
+ pair.lastError = undefined;
18007
+ pair.attempts++;
18008
+ };
18009
+ this.succeedWs = (cid) => {
18010
+ const pair = this.wsPairs.get(cid);
18011
+ if (!pair)
18012
+ return;
18013
+ const sfuId = this.getSfuId(cid);
18014
+ this.send({
18015
+ ...this.buildCommon(cid, 'WSJoin', pair),
18016
+ ...this.sessionIdField(cid),
18017
+ ...(sfuId && { sfu_id: sfuId }),
18018
+ event_type: 'completed',
18019
+ outcome: 'success',
18020
+ retry_count_attempt: pair.attempts - 1,
18021
+ elapsed_time: Date.now() - pair.startedAt,
18022
+ });
18023
+ this.wsPairs.delete(cid);
18024
+ };
18025
+ this.failWs = (cid) => {
18026
+ const pair = this.wsPairs.get(cid);
18027
+ if (!pair || !pair.lastError) {
18028
+ this.wsPairs.delete(cid);
18029
+ return;
18030
+ }
18031
+ const { reason, code } = pair.lastError;
18032
+ const sfuId = this.getSfuId(cid);
18033
+ this.send({
18034
+ ...this.buildCommon(cid, 'WSJoin', pair),
18035
+ ...this.sessionIdField(cid),
18036
+ event_type: 'completed',
18037
+ outcome: 'failure',
18038
+ retry_count_attempt: pair.attempts - 1,
18039
+ elapsed_time: Date.now() - pair.startedAt,
18040
+ ...(sfuId && { sfu_id: sfuId }),
18041
+ retry_failure_reason: reason,
18042
+ retry_failure_code: code,
18043
+ });
18044
+ this.wsPairs.delete(cid);
18045
+ };
18046
+ this.onPeerConnectionStateChange = (cid, event) => {
18047
+ const role = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
18048
+ if (event.stateType === 'ice' && event.state === 'failed') {
18049
+ this.emitPeerConnectionFailure(cid, role, 'ICE_CONNECTIVITY_FAILED', 'ICE connectivity checks failed', 'FAILED');
18050
+ return;
18051
+ }
18052
+ if (event.stateType === 'peerConnection' && event.state === 'failed') {
18053
+ this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed', 'CONNECTED');
18054
+ return;
18055
+ }
18056
+ if (event.stateType !== 'peerConnection')
18057
+ return;
18058
+ switch (event.state) {
18059
+ case 'connecting':
18060
+ if (this.peerConnectionPairs.has(pcKey(cid, role)))
18061
+ return;
18062
+ this.openPeerConnectionPair(cid, role);
18063
+ break;
18064
+ case 'connected':
18065
+ this.emitPeerConnectionSuccess(cid, role);
18066
+ this.pcEverConnected.set(pcKey(cid, role), true);
18067
+ break;
18068
+ }
18069
+ };
18070
+ this.openPeerConnectionPair = (cid, role) => {
18071
+ const key = pcKey(cid, role);
18072
+ const pair = {
18073
+ sid: generateUUIDv4(),
18074
+ attempts: 0,
18075
+ startedAt: Date.now(),
18076
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
18077
+ sfuId: this.getSfuId(cid),
18078
+ userSessionId: this.getUserSessionId(cid),
18079
+ wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
18080
+ };
18081
+ this.peerConnectionPairs.set(key, pair);
18082
+ this.send({
18083
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18084
+ ...this.sessionIdField(cid),
18085
+ peer_connection: role,
18086
+ was_previously_connected: pair.wasPreviouslyConnected,
18087
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18088
+ ...(pair.userSessionId && {
18089
+ user_session_id: pair.userSessionId,
18090
+ }),
18091
+ event_type: 'initiated',
18092
+ });
18093
+ };
18094
+ this.emitPeerConnectionSuccess = (cid, role) => {
18095
+ const key = pcKey(cid, role);
18096
+ const pair = this.peerConnectionPairs.get(key);
18097
+ if (!pair)
18098
+ return;
18099
+ this.send({
18100
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18101
+ ...this.sessionIdField(cid),
18102
+ peer_connection: role,
18103
+ was_previously_connected: pair.wasPreviouslyConnected,
18104
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18105
+ ...(pair.userSessionId && {
18106
+ user_session_id: pair.userSessionId,
18107
+ }),
18108
+ event_type: 'completed',
18109
+ outcome: 'success',
18110
+ retry_count_attempt: 0,
18111
+ elapsed_time: Date.now() - pair.startedAt,
18112
+ });
18113
+ this.peerConnectionPairs.delete(key);
18114
+ };
18115
+ this.emitPeerConnectionFailure = (cid, role, code, reason, iceState) => {
18116
+ const key = pcKey(cid, role);
18117
+ const pair = this.peerConnectionPairs.get(key);
18118
+ if (!pair)
18119
+ return;
18120
+ this.send({
18121
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18122
+ ...this.sessionIdField(cid),
18123
+ peer_connection: role,
18124
+ was_previously_connected: pair.wasPreviouslyConnected,
18125
+ ...(pair.userSessionId && {
18126
+ user_session_id: pair.userSessionId,
18127
+ }),
18128
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18129
+ event_type: 'completed',
18130
+ outcome: 'failure',
18131
+ retry_count_attempt: 0,
18132
+ elapsed_time: Date.now() - pair.startedAt,
18133
+ ice_state: iceState,
18134
+ retry_failure_reason: reason,
18135
+ retry_failure_code: code,
18136
+ });
18137
+ this.peerConnectionPairs.delete(key);
18138
+ };
18139
+ this.getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
18140
+ this.getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
18141
+ this.sessionIdField = (cid) => {
18142
+ const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
18143
+ return callSessionId ? { call_session_id: callSessionId } : {};
18144
+ };
18145
+ this.buildCommon = (cid, stage, pair) => {
18146
+ const ctx = this.callContexts.get(cid);
18147
+ const coordinatorConnectId = this.coordinatorConnectId;
18148
+ return {
18149
+ user_id: this.streamClient.userID,
18150
+ type: ctx?.callType ?? '',
18151
+ id: ctx?.callId ?? '',
18152
+ call_cid: cid,
18153
+ stage,
18154
+ stage_id: pair.sid,
18155
+ ...(pair.joinAttemptIdSnapshot && {
18156
+ join_attempt_id: pair.joinAttemptIdSnapshot,
18157
+ }),
18158
+ ...(coordinatorConnectId && {
18159
+ coordinator_connect_id: coordinatorConnectId,
18160
+ }),
18161
+ timestamp: new Date().toISOString(),
18162
+ user_agent: this.streamClient.getUserAgent(),
18163
+ sdk_version: this.streamClient.getSdkVersion(),
18164
+ };
18165
+ };
18166
+ this.send = (body) => {
18167
+ void this.sendWithRetry(body);
18168
+ };
18169
+ this.sendWithRetry = async (body) => {
18170
+ for (let attempt = 0; attempt < 5; attempt++) {
18171
+ try {
18172
+ await this.streamClient.doAxiosRequest('post', '/call_client_event', { events: [body] }, { publicEndpoint: true });
18173
+ return true;
18174
+ }
18175
+ catch (err) {
18176
+ const status = err?.response
18177
+ ?.status;
18178
+ if (typeof status === 'number' && status >= 400 && status < 500) {
18179
+ this.logger.debug(`Client event rejected (${status}), not retrying`, body.stage, body.event_type);
18180
+ return false;
18181
+ }
18182
+ if (attempt === 4) {
18183
+ this.logger.debug('Client event delivery failed after retries', body.stage, body.event_type, err);
18184
+ return false;
18185
+ }
18186
+ await sleep(retryInterval(attempt));
18187
+ }
18188
+ }
18189
+ return false;
18190
+ };
18191
+ this.streamClient = options.streamClient;
18192
+ }
18193
+ }
18194
+ const readPermissionStatus = (permission) => {
18195
+ const state = getCurrentValue(permission.asStateObservable());
18196
+ switch (state) {
18197
+ case 'granted':
18198
+ return 'GRANTED';
18199
+ case 'denied':
18200
+ return 'FAILED';
18201
+ case 'prompting':
18202
+ return 'INITIATED';
18203
+ case 'prompt':
18204
+ default:
18205
+ return 'NOT_INITIATED';
18206
+ }
18207
+ };
18208
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
18209
+ const applyError = (pair, next) => {
18210
+ if (!pair)
18211
+ return;
18212
+ pair.lastError = next;
18213
+ };
18214
+ const applyErrorIfAbsent = (pair, next) => {
18215
+ if (!pair || pair.lastError)
18216
+ return;
18217
+ pair.lastError = next;
18218
+ };
18219
+ const mapCoordinatorHttpError = (err) => {
18220
+ if (err instanceof ErrorFromResponse) {
18221
+ return {
18222
+ reason: err.message,
18223
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18224
+ };
18225
+ }
18226
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18227
+ };
18228
+ const mapCoordinatorWsError = (err) => {
18229
+ if (err instanceof ErrorFromResponse) {
18230
+ return {
18231
+ reason: err.message,
18232
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18233
+ };
18234
+ }
18235
+ if (err instanceof Error) {
18236
+ try {
18237
+ const parsed = JSON.parse(err.message);
18238
+ if (typeof parsed.isWSFailure === 'boolean') {
18239
+ return {
18240
+ reason: parsed.message || err.message,
18241
+ code: !parsed.isWSFailure && parsed.code
18242
+ ? String(parsed.code)
18243
+ : 'SERVER_ERROR',
18244
+ };
18245
+ }
18246
+ }
18247
+ catch { }
18248
+ }
18249
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18250
+ };
18251
+ const mapWsJoinError = (err) => {
18252
+ if (err instanceof SfuJoinError) {
18253
+ const sfuError = err.errorEvent.error;
18254
+ return {
18255
+ reason: sfuError?.message || err.message,
18256
+ code: (sfuError && ErrorCode[sfuError.code]) || 'SFU_ERROR',
18257
+ };
18258
+ }
18259
+ const reason = errorMessage(err);
18260
+ if (err instanceof SfuTimeoutError) {
18261
+ return { reason, code: 'REQUEST_TIMEOUT' };
18262
+ }
18263
+ return { reason, code: 'SFU_ERROR' };
18264
+ };
18265
+
17580
18266
  /**
17581
18267
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
17582
18268
  */
@@ -17640,6 +18326,7 @@ class StreamVideoClient {
17640
18326
  }
17641
18327
  call = new Call({
17642
18328
  streamClient: this.streamClient,
18329
+ clientEventReporter: this.clientEventReporter,
17643
18330
  type: e.call.type,
17644
18331
  id: e.call.id,
17645
18332
  members: e.members,
@@ -17709,6 +18396,8 @@ class StreamVideoClient {
17709
18396
  user.id = '!anon';
17710
18397
  return this.connectAnonymousUser(user, tokenOrProvider);
17711
18398
  }
18399
+ const reporter = this.clientEventReporter;
18400
+ reporter.startCoordinatorConnection(user.id);
17712
18401
  const connectUserResponse = await withoutConcurrency(this.connectionConcurrencyTag, async () => {
17713
18402
  const client = this.streamClient;
17714
18403
  const { onConnectUserError, persistUserOnConnectionFailure } = client.options;
@@ -17718,14 +18407,15 @@ class StreamVideoClient {
17718
18407
  for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
17719
18408
  try {
17720
18409
  this.logger.trace(`Connecting user (${attempt})`, user);
17721
- return user.type === 'guest'
17722
- ? await client.connectGuestUser(user)
17723
- : await client.connectUser(user, tokenOrProvider);
18410
+ return await reporter.trackCoordinatorWs(() => user.type === 'guest'
18411
+ ? client.connectGuestUser(user)
18412
+ : client.connectUser(user, tokenOrProvider));
17724
18413
  }
17725
18414
  catch (err) {
17726
18415
  this.logger.warn(`Failed to connect a user (${attempt})`, err);
17727
18416
  errorQueue.push(err);
17728
18417
  if (attempt === maxConnectUserRetries - 1) {
18418
+ reporter.closeCoordinatorWs();
17729
18419
  onConnectUserError?.(err, errorQueue);
17730
18420
  throw err;
17731
18421
  }
@@ -17803,6 +18493,7 @@ class StreamVideoClient {
17803
18493
  return (call ??
17804
18494
  new Call({
17805
18495
  streamClient: this.streamClient,
18496
+ clientEventReporter: this.clientEventReporter,
17806
18497
  id: id,
17807
18498
  type: type,
17808
18499
  clientStore: this.writeableStateStore,
@@ -17827,6 +18518,7 @@ class StreamVideoClient {
17827
18518
  for (const c of response.calls) {
17828
18519
  const call = new Call({
17829
18520
  streamClient: this.streamClient,
18521
+ clientEventReporter: this.clientEventReporter,
17830
18522
  id: c.call.id,
17831
18523
  type: c.call.type,
17832
18524
  members: c.members,
@@ -17934,6 +18626,7 @@ class StreamVideoClient {
17934
18626
  const [callType, callId] = call_cid.split(':');
17935
18627
  call = new Call({
17936
18628
  streamClient: this.streamClient,
18629
+ clientEventReporter: this.clientEventReporter,
17937
18630
  type: callType,
17938
18631
  id: callId,
17939
18632
  clientStore: this.writeableStateStore,
@@ -17974,6 +18667,9 @@ class StreamVideoClient {
17974
18667
  this.logger = videoLoggerSystem.getLogger('client');
17975
18668
  this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
17976
18669
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
18670
+ this.clientEventReporter = new ClientEventReporter({
18671
+ streamClient: this.streamClient,
18672
+ });
17977
18673
  this.writeableStateStore = new StreamVideoWriteableStateStore();
17978
18674
  this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(this.writeableStateStore);
17979
18675
  if (typeof apiKeyOrArgs !== 'string' && apiKeyOrArgs.user) {
@@ -18087,6 +18783,7 @@ exports.ScreenShareState = ScreenShareState;
18087
18783
  exports.SfuEvents = events;
18088
18784
  exports.SfuJoinError = SfuJoinError;
18089
18785
  exports.SfuModels = models;
18786
+ exports.SfuTimeoutError = SfuTimeoutError;
18090
18787
  exports.SpeakerManager = SpeakerManager;
18091
18788
  exports.SpeakerState = SpeakerState;
18092
18789
  exports.StartClosedCaptionsRequestLanguageEnum = StartClosedCaptionsRequestLanguageEnum;