@stream-io/video-client 1.52.1-beta.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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.browser.es.js +801 -123
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +801 -122
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +801 -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/errors/SfuTimeoutError.d.ts +8 -0
  12. package/dist/src/errors/index.d.ts +1 -0
  13. package/dist/src/gen/google/protobuf/struct.d.ts +1 -3
  14. package/dist/src/gen/google/protobuf/timestamp.d.ts +1 -3
  15. package/dist/src/gen/video/sfu/event/events.d.ts +1 -22
  16. package/dist/src/gen/video/sfu/models/models.d.ts +0 -4
  17. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +2 -23
  18. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  20. package/dist/src/reporting/index.d.ts +1 -0
  21. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  22. package/dist/src/rtc/Publisher.d.ts +1 -4
  23. package/dist/src/rtc/Subscriber.d.ts +0 -7
  24. package/dist/src/rtc/types.d.ts +24 -1
  25. package/dist/src/types.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +185 -106
  28. package/src/StreamSfuClient.ts +3 -3
  29. package/src/StreamVideoClient.ts +18 -3
  30. package/src/__tests__/Call.autodrop.test.ts +4 -1
  31. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  32. package/src/__tests__/Call.publishing.test.ts +4 -1
  33. package/src/__tests__/Call.test.ts +23 -0
  34. package/src/coordinator/connection/client.ts +5 -0
  35. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  37. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  38. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  39. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
  40. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  41. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  42. package/src/errors/SfuTimeoutError.ts +7 -0
  43. package/src/errors/index.ts +1 -0
  44. package/src/events/__tests__/call.test.ts +2 -0
  45. package/src/events/__tests__/mutes.test.ts +4 -1
  46. package/src/events/call.ts +8 -0
  47. package/src/gen/google/protobuf/struct.ts +12 -7
  48. package/src/gen/google/protobuf/timestamp.ts +7 -6
  49. package/src/gen/video/sfu/event/events.ts +25 -23
  50. package/src/gen/video/sfu/models/models.ts +1 -11
  51. package/src/gen/video/sfu/signal_rpc/signal.client.ts +29 -25
  52. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  53. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  54. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  56. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  57. package/src/helpers/client-details.ts +1 -1
  58. package/src/helpers/firstVideoFrame.ts +38 -0
  59. package/src/reporting/ClientEventReporter.ts +859 -0
  60. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  61. package/src/reporting/index.ts +1 -0
  62. package/src/rtc/BasePeerConnection.ts +30 -0
  63. package/src/rtc/Publisher.ts +0 -4
  64. package/src/rtc/Subscriber.ts +2 -28
  65. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  66. package/src/rtc/types.ts +34 -0
  67. package/src/types.ts +6 -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.0";
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,
@@ -13843,7 +13884,7 @@ class Call {
13843
13884
  * Use the [`StreamVideoClient.call`](./StreamVideoClient.md/#call)
13844
13885
  * method to construct a `Call` instance.
13845
13886
  */
13846
- 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, }) {
13847
13888
  /**
13848
13889
  * The state of this call.
13849
13890
  */
@@ -13880,7 +13921,6 @@ class Call {
13880
13921
  // maintain the order of publishing tracks to restore them after a reconnection
13881
13922
  // it shouldn't contain duplicates
13882
13923
  this.trackPublishOrder = [];
13883
- this.selfSubEnabled = false;
13884
13924
  this.hasJoinedOnce = false;
13885
13925
  this.deviceSettingsAppliedOnce = false;
13886
13926
  this.initialized = false;
@@ -14171,9 +14211,14 @@ class Call {
14171
14211
  this.sfuStatsReporter = undefined;
14172
14212
  this.lastStatsOptions = undefined;
14173
14213
  await this.subscriber?.dispose();
14214
+ this.clientEventReporter.abort(this.cid, {
14215
+ code: 'CLIENT_ABORTED',
14216
+ reason: leaveReason,
14217
+ });
14174
14218
  this.subscriber = undefined;
14175
14219
  await this.publisher?.dispose();
14176
14220
  this.publisher = undefined;
14221
+ this.clientEventReporter.unregisterCall(this.cid);
14177
14222
  await this.sfuClient?.leaveAndClose(leaveReason);
14178
14223
  this.sfuClient = undefined;
14179
14224
  this.trackSubscriptionManager.setSfuClient(undefined);
@@ -14225,30 +14270,6 @@ class Call {
14225
14270
  await Promise.all(stopOnLeavePromises);
14226
14271
  });
14227
14272
  };
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
14273
  /**
14253
14274
  * Update from the call response from the "call.ring" event
14254
14275
  * @internal
@@ -14395,7 +14416,7 @@ class Call {
14395
14416
  *
14396
14417
  * @returns a promise which resolves once the call join-flow has finished.
14397
14418
  */
14398
- this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, selfSubEnabled = false, ...data } = {}) => {
14419
+ this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
14399
14420
  const callingState = this.state.callingState;
14400
14421
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
14401
14422
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -14403,15 +14424,19 @@ class Call {
14403
14424
  if (data?.ring) {
14404
14425
  this.ringingSubject.next(true);
14405
14426
  }
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
14427
  const callingX = globalThis.streamRNVideoSDK?.callingX;
14410
14428
  if (callingX) {
14411
14429
  // for Android/iOS, we need to start the call in the callingx library as soon as possible
14412
14430
  await callingX.joinCall(this, this.clientStore.calls);
14413
14431
  }
14414
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
+ });
14415
14440
  this.joinResponseTimeout = joinResponseTimeout;
14416
14441
  this.rpcRequestTimeout = rpcRequestTimeout;
14417
14442
  // we will count the number of join failures per SFU.
@@ -14421,39 +14446,42 @@ class Call {
14421
14446
  const joinData = data;
14422
14447
  maxJoinRetries = Math.max(maxJoinRetries, 1);
14423
14448
  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());
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;
14450
14457
  }
14451
- if (attempt === maxJoinRetries - 1) {
14452
- 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
+ }
14453
14481
  }
14482
+ await sleep(retryInterval(attempt));
14454
14483
  }
14455
- await sleep(retryInterval(attempt));
14456
- }
14484
+ });
14457
14485
  }
14458
14486
  catch (error) {
14459
14487
  callingX?.endCall(this, 'error');
@@ -14482,7 +14510,7 @@ class Call {
14482
14510
  performingMigration ||
14483
14511
  data?.migrating_from) {
14484
14512
  try {
14485
- const joinResponse = await this.doJoinRequest(data);
14513
+ const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
14486
14514
  this.credentials = joinResponse.credentials;
14487
14515
  statsOptions = joinResponse.stats_options;
14488
14516
  this.lastStatsOptions = statsOptions;
@@ -14540,9 +14568,11 @@ class Call {
14540
14568
  const preferredSubscribeOptions = !isReconnecting
14541
14569
  ? this.getPreferredSubscribeOptions()
14542
14570
  : [];
14571
+ const unifiedSessionId = this.unifiedSessionId;
14572
+ const capabilities = Array.from(this.clientCapabilities);
14543
14573
  try {
14544
- const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
14545
- unifiedSessionId: this.unifiedSessionId,
14574
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await this.clientEventReporter.track(this.cid, 'WSJoin', () => sfuClient.join({
14575
+ unifiedSessionId,
14546
14576
  subscriberSdp,
14547
14577
  publisherSdp,
14548
14578
  clientDetails,
@@ -14550,9 +14580,9 @@ class Call {
14550
14580
  reconnectDetails,
14551
14581
  preferredPublishOptions,
14552
14582
  preferredSubscribeOptions,
14553
- capabilities: Array.from(this.clientCapabilities),
14583
+ capabilities,
14554
14584
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
14555
- });
14585
+ }));
14556
14586
  this.currentPublishOptions = publishOptions;
14557
14587
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
14558
14588
  if (callState) {
@@ -14764,6 +14794,16 @@ class Call {
14764
14794
  // "ICE never connected" failure budget can be cleared.
14765
14795
  this.iceFailuresWithoutConnect = 0;
14766
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
+ },
14767
14807
  };
14768
14808
  this.subscriber = new Subscriber(basePeerConnectionOptions);
14769
14809
  // anonymous users can't publish anything hence, there is no need
@@ -14773,9 +14813,7 @@ class Call {
14773
14813
  if (closePreviousInstances && this.publisher) {
14774
14814
  await this.publisher.dispose();
14775
14815
  }
14776
- this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
14777
- selfSubEnabled: this.selfSubEnabled,
14778
- });
14816
+ this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
14779
14817
  }
14780
14818
  this.statsReporter?.stop();
14781
14819
  if (this.statsReportingIntervalInMs > 0) {
@@ -15042,7 +15080,10 @@ class Call {
15042
15080
  const reconnectStartTime = Date.now();
15043
15081
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
15044
15082
  this.state.setCallingState(exports.CallingState.RECONNECTING);
15045
- 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));
15046
15087
  await this.restorePublishedTracks();
15047
15088
  this.restoreSubscribedTracks();
15048
15089
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
@@ -15066,11 +15107,11 @@ class Call {
15066
15107
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
15067
15108
  try {
15068
15109
  const currentSfu = currentSfuClient.edgeName;
15069
- await this.doJoin({
15110
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
15070
15111
  ...this.joinCallData,
15071
15112
  migrating_from: currentSfu,
15072
15113
  migrating_from_list: [currentSfu],
15073
- });
15114
+ }));
15074
15115
  }
15075
15116
  finally {
15076
15117
  // cleanup the migration_from field after the migration is complete or failed
@@ -15106,11 +15147,22 @@ class Call {
15106
15147
  this.registerReconnectHandlers = () => {
15107
15148
  // handles the legacy "goAway" event
15108
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
+ });
15109
15154
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
15110
15155
  });
15111
15156
  // handles the "error" event, through which the SFU can request a reconnect
15112
15157
  const unregisterOnError = this.on('error', (e) => {
15113
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
+ }
15114
15166
  // SFU_FULL is a join error, and when emitted, although it specifies a
15115
15167
  // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
15116
15168
  // This is now handled separately in the `call.join()` method.
@@ -15805,7 +15857,9 @@ class Call {
15805
15857
  this.leave({
15806
15858
  reject: true,
15807
15859
  reason: 'timeout',
15808
- 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`}`,
15809
15863
  }).catch((err) => {
15810
15864
  this.logger.error('Failed to drop call', err);
15811
15865
  });
@@ -16011,15 +16065,36 @@ class Call {
16011
16065
  * @param trackType the kind of video.
16012
16066
  */
16013
16067
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
16014
- const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16015
- if (!unbind)
16068
+ const unbindDynascale = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16069
+ const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(videoElement, sessionId, trackType);
16070
+ if (!unbindDynascale && !stopFirstFrameDetector)
16016
16071
  return;
16072
+ const unbind = () => {
16073
+ stopFirstFrameDetector?.();
16074
+ unbindDynascale?.();
16075
+ };
16017
16076
  this.leaveCallHooks.add(unbind);
16018
16077
  return () => {
16019
16078
  this.leaveCallHooks.delete(unbind);
16020
16079
  unbind();
16021
16080
  };
16022
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
+ };
16023
16098
  /**
16024
16099
  * Binds a DOM <audio> element to the given session id.
16025
16100
  *
@@ -16169,6 +16244,7 @@ class Call {
16169
16244
  this.ringingSubject = new rxjs.BehaviorSubject(ringing);
16170
16245
  this.watching = watching;
16171
16246
  this.streamClient = streamClient;
16247
+ this.clientEventReporter = clientEventReporter;
16172
16248
  this.clientStore = clientStore;
16173
16249
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
16174
16250
  this.logger = videoLoggerSystem.getLogger('Call');
@@ -16205,12 +16281,6 @@ class Call {
16205
16281
  get currentUserId() {
16206
16282
  return this.clientStore.connectedUser?.id;
16207
16283
  }
16208
- /**
16209
- * A flag indicating whether self-subscription is enabled for the call.
16210
- */
16211
- get isSelfSubEnabled() {
16212
- return this.selfSubEnabled;
16213
- }
16214
16284
  /**
16215
16285
  * A flag indicating whether the call was created by the current user.
16216
16286
  */
@@ -17397,10 +17467,12 @@ class StreamClient {
17397
17467
  this.logger.info('StreamClient.connect: this.wsConnection.connect()');
17398
17468
  return await this.wsConnection.connect(this.defaultWSTimeout);
17399
17469
  };
17470
+ this.getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
17471
+ "1.53.0";
17400
17472
  this.getUserAgent = () => {
17401
17473
  if (!this.cachedUserAgent) {
17402
17474
  const { clientAppIdentifier = {} } = this.options;
17403
- const { sdkName = 'js', sdkVersion = "1.52.1-beta.0", ...extras } = clientAppIdentifier;
17475
+ const { sdkName = 'js', sdkVersion = "1.53.0", ...extras } = clientAppIdentifier;
17404
17476
  this.cachedUserAgent = [
17405
17477
  `stream-video-${sdkName}-v${sdkVersion}`,
17406
17478
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -17577,6 +17649,602 @@ const createTokenOrProvider = (options) => {
17577
17649
  return token || tokenProvider;
17578
17650
  };
17579
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
+
17580
18248
  /**
17581
18249
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
17582
18250
  */
@@ -17640,6 +18308,7 @@ class StreamVideoClient {
17640
18308
  }
17641
18309
  call = new Call({
17642
18310
  streamClient: this.streamClient,
18311
+ clientEventReporter: this.clientEventReporter,
17643
18312
  type: e.call.type,
17644
18313
  id: e.call.id,
17645
18314
  members: e.members,
@@ -17709,6 +18378,8 @@ class StreamVideoClient {
17709
18378
  user.id = '!anon';
17710
18379
  return this.connectAnonymousUser(user, tokenOrProvider);
17711
18380
  }
18381
+ const reporter = this.clientEventReporter;
18382
+ reporter.startCoordinatorConnection(user.id);
17712
18383
  const connectUserResponse = await withoutConcurrency(this.connectionConcurrencyTag, async () => {
17713
18384
  const client = this.streamClient;
17714
18385
  const { onConnectUserError, persistUserOnConnectionFailure } = client.options;
@@ -17718,14 +18389,15 @@ class StreamVideoClient {
17718
18389
  for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
17719
18390
  try {
17720
18391
  this.logger.trace(`Connecting user (${attempt})`, user);
17721
- return user.type === 'guest'
17722
- ? await client.connectGuestUser(user)
17723
- : await client.connectUser(user, tokenOrProvider);
18392
+ return await reporter.trackCoordinatorWs(() => user.type === 'guest'
18393
+ ? client.connectGuestUser(user)
18394
+ : client.connectUser(user, tokenOrProvider));
17724
18395
  }
17725
18396
  catch (err) {
17726
18397
  this.logger.warn(`Failed to connect a user (${attempt})`, err);
17727
18398
  errorQueue.push(err);
17728
18399
  if (attempt === maxConnectUserRetries - 1) {
18400
+ reporter.closeCoordinatorWs();
17729
18401
  onConnectUserError?.(err, errorQueue);
17730
18402
  throw err;
17731
18403
  }
@@ -17803,6 +18475,7 @@ class StreamVideoClient {
17803
18475
  return (call ??
17804
18476
  new Call({
17805
18477
  streamClient: this.streamClient,
18478
+ clientEventReporter: this.clientEventReporter,
17806
18479
  id: id,
17807
18480
  type: type,
17808
18481
  clientStore: this.writeableStateStore,
@@ -17827,6 +18500,7 @@ class StreamVideoClient {
17827
18500
  for (const c of response.calls) {
17828
18501
  const call = new Call({
17829
18502
  streamClient: this.streamClient,
18503
+ clientEventReporter: this.clientEventReporter,
17830
18504
  id: c.call.id,
17831
18505
  type: c.call.type,
17832
18506
  members: c.members,
@@ -17934,6 +18608,7 @@ class StreamVideoClient {
17934
18608
  const [callType, callId] = call_cid.split(':');
17935
18609
  call = new Call({
17936
18610
  streamClient: this.streamClient,
18611
+ clientEventReporter: this.clientEventReporter,
17937
18612
  type: callType,
17938
18613
  id: callId,
17939
18614
  clientStore: this.writeableStateStore,
@@ -17974,6 +18649,9 @@ class StreamVideoClient {
17974
18649
  this.logger = videoLoggerSystem.getLogger('client');
17975
18650
  this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
17976
18651
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
18652
+ this.clientEventReporter = new ClientEventReporter({
18653
+ streamClient: this.streamClient,
18654
+ });
17977
18655
  this.writeableStateStore = new StreamVideoWriteableStateStore();
17978
18656
  this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(this.writeableStateStore);
17979
18657
  if (typeof apiKeyOrArgs !== 'string' && apiKeyOrArgs.user) {
@@ -18087,6 +18765,7 @@ exports.ScreenShareState = ScreenShareState;
18087
18765
  exports.SfuEvents = events;
18088
18766
  exports.SfuJoinError = SfuJoinError;
18089
18767
  exports.SfuModels = models;
18768
+ exports.SfuTimeoutError = SfuTimeoutError;
18090
18769
  exports.SpeakerManager = SpeakerManager;
18091
18770
  exports.SpeakerState = SpeakerState;
18092
18771
  exports.StartClosedCaptionsRequestLanguageEnum = StartClosedCaptionsRequestLanguageEnum;