@stream-io/video-client 1.52.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.browser.es.js +796 -51
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +796 -50
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +796 -51
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +5 -1
  9. package/dist/src/StreamVideoClient.d.ts +2 -0
  10. package/dist/src/coordinator/connection/client.d.ts +1 -0
  11. package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
  12. package/dist/src/errors/index.d.ts +1 -0
  13. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  15. package/dist/src/reporting/index.d.ts +1 -0
  16. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  17. package/dist/src/rtc/types.d.ts +24 -1
  18. package/dist/src/types.d.ts +5 -0
  19. package/package.json +1 -1
  20. package/src/Call.ts +184 -60
  21. package/src/StreamSfuClient.ts +3 -3
  22. package/src/StreamVideoClient.ts +18 -3
  23. package/src/__tests__/Call.autodrop.test.ts +4 -1
  24. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  25. package/src/__tests__/Call.publishing.test.ts +4 -1
  26. package/src/__tests__/Call.test.ts +23 -0
  27. package/src/coordinator/connection/client.ts +5 -0
  28. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  29. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  30. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  31. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  32. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
  33. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  34. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  35. package/src/errors/SfuTimeoutError.ts +7 -0
  36. package/src/errors/index.ts +1 -0
  37. package/src/events/__tests__/call.test.ts +2 -0
  38. package/src/events/__tests__/mutes.test.ts +4 -1
  39. package/src/events/call.ts +8 -0
  40. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  41. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  42. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  43. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  44. package/src/helpers/firstVideoFrame.ts +38 -0
  45. package/src/reporting/ClientEventReporter.ts +859 -0
  46. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  47. package/src/reporting/index.ts +1 -0
  48. package/src/rtc/BasePeerConnection.ts +30 -0
  49. package/src/rtc/Subscriber.ts +1 -0
  50. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  51. package/src/rtc/types.ts +34 -0
  52. package/src/types.ts +6 -0
@@ -6640,7 +6640,7 @@ const getSdkVersion = (sdk) => {
6640
6640
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6641
6641
  };
6642
6642
 
6643
- const version = "1.52.0";
6643
+ const version = "1.53.0";
6644
6644
  const [major, minor, patch] = version.split('.');
6645
6645
  let sdkInfo = {
6646
6646
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7734,7 +7734,7 @@ class BasePeerConnection {
7734
7734
  /**
7735
7735
  * Constructs a new `BasePeerConnection` instance.
7736
7736
  */
7737
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
7737
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
7738
7738
  this.iceHasEverConnected = false;
7739
7739
  this.isIceRestarting = false;
7740
7740
  this.isDisposed = false;
@@ -7888,6 +7888,10 @@ class BasePeerConnection {
7888
7888
  this.onConnectionStateChange = async () => {
7889
7889
  const state = this.pc.connectionState;
7890
7890
  this.logger.debug(`Connection state changed`, state);
7891
+ this.fireOnPeerConnectionStateChange({
7892
+ stateType: 'peerConnection',
7893
+ state,
7894
+ });
7891
7895
  if (this.tracer && (state === 'connected' || state === 'failed')) {
7892
7896
  try {
7893
7897
  const stats = await this.stats.get();
@@ -7910,8 +7914,20 @@ class BasePeerConnection {
7910
7914
  this.onIceConnectionStateChange = () => {
7911
7915
  const state = this.pc.iceConnectionState;
7912
7916
  this.logger.debug(`ICE connection state changed`, state);
7917
+ this.fireOnPeerConnectionStateChange({ stateType: 'ice', state });
7913
7918
  this.handleConnectionStateUpdate(state);
7914
7919
  };
7920
+ this.fireOnPeerConnectionStateChange = (event) => {
7921
+ try {
7922
+ this.onPeerConnectionStateChange?.({
7923
+ peerType: this.peerType,
7924
+ ...event,
7925
+ });
7926
+ }
7927
+ catch (err) {
7928
+ this.logger.warn('onPeerConnectionStateChange listener threw', err);
7929
+ }
7930
+ };
7915
7931
  this.handleConnectionStateUpdate = (state) => {
7916
7932
  const { callingState } = this.state;
7917
7933
  if (callingState === CallingState.OFFLINE)
@@ -8026,6 +8042,8 @@ class BasePeerConnection {
8026
8042
  this.tag = tag;
8027
8043
  this.onReconnectionNeeded = onReconnectionNeeded;
8028
8044
  this.onIceConnected = onIceConnected;
8045
+ this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8046
+ this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8029
8047
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
8030
8048
  this.pc = this.createPeerConnection(connectionConfig);
8031
8049
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType, statsTimestampDriftThresholdMs);
@@ -8048,6 +8066,8 @@ class BasePeerConnection {
8048
8066
  this.preConnectStuckTimeout = undefined;
8049
8067
  this.onReconnectionNeeded = undefined;
8050
8068
  this.onIceConnected = undefined;
8069
+ this.onPeerConnectionStateChange = undefined;
8070
+ this.onRemoteTrackUnmute = undefined;
8051
8071
  this.isDisposed = true;
8052
8072
  this.detachEventHandlers();
8053
8073
  this.pc.close();
@@ -9037,6 +9057,7 @@ class Subscriber extends BasePeerConnection {
9037
9057
  track.addEventListener('unmute', () => {
9038
9058
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
9039
9059
  this.setRemoteTrackInterrupted(trackId, trackType, false);
9060
+ this.onRemoteTrackUnmute?.(trackType, track.id);
9040
9061
  });
9041
9062
  track.addEventListener('ended', () => {
9042
9063
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
@@ -9276,6 +9297,15 @@ class SfuJoinError extends Error {
9276
9297
  }
9277
9298
  }
9278
9299
 
9300
+ /**
9301
+ * An error thrown when a client-side SFU deadline (e.g., waiting for the
9302
+ * signaling WS to open or for the `joinResponse` to arrive) fires before
9303
+ * the awaited operation resolves. Allows consumers (e.g., the client event
9304
+ * reporter) to classify timeouts without relying on message wording.
9305
+ */
9306
+ class SfuTimeoutError extends Error {
9307
+ }
9308
+
9279
9309
  /**
9280
9310
  * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
9281
9311
  * to the underlying promise. The handler marks the rejection path as handled
@@ -9381,7 +9411,7 @@ class StreamSfuClient {
9381
9411
  timeoutId = setTimeout(() => {
9382
9412
  const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
9383
9413
  this.tracer?.trace('signal.timeout', message);
9384
- reject(new Error(message));
9414
+ reject(new SfuTimeoutError(message));
9385
9415
  }, this.joinResponseTimeout);
9386
9416
  }),
9387
9417
  ]));
@@ -9551,7 +9581,7 @@ class StreamSfuClient {
9551
9581
  cleanupJoinSubscriptions();
9552
9582
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
9553
9583
  this.tracer?.trace('joinRequestTimeout', message);
9554
- current.reject(new Error(message));
9584
+ current.reject(new SfuTimeoutError(message));
9555
9585
  }, this.joinResponseTimeout);
9556
9586
  const joinRequest = SfuRequest.create({
9557
9587
  requestPayload: {
@@ -9768,6 +9798,10 @@ const watchCallEnded = (call) => {
9768
9798
  const { callingState } = call.state;
9769
9799
  if (callingState !== CallingState.IDLE &&
9770
9800
  callingState !== CallingState.LEFT) {
9801
+ call.clientEventReporter.abort(call.cid, {
9802
+ code: 'BACKEND_LEAVE',
9803
+ reason: 'call.ended event received',
9804
+ });
9771
9805
  call
9772
9806
  .leave({ message: 'call.ended event received', reject: false })
9773
9807
  .catch((err) => {
@@ -9797,6 +9831,10 @@ const watchSfuCallEnded = (call) => {
9797
9831
  call.state.setEndedAt(new Date());
9798
9832
  const reason = CallEndedReason[e.reason];
9799
9833
  globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
9834
+ call.clientEventReporter.abort(call.cid, {
9835
+ code: 'BACKEND_LEAVE',
9836
+ reason: `callEnded received: ${reason}`,
9837
+ });
9800
9838
  await call.leave({ message: `callEnded received: ${reason}` });
9801
9839
  }
9802
9840
  catch (err) {
@@ -10949,6 +10987,40 @@ class DynascaleManager {
10949
10987
  }
10950
10988
  }
10951
10989
 
10990
+ /**
10991
+ * Invokes `onFirstFrame` once when the video element renders a frame.
10992
+ *
10993
+ * Uses `requestVideoFrameCallback` when available, falling back to `loadeddata`
10994
+ * for browsers that don't support it.
10995
+ */
10996
+ const createFirstVideoFrameDetector = (videoElement, onFirstFrame) => {
10997
+ let done = false;
10998
+ const notify = () => {
10999
+ if (done)
11000
+ return;
11001
+ done = true;
11002
+ onFirstFrame();
11003
+ };
11004
+ if (typeof videoElement.requestVideoFrameCallback === 'function') {
11005
+ const handle = videoElement.requestVideoFrameCallback(notify);
11006
+ return () => {
11007
+ done = true;
11008
+ videoElement.cancelVideoFrameCallback(handle);
11009
+ };
11010
+ }
11011
+ if (videoElement.readyState >= videoElement.HAVE_CURRENT_DATA) {
11012
+ queueMicrotask(notify);
11013
+ return () => {
11014
+ done = true;
11015
+ };
11016
+ }
11017
+ videoElement.addEventListener('loadeddata', notify, { once: true });
11018
+ return () => {
11019
+ done = true;
11020
+ videoElement.removeEventListener('loadeddata', notify);
11021
+ };
11022
+ };
11023
+
10952
11024
  const DEFAULT_THRESHOLD = 0.35;
10953
11025
  const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10954
11026
  videoTrack: VisibilityState.UNKNOWN,
@@ -13792,7 +13864,7 @@ class Call {
13792
13864
  * Use the [`StreamVideoClient.call`](./StreamVideoClient.md/#call)
13793
13865
  * method to construct a `Call` instance.
13794
13866
  */
13795
- constructor({ type, id, streamClient, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13867
+ constructor({ type, id, streamClient, clientEventReporter, members, ownCapabilities, sortParticipantsBy, clientStore, ringing = false, watching = false, }) {
13796
13868
  /**
13797
13869
  * The state of this call.
13798
13870
  */
@@ -14119,9 +14191,14 @@ class Call {
14119
14191
  this.sfuStatsReporter = undefined;
14120
14192
  this.lastStatsOptions = undefined;
14121
14193
  await this.subscriber?.dispose();
14194
+ this.clientEventReporter.abort(this.cid, {
14195
+ code: 'CLIENT_ABORTED',
14196
+ reason: leaveReason,
14197
+ });
14122
14198
  this.subscriber = undefined;
14123
14199
  await this.publisher?.dispose();
14124
14200
  this.publisher = undefined;
14201
+ this.clientEventReporter.unregisterCall(this.cid);
14125
14202
  await this.sfuClient?.leaveAndClose(leaveReason);
14126
14203
  this.sfuClient = undefined;
14127
14204
  this.trackSubscriptionManager.setSfuClient(undefined);
@@ -14333,6 +14410,13 @@ class Call {
14333
14410
  await callingX.joinCall(this, this.clientStore.calls);
14334
14411
  }
14335
14412
  await this.setup();
14413
+ this.clientEventReporter.registerCall(this.cid, {
14414
+ callType: this.type,
14415
+ callId: this.id,
14416
+ getCallSessionId: () => this.state.session?.id ?? '',
14417
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
14418
+ getUserSessionId: () => this.sfuClient?.sessionId ?? '',
14419
+ });
14336
14420
  this.joinResponseTimeout = joinResponseTimeout;
14337
14421
  this.rpcRequestTimeout = rpcRequestTimeout;
14338
14422
  // we will count the number of join failures per SFU.
@@ -14342,39 +14426,42 @@ class Call {
14342
14426
  const joinData = data;
14343
14427
  maxJoinRetries = Math.max(maxJoinRetries, 1);
14344
14428
  try {
14345
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14346
- try {
14347
- this.logger.trace(`Joining call (${attempt})`, this.cid);
14348
- await this.doJoin(data);
14349
- delete joinData.migrating_from;
14350
- delete joinData.migrating_from_list;
14351
- break;
14352
- }
14353
- catch (err) {
14354
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14355
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14356
- (err instanceof SfuJoinError && err.unrecoverable)) {
14357
- // if the error is unrecoverable, we should not retry as that signals
14358
- // that connectivity is good, but the coordinator doesn't allow the user
14359
- // to join the call due to some reason (e.g., ended call, expired token...)
14360
- throw err;
14361
- }
14362
- // immediately switch to a different SFU in case of recoverable join error
14363
- const switchSfu = err instanceof SfuJoinError &&
14364
- SfuJoinError.isJoinErrorCode(err.errorEvent);
14365
- const sfuId = this.credentials?.server.edge_name || '';
14366
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14367
- sfuJoinFailures.set(sfuId, failures);
14368
- if (switchSfu || failures >= 2) {
14369
- joinData.migrating_from = sfuId;
14370
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14429
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
14430
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
14431
+ try {
14432
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
14433
+ await this.doJoin(data);
14434
+ delete joinData.migrating_from;
14435
+ delete joinData.migrating_from_list;
14436
+ return;
14371
14437
  }
14372
- if (attempt === maxJoinRetries - 1) {
14373
- throw err;
14438
+ catch (err) {
14439
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
14440
+ if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
14441
+ (err instanceof SfuJoinError && err.unrecoverable)) {
14442
+ throw err;
14443
+ }
14444
+ const switchSfu = err instanceof SfuJoinError &&
14445
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
14446
+ const sfuId = this.credentials?.server.edge_name;
14447
+ if (sfuId) {
14448
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
14449
+ sfuJoinFailures.set(sfuId, failures);
14450
+ if (switchSfu || failures >= 2) {
14451
+ joinData.migrating_from = sfuId;
14452
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
14453
+ if (attempt < maxJoinRetries - 1) {
14454
+ this.clientEventReporter.startCorrelation(this.cid, 'first-attempt');
14455
+ }
14456
+ }
14457
+ }
14458
+ if (attempt === maxJoinRetries - 1) {
14459
+ throw err;
14460
+ }
14374
14461
  }
14462
+ await sleep(retryInterval(attempt));
14375
14463
  }
14376
- await sleep(retryInterval(attempt));
14377
- }
14464
+ });
14378
14465
  }
14379
14466
  catch (error) {
14380
14467
  callingX?.endCall(this, 'error');
@@ -14403,7 +14490,7 @@ class Call {
14403
14490
  performingMigration ||
14404
14491
  data?.migrating_from) {
14405
14492
  try {
14406
- const joinResponse = await this.doJoinRequest(data);
14493
+ const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
14407
14494
  this.credentials = joinResponse.credentials;
14408
14495
  statsOptions = joinResponse.stats_options;
14409
14496
  this.lastStatsOptions = statsOptions;
@@ -14461,9 +14548,11 @@ class Call {
14461
14548
  const preferredSubscribeOptions = !isReconnecting
14462
14549
  ? this.getPreferredSubscribeOptions()
14463
14550
  : [];
14551
+ const unifiedSessionId = this.unifiedSessionId;
14552
+ const capabilities = Array.from(this.clientCapabilities);
14464
14553
  try {
14465
- const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
14466
- unifiedSessionId: this.unifiedSessionId,
14554
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await this.clientEventReporter.track(this.cid, 'WSJoin', () => sfuClient.join({
14555
+ unifiedSessionId,
14467
14556
  subscriberSdp,
14468
14557
  publisherSdp,
14469
14558
  clientDetails,
@@ -14471,9 +14560,9 @@ class Call {
14471
14560
  reconnectDetails,
14472
14561
  preferredPublishOptions,
14473
14562
  preferredSubscribeOptions,
14474
- capabilities: Array.from(this.clientCapabilities),
14563
+ capabilities,
14475
14564
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
14476
- });
14565
+ }));
14477
14566
  this.currentPublishOptions = publishOptions;
14478
14567
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
14479
14568
  if (callState) {
@@ -14685,6 +14774,16 @@ class Call {
14685
14774
  // "ICE never connected" failure budget can be cleared.
14686
14775
  this.iceFailuresWithoutConnect = 0;
14687
14776
  },
14777
+ onPeerConnectionStateChange: (event) => {
14778
+ this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
14779
+ },
14780
+ onRemoteTrackUnmute: (trackType, trackId) => {
14781
+ const reportable = trackType === TrackType.AUDIO ||
14782
+ (isReactNative() && trackType === TrackType.VIDEO);
14783
+ if (!reportable)
14784
+ return;
14785
+ this.clientEventReporter.reportFirstFrame(this.cid, trackType, trackId);
14786
+ },
14688
14787
  };
14689
14788
  this.subscriber = new Subscriber(basePeerConnectionOptions);
14690
14789
  // anonymous users can't publish anything hence, there is no need
@@ -14961,7 +15060,10 @@ class Call {
14961
15060
  const reconnectStartTime = Date.now();
14962
15061
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
14963
15062
  this.state.setCallingState(CallingState.RECONNECTING);
14964
- await this.doJoin(this.joinCallData);
15063
+ const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
15064
+ ? 'network-available'
15065
+ : 'full-rejoin';
15066
+ await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
14965
15067
  await this.restorePublishedTracks();
14966
15068
  this.restoreSubscribedTracks();
14967
15069
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
@@ -14985,11 +15087,11 @@ class Call {
14985
15087
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
14986
15088
  try {
14987
15089
  const currentSfu = currentSfuClient.edgeName;
14988
- await this.doJoin({
15090
+ await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
14989
15091
  ...this.joinCallData,
14990
15092
  migrating_from: currentSfu,
14991
15093
  migrating_from_list: [currentSfu],
14992
- });
15094
+ }));
14993
15095
  }
14994
15096
  finally {
14995
15097
  // cleanup the migration_from field after the migration is complete or failed
@@ -15025,11 +15127,22 @@ class Call {
15025
15127
  this.registerReconnectHandlers = () => {
15026
15128
  // handles the legacy "goAway" event
15027
15129
  const unregisterGoAway = this.on('goAway', () => {
15130
+ this.clientEventReporter.captureWsError(this.cid, {
15131
+ code: 'SFU_GO_AWAY',
15132
+ reason: 'SFU goAway received during WS join',
15133
+ });
15028
15134
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
15029
15135
  });
15030
15136
  // handles the "error" event, through which the SFU can request a reconnect
15031
15137
  const unregisterOnError = this.on('error', (e) => {
15032
15138
  const { reconnectStrategy: strategy, error } = e;
15139
+ if (!SfuJoinError.isJoinErrorCode(e)) {
15140
+ const code = error?.code ? ErrorCode[error.code] : undefined;
15141
+ this.clientEventReporter.captureWsError(this.cid, {
15142
+ code: code ?? 'SFU_ERROR',
15143
+ reason: error?.message || 'SFU error during WS join',
15144
+ });
15145
+ }
15033
15146
  // SFU_FULL is a join error, and when emitted, although it specifies a
15034
15147
  // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
15035
15148
  // This is now handled separately in the `call.join()` method.
@@ -15724,7 +15837,9 @@ class Call {
15724
15837
  this.leave({
15725
15838
  reject: true,
15726
15839
  reason: 'timeout',
15727
- message: `ringing timeout - ${this.isCreatedByMe ? 'no one accepted' : `user didn't interact with incoming call screen`}`,
15840
+ message: `ringing timeout - ${this.isCreatedByMe
15841
+ ? 'no one accepted'
15842
+ : `user didn't interact with incoming call screen`}`,
15728
15843
  }).catch((err) => {
15729
15844
  this.logger.error('Failed to drop call', err);
15730
15845
  });
@@ -15930,15 +16045,36 @@ class Call {
15930
16045
  * @param trackType the kind of video.
15931
16046
  */
15932
16047
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
15933
- const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
15934
- if (!unbind)
16048
+ const unbindDynascale = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
16049
+ const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(videoElement, sessionId, trackType);
16050
+ if (!unbindDynascale && !stopFirstFrameDetector)
15935
16051
  return;
16052
+ const unbind = () => {
16053
+ stopFirstFrameDetector?.();
16054
+ unbindDynascale?.();
16055
+ };
15936
16056
  this.leaveCallHooks.add(unbind);
15937
16057
  return () => {
15938
16058
  this.leaveCallHooks.delete(unbind);
15939
16059
  unbind();
15940
16060
  };
15941
16061
  };
16062
+ this.bindFirstVideoFrameDetector = (videoElement, sessionId, trackType) => {
16063
+ if (trackType !== 'videoTrack')
16064
+ return;
16065
+ return createFirstVideoFrameDetector(videoElement, () => {
16066
+ this.reportFirstRenderedVideoFrame(sessionId);
16067
+ });
16068
+ };
16069
+ this.reportFirstRenderedVideoFrame = (sessionId) => {
16070
+ const participant = this.state.findParticipantBySessionId(sessionId);
16071
+ if (participant?.isLocalParticipant)
16072
+ return;
16073
+ const trackId = participant?.videoStream?.getVideoTracks()[0]?.id;
16074
+ if (!trackId)
16075
+ return;
16076
+ this.clientEventReporter.reportFirstFrame(this.cid, TrackType.VIDEO, trackId);
16077
+ };
15942
16078
  /**
15943
16079
  * Binds a DOM <audio> element to the given session id.
15944
16080
  *
@@ -16088,6 +16224,7 @@ class Call {
16088
16224
  this.ringingSubject = new BehaviorSubject(ringing);
16089
16225
  this.watching = watching;
16090
16226
  this.streamClient = streamClient;
16227
+ this.clientEventReporter = clientEventReporter;
16091
16228
  this.clientStore = clientStore;
16092
16229
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
16093
16230
  this.logger = videoLoggerSystem.getLogger('Call');
@@ -17312,10 +17449,12 @@ class StreamClient {
17312
17449
  this.logger.info('StreamClient.connect: this.wsConnection.connect()');
17313
17450
  return await this.wsConnection.connect(this.defaultWSTimeout);
17314
17451
  };
17452
+ this.getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
17453
+ "1.53.0";
17315
17454
  this.getUserAgent = () => {
17316
17455
  if (!this.cachedUserAgent) {
17317
17456
  const { clientAppIdentifier = {} } = this.options;
17318
- const { sdkName = 'js', sdkVersion = "1.52.0", ...extras } = clientAppIdentifier;
17457
+ const { sdkName = 'js', sdkVersion = "1.53.0", ...extras } = clientAppIdentifier;
17319
17458
  this.cachedUserAgent = [
17320
17459
  `stream-video-${sdkName}-v${sdkVersion}`,
17321
17460
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -17492,6 +17631,602 @@ const createTokenOrProvider = (options) => {
17492
17631
  return token || tokenProvider;
17493
17632
  };
17494
17633
 
17634
+ const pcKey = (cid, role) => `${cid}:${role}`;
17635
+ class ClientEventReporter {
17636
+ constructor(options) {
17637
+ this.logger = videoLoggerSystem.getLogger('ClientEventReporter');
17638
+ this.callContexts = new Map();
17639
+ this.joinAttemptIds = new Map();
17640
+ this.joinReasons = new Map();
17641
+ this.coordinatorPairs = new Map();
17642
+ this.wsPairs = new Map();
17643
+ this.peerConnectionPairs = new Map();
17644
+ this.pcEverConnected = new Map();
17645
+ this.firstFrameReported = new Set();
17646
+ /**
17647
+ * Starts a new coordinator connection correlation scope.
17648
+ *
17649
+ * @param userId the id of the user being connected. Captured here because
17650
+ * the `CoordinatorWS` stage emits before the connection flow assigns
17651
+ * the user to the client, so it can't be read from the client yet.
17652
+ */
17653
+ this.startCoordinatorConnection = (userId) => {
17654
+ this.coordinatorConnectId = generateUUIDv4();
17655
+ this.coordinatorConnectUserId = userId;
17656
+ return this.coordinatorConnectId;
17657
+ };
17658
+ this.trackCoordinatorWs = async (op) => {
17659
+ this.beginCoordinatorWs();
17660
+ try {
17661
+ const result = await op();
17662
+ this.succeedCoordinatorWs();
17663
+ return result;
17664
+ }
17665
+ catch (err) {
17666
+ applyError(this.coordinatorWsPair, mapCoordinatorWsError(err));
17667
+ throw err;
17668
+ }
17669
+ };
17670
+ this.beginCoordinatorWs = () => {
17671
+ if (!this.coordinatorWsPair) {
17672
+ this.coordinatorWsPair = {
17673
+ sid: generateUUIDv4(),
17674
+ attempts: 0,
17675
+ startedAt: Date.now(),
17676
+ userIdSnapshot: this.coordinatorConnectUserId,
17677
+ };
17678
+ this.send({
17679
+ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair),
17680
+ event_type: 'initiated',
17681
+ });
17682
+ }
17683
+ this.coordinatorWsPair.attempts++;
17684
+ };
17685
+ this.succeedCoordinatorWs = () => {
17686
+ const pair = this.coordinatorWsPair;
17687
+ if (!pair)
17688
+ return;
17689
+ this.send({
17690
+ ...this.buildCoordinatorWsCommon(pair),
17691
+ event_type: 'completed',
17692
+ outcome: 'success',
17693
+ retry_count_attempt: pair.attempts - 1,
17694
+ elapsed_time: Date.now() - pair.startedAt,
17695
+ });
17696
+ this.coordinatorWsPair = undefined;
17697
+ };
17698
+ this.closeCoordinatorWs = () => {
17699
+ const pair = this.coordinatorWsPair;
17700
+ if (!pair || !pair.lastError) {
17701
+ this.coordinatorWsPair = undefined;
17702
+ return;
17703
+ }
17704
+ const { reason, code } = pair.lastError;
17705
+ this.send({
17706
+ ...this.buildCoordinatorWsCommon(pair),
17707
+ event_type: 'completed',
17708
+ outcome: 'failure',
17709
+ retry_count_attempt: pair.attempts - 1,
17710
+ elapsed_time: Date.now() - pair.startedAt,
17711
+ retry_failure_reason: reason,
17712
+ retry_failure_code: code,
17713
+ });
17714
+ this.coordinatorWsPair = undefined;
17715
+ };
17716
+ this.buildCoordinatorWsCommon = (pair) => ({
17717
+ user_id: pair.userIdSnapshot ?? this.streamClient.userID,
17718
+ stage: 'CoordinatorWS',
17719
+ stage_id: pair.sid,
17720
+ ...(this.coordinatorConnectId && {
17721
+ coordinator_connect_id: this.coordinatorConnectId,
17722
+ }),
17723
+ timestamp: new Date().toISOString(),
17724
+ user_agent: this.streamClient.getUserAgent(),
17725
+ sdk_version: this.streamClient.getSdkVersion(),
17726
+ });
17727
+ this.emitMediaPermission = (cid) => {
17728
+ if (isReactNative() || !this.callContexts.has(cid))
17729
+ return;
17730
+ const pair = {
17731
+ sid: generateUUIDv4(),
17732
+ attempts: 0,
17733
+ startedAt: Date.now(),
17734
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17735
+ };
17736
+ this.send({
17737
+ ...this.buildCommon(cid, 'MediaDevicePermission', pair),
17738
+ ...this.sessionIdField(cid),
17739
+ microphone_permission_status: readPermissionStatus(getAudioBrowserPermission()),
17740
+ camera_permission_status: readPermissionStatus(getVideoBrowserPermission()),
17741
+ event_type: 'initiated',
17742
+ });
17743
+ };
17744
+ this.registerCall = (cid, ctx) => {
17745
+ this.callContexts.set(cid, ctx);
17746
+ };
17747
+ this.unregisterCall = (cid) => {
17748
+ this.callContexts.delete(cid);
17749
+ this.joinAttemptIds.delete(cid);
17750
+ this.joinReasons.delete(cid);
17751
+ this.coordinatorPairs.delete(cid);
17752
+ this.wsPairs.delete(cid);
17753
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17754
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17755
+ for (const role of ['publish', 'subscribe']) {
17756
+ const key = pcKey(cid, role);
17757
+ this.peerConnectionPairs.delete(key);
17758
+ this.pcEverConnected.delete(key);
17759
+ }
17760
+ };
17761
+ this.startCorrelation = (cid, joinReason) => {
17762
+ try {
17763
+ this.closeCallPairs(cid);
17764
+ this.joinAttemptIds.set(cid, generateUUIDv4());
17765
+ this.joinReasons.set(cid, joinReason);
17766
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
17767
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
17768
+ this.emitJoinInitiated(cid);
17769
+ this.emitMediaPermission(cid);
17770
+ }
17771
+ catch (err) {
17772
+ this.logger.warn('Failed to start join correlation', err);
17773
+ }
17774
+ };
17775
+ this.withJoinLifecycle = async (cid, joinReason, op) => {
17776
+ this.startCorrelation(cid, joinReason);
17777
+ try {
17778
+ return await op();
17779
+ }
17780
+ catch (err) {
17781
+ this.closeCallPairs(cid);
17782
+ throw err;
17783
+ }
17784
+ };
17785
+ this.track = async (cid, stage, op) => {
17786
+ this.beginAttempt(cid, stage);
17787
+ try {
17788
+ const result = await op();
17789
+ this.succeedAttempt(cid, stage);
17790
+ return result;
17791
+ }
17792
+ catch (err) {
17793
+ this.applyStageError(cid, stage, err);
17794
+ throw err;
17795
+ }
17796
+ };
17797
+ this.reportFirstFrame = (cid, trackType, trackId) => {
17798
+ const stage = trackType === TrackType.VIDEO
17799
+ ? 'FirstVideoFrame'
17800
+ : trackType === TrackType.AUDIO
17801
+ ? 'FirstAudioFrame'
17802
+ : undefined;
17803
+ if (!stage)
17804
+ return;
17805
+ const key = `${cid}:${stage}`;
17806
+ if (this.firstFrameReported.has(key))
17807
+ return;
17808
+ this.firstFrameReported.add(key);
17809
+ const pair = {
17810
+ sid: generateUUIDv4(),
17811
+ attempts: 0,
17812
+ startedAt: Date.now(),
17813
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17814
+ };
17815
+ const resolvedSfuId = this.getSfuId(cid);
17816
+ this.send({
17817
+ ...this.buildCommon(cid, stage, pair),
17818
+ ...this.sessionIdField(cid),
17819
+ ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
17820
+ track_id: trackId,
17821
+ event_type: 'initiated',
17822
+ });
17823
+ };
17824
+ this.captureWsError = (cid, opts) => {
17825
+ const pair = this.wsPairs.get(cid);
17826
+ if (!pair)
17827
+ return;
17828
+ applyError(pair, { reason: opts.reason, code: opts.code });
17829
+ };
17830
+ this.close = (cid) => {
17831
+ this.closeCallPairs(cid);
17832
+ };
17833
+ this.abort = (cid, opts) => {
17834
+ try {
17835
+ const { code, reason } = opts;
17836
+ const stageError = { code, reason };
17837
+ applyErrorIfAbsent(this.coordinatorPairs.get(cid), stageError);
17838
+ applyErrorIfAbsent(this.wsPairs.get(cid), stageError);
17839
+ this.failCoordinator(cid);
17840
+ this.failWs(cid);
17841
+ this.emitPeerConnectionFailure(cid, 'publish', code, reason, 'NOT_CONNECTED');
17842
+ this.emitPeerConnectionFailure(cid, 'subscribe', code, reason, 'NOT_CONNECTED');
17843
+ }
17844
+ catch (err) {
17845
+ this.logger.warn('Failed to report abort', err);
17846
+ }
17847
+ };
17848
+ this.closeCallPairs = (cid) => {
17849
+ if (this.coordinatorPairs.get(cid))
17850
+ this.failCoordinator(cid);
17851
+ if (this.wsPairs.get(cid))
17852
+ this.failWs(cid);
17853
+ for (const role of ['publish', 'subscribe']) {
17854
+ this.emitPeerConnectionFailure(cid, role, 'CLIENT_ABORTED', 'superseded by a new join attempt', 'NOT_CONNECTED');
17855
+ }
17856
+ };
17857
+ this.emitJoinInitiated = (cid) => {
17858
+ const joinAttemptId = this.joinAttemptIds.get(cid);
17859
+ if (!joinAttemptId)
17860
+ return;
17861
+ const coordinatorConnectId = this.coordinatorConnectId;
17862
+ this.send({
17863
+ user_id: this.streamClient.userID,
17864
+ stage: 'JoinInitiated',
17865
+ join_attempt_id: joinAttemptId,
17866
+ ...(coordinatorConnectId && {
17867
+ coordinator_connect_id: coordinatorConnectId,
17868
+ }),
17869
+ timestamp: new Date().toISOString(),
17870
+ user_agent: this.streamClient.getUserAgent(),
17871
+ sdk_version: this.streamClient.getSdkVersion(),
17872
+ event_type: 'initiated',
17873
+ });
17874
+ };
17875
+ this.beginAttempt = (cid, stage) => {
17876
+ if (stage === 'CoordinatorJoin')
17877
+ this.beginCoordinatorAttempt(cid);
17878
+ else
17879
+ this.beginWsAttempt(cid);
17880
+ };
17881
+ this.succeedAttempt = (cid, stage) => {
17882
+ if (stage === 'CoordinatorJoin')
17883
+ this.succeedCoordinator(cid);
17884
+ else
17885
+ this.succeedWs(cid);
17886
+ };
17887
+ this.applyStageError = (cid, stage, err) => {
17888
+ const pair = stage === 'CoordinatorJoin'
17889
+ ? this.coordinatorPairs.get(cid)
17890
+ : this.wsPairs.get(cid);
17891
+ applyErrorIfAbsent(pair, stage === 'CoordinatorJoin'
17892
+ ? mapCoordinatorHttpError(err)
17893
+ : mapWsJoinError(err));
17894
+ };
17895
+ this.beginCoordinatorAttempt = (cid) => {
17896
+ let pair = this.coordinatorPairs.get(cid);
17897
+ if (!pair) {
17898
+ pair = {
17899
+ sid: generateUUIDv4(),
17900
+ attempts: 0,
17901
+ startedAt: Date.now(),
17902
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17903
+ joinReasonSnapshot: this.joinReasons.get(cid),
17904
+ };
17905
+ this.coordinatorPairs.set(cid, pair);
17906
+ this.send({
17907
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17908
+ ...(pair.joinReasonSnapshot && {
17909
+ join_reason: pair.joinReasonSnapshot,
17910
+ }),
17911
+ event_type: 'initiated',
17912
+ });
17913
+ }
17914
+ pair.lastError = undefined;
17915
+ pair.attempts++;
17916
+ };
17917
+ this.succeedCoordinator = (cid) => {
17918
+ const pair = this.coordinatorPairs.get(cid);
17919
+ if (!pair)
17920
+ return;
17921
+ this.send({
17922
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
17923
+ ...this.sessionIdField(cid),
17924
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
17925
+ event_type: 'completed',
17926
+ outcome: 'success',
17927
+ retry_count_attempt: pair.attempts - 1,
17928
+ elapsed_time: Date.now() - pair.startedAt,
17929
+ });
17930
+ this.coordinatorPairs.delete(cid);
17931
+ };
17932
+ this.failCoordinator = (cid) => {
17933
+ const pair = this.coordinatorPairs.get(cid);
17934
+ if (!pair || !pair.lastError) {
17935
+ this.coordinatorPairs.delete(cid);
17936
+ return;
17937
+ }
17938
+ const { reason, code } = pair.lastError;
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: 'failure',
17945
+ retry_count_attempt: pair.attempts - 1,
17946
+ elapsed_time: Date.now() - pair.startedAt,
17947
+ retry_failure_reason: reason,
17948
+ retry_failure_code: code,
17949
+ });
17950
+ this.coordinatorPairs.delete(cid);
17951
+ };
17952
+ this.beginWsAttempt = (cid) => {
17953
+ let pair = this.wsPairs.get(cid);
17954
+ if (!pair) {
17955
+ pair = {
17956
+ sid: generateUUIDv4(),
17957
+ attempts: 0,
17958
+ startedAt: Date.now(),
17959
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
17960
+ };
17961
+ this.wsPairs.set(cid, pair);
17962
+ const sfuId = this.getSfuId(cid);
17963
+ this.send({
17964
+ ...this.buildCommon(cid, 'WSJoin', pair),
17965
+ ...this.sessionIdField(cid),
17966
+ ...(sfuId && { sfu_id: sfuId }),
17967
+ event_type: 'initiated',
17968
+ });
17969
+ }
17970
+ pair.lastError = undefined;
17971
+ pair.attempts++;
17972
+ };
17973
+ this.succeedWs = (cid) => {
17974
+ const pair = this.wsPairs.get(cid);
17975
+ if (!pair)
17976
+ return;
17977
+ const sfuId = this.getSfuId(cid);
17978
+ this.send({
17979
+ ...this.buildCommon(cid, 'WSJoin', pair),
17980
+ ...this.sessionIdField(cid),
17981
+ ...(sfuId && { sfu_id: sfuId }),
17982
+ event_type: 'completed',
17983
+ outcome: 'success',
17984
+ retry_count_attempt: pair.attempts - 1,
17985
+ elapsed_time: Date.now() - pair.startedAt,
17986
+ });
17987
+ this.wsPairs.delete(cid);
17988
+ };
17989
+ this.failWs = (cid) => {
17990
+ const pair = this.wsPairs.get(cid);
17991
+ if (!pair || !pair.lastError) {
17992
+ this.wsPairs.delete(cid);
17993
+ return;
17994
+ }
17995
+ const { reason, code } = pair.lastError;
17996
+ const sfuId = this.getSfuId(cid);
17997
+ this.send({
17998
+ ...this.buildCommon(cid, 'WSJoin', pair),
17999
+ ...this.sessionIdField(cid),
18000
+ event_type: 'completed',
18001
+ outcome: 'failure',
18002
+ retry_count_attempt: pair.attempts - 1,
18003
+ elapsed_time: Date.now() - pair.startedAt,
18004
+ ...(sfuId && { sfu_id: sfuId }),
18005
+ retry_failure_reason: reason,
18006
+ retry_failure_code: code,
18007
+ });
18008
+ this.wsPairs.delete(cid);
18009
+ };
18010
+ this.onPeerConnectionStateChange = (cid, event) => {
18011
+ const role = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
18012
+ if (event.stateType === 'ice' && event.state === 'failed') {
18013
+ this.emitPeerConnectionFailure(cid, role, 'ICE_CONNECTIVITY_FAILED', 'ICE connectivity checks failed', 'FAILED');
18014
+ return;
18015
+ }
18016
+ if (event.stateType === 'peerConnection' && event.state === 'failed') {
18017
+ this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed', 'CONNECTED');
18018
+ return;
18019
+ }
18020
+ if (event.stateType !== 'peerConnection')
18021
+ return;
18022
+ switch (event.state) {
18023
+ case 'connecting':
18024
+ if (this.peerConnectionPairs.has(pcKey(cid, role)))
18025
+ return;
18026
+ this.openPeerConnectionPair(cid, role);
18027
+ break;
18028
+ case 'connected':
18029
+ this.emitPeerConnectionSuccess(cid, role);
18030
+ this.pcEverConnected.set(pcKey(cid, role), true);
18031
+ break;
18032
+ }
18033
+ };
18034
+ this.openPeerConnectionPair = (cid, role) => {
18035
+ const key = pcKey(cid, role);
18036
+ const pair = {
18037
+ sid: generateUUIDv4(),
18038
+ attempts: 0,
18039
+ startedAt: Date.now(),
18040
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
18041
+ sfuId: this.getSfuId(cid),
18042
+ userSessionId: this.getUserSessionId(cid),
18043
+ wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
18044
+ };
18045
+ this.peerConnectionPairs.set(key, pair);
18046
+ this.send({
18047
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18048
+ ...this.sessionIdField(cid),
18049
+ peer_connection: role,
18050
+ was_previously_connected: pair.wasPreviouslyConnected,
18051
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18052
+ ...(pair.userSessionId && {
18053
+ user_session_id: pair.userSessionId,
18054
+ }),
18055
+ event_type: 'initiated',
18056
+ });
18057
+ };
18058
+ this.emitPeerConnectionSuccess = (cid, role) => {
18059
+ const key = pcKey(cid, role);
18060
+ const pair = this.peerConnectionPairs.get(key);
18061
+ if (!pair)
18062
+ return;
18063
+ this.send({
18064
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18065
+ ...this.sessionIdField(cid),
18066
+ peer_connection: role,
18067
+ was_previously_connected: pair.wasPreviouslyConnected,
18068
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18069
+ ...(pair.userSessionId && {
18070
+ user_session_id: pair.userSessionId,
18071
+ }),
18072
+ event_type: 'completed',
18073
+ outcome: 'success',
18074
+ retry_count_attempt: 0,
18075
+ elapsed_time: Date.now() - pair.startedAt,
18076
+ });
18077
+ this.peerConnectionPairs.delete(key);
18078
+ };
18079
+ this.emitPeerConnectionFailure = (cid, role, code, reason, iceState) => {
18080
+ const key = pcKey(cid, role);
18081
+ const pair = this.peerConnectionPairs.get(key);
18082
+ if (!pair)
18083
+ return;
18084
+ this.send({
18085
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
18086
+ ...this.sessionIdField(cid),
18087
+ peer_connection: role,
18088
+ was_previously_connected: pair.wasPreviouslyConnected,
18089
+ ...(pair.userSessionId && {
18090
+ user_session_id: pair.userSessionId,
18091
+ }),
18092
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
18093
+ event_type: 'completed',
18094
+ outcome: 'failure',
18095
+ retry_count_attempt: 0,
18096
+ elapsed_time: Date.now() - pair.startedAt,
18097
+ ice_state: iceState,
18098
+ retry_failure_reason: reason,
18099
+ retry_failure_code: code,
18100
+ });
18101
+ this.peerConnectionPairs.delete(key);
18102
+ };
18103
+ this.getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
18104
+ this.getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
18105
+ this.sessionIdField = (cid) => {
18106
+ const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
18107
+ return callSessionId ? { call_session_id: callSessionId } : {};
18108
+ };
18109
+ this.buildCommon = (cid, stage, pair) => {
18110
+ const ctx = this.callContexts.get(cid);
18111
+ const coordinatorConnectId = this.coordinatorConnectId;
18112
+ return {
18113
+ user_id: this.streamClient.userID,
18114
+ type: ctx?.callType ?? '',
18115
+ id: ctx?.callId ?? '',
18116
+ call_cid: cid,
18117
+ stage,
18118
+ stage_id: pair.sid,
18119
+ ...(pair.joinAttemptIdSnapshot && {
18120
+ join_attempt_id: pair.joinAttemptIdSnapshot,
18121
+ }),
18122
+ ...(coordinatorConnectId && {
18123
+ coordinator_connect_id: coordinatorConnectId,
18124
+ }),
18125
+ timestamp: new Date().toISOString(),
18126
+ user_agent: this.streamClient.getUserAgent(),
18127
+ sdk_version: this.streamClient.getSdkVersion(),
18128
+ };
18129
+ };
18130
+ this.send = (body) => {
18131
+ void this.sendWithRetry(body);
18132
+ };
18133
+ this.sendWithRetry = async (body) => {
18134
+ for (let attempt = 0; attempt < 5; attempt++) {
18135
+ try {
18136
+ await this.streamClient.doAxiosRequest('post', '/call_client_event', { events: [body] }, { publicEndpoint: true });
18137
+ return true;
18138
+ }
18139
+ catch (err) {
18140
+ const status = err?.response
18141
+ ?.status;
18142
+ if (typeof status === 'number' && status >= 400 && status < 500) {
18143
+ this.logger.debug(`Client event rejected (${status}), not retrying`, body.stage, body.event_type);
18144
+ return false;
18145
+ }
18146
+ if (attempt === 4) {
18147
+ this.logger.debug('Client event delivery failed after retries', body.stage, body.event_type, err);
18148
+ return false;
18149
+ }
18150
+ await sleep(retryInterval(attempt));
18151
+ }
18152
+ }
18153
+ return false;
18154
+ };
18155
+ this.streamClient = options.streamClient;
18156
+ }
18157
+ }
18158
+ const readPermissionStatus = (permission) => {
18159
+ const state = getCurrentValue(permission.asStateObservable());
18160
+ switch (state) {
18161
+ case 'granted':
18162
+ return 'GRANTED';
18163
+ case 'denied':
18164
+ return 'FAILED';
18165
+ case 'prompting':
18166
+ return 'INITIATED';
18167
+ case 'prompt':
18168
+ default:
18169
+ return 'NOT_INITIATED';
18170
+ }
18171
+ };
18172
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
18173
+ const applyError = (pair, next) => {
18174
+ if (!pair)
18175
+ return;
18176
+ pair.lastError = next;
18177
+ };
18178
+ const applyErrorIfAbsent = (pair, next) => {
18179
+ if (!pair || pair.lastError)
18180
+ return;
18181
+ pair.lastError = next;
18182
+ };
18183
+ const mapCoordinatorHttpError = (err) => {
18184
+ if (err instanceof ErrorFromResponse) {
18185
+ return {
18186
+ reason: err.message,
18187
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18188
+ };
18189
+ }
18190
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18191
+ };
18192
+ const mapCoordinatorWsError = (err) => {
18193
+ if (err instanceof ErrorFromResponse) {
18194
+ return {
18195
+ reason: err.message,
18196
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
18197
+ };
18198
+ }
18199
+ if (err instanceof Error) {
18200
+ try {
18201
+ const parsed = JSON.parse(err.message);
18202
+ if (typeof parsed.isWSFailure === 'boolean') {
18203
+ return {
18204
+ reason: parsed.message || err.message,
18205
+ code: !parsed.isWSFailure && parsed.code
18206
+ ? String(parsed.code)
18207
+ : 'SERVER_ERROR',
18208
+ };
18209
+ }
18210
+ }
18211
+ catch { }
18212
+ }
18213
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
18214
+ };
18215
+ const mapWsJoinError = (err) => {
18216
+ if (err instanceof SfuJoinError) {
18217
+ const sfuError = err.errorEvent.error;
18218
+ return {
18219
+ reason: sfuError?.message || err.message,
18220
+ code: (sfuError && ErrorCode[sfuError.code]) || 'SFU_ERROR',
18221
+ };
18222
+ }
18223
+ const reason = errorMessage(err);
18224
+ if (err instanceof SfuTimeoutError) {
18225
+ return { reason, code: 'REQUEST_TIMEOUT' };
18226
+ }
18227
+ return { reason, code: 'SFU_ERROR' };
18228
+ };
18229
+
17495
18230
  /**
17496
18231
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
17497
18232
  */
@@ -17555,6 +18290,7 @@ class StreamVideoClient {
17555
18290
  }
17556
18291
  call = new Call({
17557
18292
  streamClient: this.streamClient,
18293
+ clientEventReporter: this.clientEventReporter,
17558
18294
  type: e.call.type,
17559
18295
  id: e.call.id,
17560
18296
  members: e.members,
@@ -17624,6 +18360,8 @@ class StreamVideoClient {
17624
18360
  user.id = '!anon';
17625
18361
  return this.connectAnonymousUser(user, tokenOrProvider);
17626
18362
  }
18363
+ const reporter = this.clientEventReporter;
18364
+ reporter.startCoordinatorConnection(user.id);
17627
18365
  const connectUserResponse = await withoutConcurrency(this.connectionConcurrencyTag, async () => {
17628
18366
  const client = this.streamClient;
17629
18367
  const { onConnectUserError, persistUserOnConnectionFailure } = client.options;
@@ -17633,14 +18371,15 @@ class StreamVideoClient {
17633
18371
  for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
17634
18372
  try {
17635
18373
  this.logger.trace(`Connecting user (${attempt})`, user);
17636
- return user.type === 'guest'
17637
- ? await client.connectGuestUser(user)
17638
- : await client.connectUser(user, tokenOrProvider);
18374
+ return await reporter.trackCoordinatorWs(() => user.type === 'guest'
18375
+ ? client.connectGuestUser(user)
18376
+ : client.connectUser(user, tokenOrProvider));
17639
18377
  }
17640
18378
  catch (err) {
17641
18379
  this.logger.warn(`Failed to connect a user (${attempt})`, err);
17642
18380
  errorQueue.push(err);
17643
18381
  if (attempt === maxConnectUserRetries - 1) {
18382
+ reporter.closeCoordinatorWs();
17644
18383
  onConnectUserError?.(err, errorQueue);
17645
18384
  throw err;
17646
18385
  }
@@ -17718,6 +18457,7 @@ class StreamVideoClient {
17718
18457
  return (call ??
17719
18458
  new Call({
17720
18459
  streamClient: this.streamClient,
18460
+ clientEventReporter: this.clientEventReporter,
17721
18461
  id: id,
17722
18462
  type: type,
17723
18463
  clientStore: this.writeableStateStore,
@@ -17742,6 +18482,7 @@ class StreamVideoClient {
17742
18482
  for (const c of response.calls) {
17743
18483
  const call = new Call({
17744
18484
  streamClient: this.streamClient,
18485
+ clientEventReporter: this.clientEventReporter,
17745
18486
  id: c.call.id,
17746
18487
  type: c.call.type,
17747
18488
  members: c.members,
@@ -17849,6 +18590,7 @@ class StreamVideoClient {
17849
18590
  const [callType, callId] = call_cid.split(':');
17850
18591
  call = new Call({
17851
18592
  streamClient: this.streamClient,
18593
+ clientEventReporter: this.clientEventReporter,
17852
18594
  type: callType,
17853
18595
  id: callId,
17854
18596
  clientStore: this.writeableStateStore,
@@ -17889,6 +18631,9 @@ class StreamVideoClient {
17889
18631
  this.logger = videoLoggerSystem.getLogger('client');
17890
18632
  this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
17891
18633
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
18634
+ this.clientEventReporter = new ClientEventReporter({
18635
+ streamClient: this.streamClient,
18636
+ });
17892
18637
  this.writeableStateStore = new StreamVideoWriteableStateStore();
17893
18638
  this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(this.writeableStateStore);
17894
18639
  if (typeof apiKeyOrArgs !== 'string' && apiKeyOrArgs.user) {
@@ -17951,5 +18696,5 @@ const humanize = (n) => {
17951
18696
  return String(n);
17952
18697
  };
17953
18698
 
17954
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
18699
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
17955
18700
  //# sourceMappingURL=index.browser.es.js.map