@stream-io/video-client 1.52.0 → 1.53.0

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