@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/src/Call.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  ClientCapability,
131
131
  ClientDetails,
132
132
  Codec,
133
+ ErrorCode,
133
134
  ParticipantSource,
134
135
  PeerType,
135
136
  PublishOption,
@@ -145,10 +146,12 @@ import {
145
146
  StatsReporter,
146
147
  Tracer,
147
148
  } from './stats';
149
+ import type { ClientEventReporter, JoinReason } from './reporting';
148
150
  import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
149
151
  import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
150
152
  import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
151
153
  import { DynascaleManager } from './helpers/DynascaleManager';
154
+ import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
152
155
  import { ViewportTracker } from './helpers/ViewportTracker';
153
156
  import { PermissionsContext } from './permissions';
154
157
  import { CallTypes } from './CallType';
@@ -292,6 +295,7 @@ export class Call {
292
295
 
293
296
  private readonly clientStore: StreamVideoWriteableStateStore;
294
297
  public readonly streamClient: StreamClient;
298
+ public readonly clientEventReporter: ClientEventReporter;
295
299
  private sfuClient?: StreamSfuClient;
296
300
  private sfuClientTag = 0;
297
301
  private unifiedSessionId?: string;
@@ -357,6 +361,7 @@ export class Call {
357
361
  type,
358
362
  id,
359
363
  streamClient,
364
+ clientEventReporter,
360
365
  members,
361
366
  ownCapabilities,
362
367
  sortParticipantsBy,
@@ -370,6 +375,7 @@ export class Call {
370
375
  this.ringingSubject = new BehaviorSubject(ringing);
371
376
  this.watching = watching;
372
377
  this.streamClient = streamClient;
378
+ this.clientEventReporter = clientEventReporter;
373
379
  this.clientStore = clientStore;
374
380
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
375
381
  this.logger = videoLoggerSystem.getLogger('Call');
@@ -740,11 +746,18 @@ export class Call {
740
746
  this.lastStatsOptions = undefined;
741
747
 
742
748
  await this.subscriber?.dispose();
749
+ this.clientEventReporter.abort(this.cid, {
750
+ code: 'CLIENT_ABORTED',
751
+ reason: leaveReason,
752
+ });
753
+
743
754
  this.subscriber = undefined;
744
755
 
745
756
  await this.publisher?.dispose();
746
757
  this.publisher = undefined;
747
758
 
759
+ this.clientEventReporter.unregisterCall(this.cid);
760
+
748
761
  await this.sfuClient?.leaveAndClose(leaveReason);
749
762
  this.sfuClient = undefined;
750
763
  this.trackSubscriptionManager.setSfuClient(undefined);
@@ -1052,6 +1065,14 @@ export class Call {
1052
1065
 
1053
1066
  await this.setup();
1054
1067
 
1068
+ this.clientEventReporter.registerCall(this.cid, {
1069
+ callType: this.type,
1070
+ callId: this.id,
1071
+ getCallSessionId: () => this.state.session?.id ?? '',
1072
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
1073
+ getUserSessionId: () => this.sfuClient?.sessionId ?? '',
1074
+ });
1075
+
1055
1076
  this.joinResponseTimeout = joinResponseTimeout;
1056
1077
  this.rpcRequestTimeout = rpcRequestTimeout;
1057
1078
  // we will count the number of join failures per SFU.
@@ -1061,44 +1082,56 @@ export class Call {
1061
1082
  const joinData: JoinCallData = data;
1062
1083
  maxJoinRetries = Math.max(maxJoinRetries, 1);
1063
1084
  try {
1064
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1065
- try {
1066
- this.logger.trace(`Joining call (${attempt})`, this.cid);
1067
- await this.doJoin(data);
1068
- delete joinData.migrating_from;
1069
- delete joinData.migrating_from_list;
1070
- break;
1071
- } catch (err) {
1072
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1073
- if (
1074
- (err instanceof ErrorFromResponse && err.unrecoverable) ||
1075
- (err instanceof SfuJoinError && err.unrecoverable)
1076
- ) {
1077
- // if the error is unrecoverable, we should not retry as that signals
1078
- // that connectivity is good, but the coordinator doesn't allow the user
1079
- // to join the call due to some reason (e.g., ended call, expired token...)
1080
- throw err;
1081
- }
1085
+ await this.clientEventReporter.withJoinLifecycle(
1086
+ this.cid,
1087
+ 'first-attempt',
1088
+ async () => {
1089
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1090
+ try {
1091
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
1092
+ await this.doJoin(data);
1093
+ delete joinData.migrating_from;
1094
+ delete joinData.migrating_from_list;
1095
+ return;
1096
+ } catch (err) {
1097
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1098
+ if (
1099
+ (err instanceof ErrorFromResponse && err.unrecoverable) ||
1100
+ (err instanceof SfuJoinError && err.unrecoverable)
1101
+ ) {
1102
+ throw err;
1103
+ }
1082
1104
 
1083
- // immediately switch to a different SFU in case of recoverable join error
1084
- const switchSfu =
1085
- err instanceof SfuJoinError &&
1086
- SfuJoinError.isJoinErrorCode(err.errorEvent);
1087
-
1088
- const sfuId = this.credentials?.server.edge_name || '';
1089
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1090
- sfuJoinFailures.set(sfuId, failures);
1091
- if (switchSfu || failures >= 2) {
1092
- joinData.migrating_from = sfuId;
1093
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
1094
- }
1105
+ const switchSfu =
1106
+ err instanceof SfuJoinError &&
1107
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
1108
+
1109
+ const sfuId = this.credentials?.server.edge_name;
1110
+ if (sfuId) {
1111
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1112
+ sfuJoinFailures.set(sfuId, failures);
1113
+ if (switchSfu || failures >= 2) {
1114
+ joinData.migrating_from = sfuId;
1115
+ joinData.migrating_from_list = Array.from(
1116
+ sfuJoinFailures.keys(),
1117
+ );
1118
+ if (attempt < maxJoinRetries - 1) {
1119
+ this.clientEventReporter.startCorrelation(
1120
+ this.cid,
1121
+ 'first-attempt',
1122
+ );
1123
+ }
1124
+ }
1125
+ }
1095
1126
 
1096
- if (attempt === maxJoinRetries - 1) {
1097
- throw err;
1127
+ if (attempt === maxJoinRetries - 1) {
1128
+ throw err;
1129
+ }
1130
+ }
1131
+ await sleep(retryInterval(attempt));
1098
1132
  }
1099
- }
1100
- await sleep(retryInterval(attempt));
1101
- }
1133
+ },
1134
+ );
1102
1135
  } catch (error) {
1103
1136
  callingX?.endCall(this, 'error');
1104
1137
  throw error;
@@ -1136,7 +1169,11 @@ export class Call {
1136
1169
  data?.migrating_from
1137
1170
  ) {
1138
1171
  try {
1139
- const joinResponse = await this.doJoinRequest(data);
1172
+ const joinResponse = await this.clientEventReporter.track(
1173
+ this.cid,
1174
+ 'CoordinatorJoin',
1175
+ () => this.doJoinRequest(data),
1176
+ );
1140
1177
  this.credentials = joinResponse.credentials;
1141
1178
  statsOptions = joinResponse.stats_options;
1142
1179
  this.lastStatsOptions = statsOptions;
@@ -1202,20 +1239,24 @@ export class Call {
1202
1239
  ? this.getPreferredSubscribeOptions()
1203
1240
  : [];
1204
1241
 
1242
+ const unifiedSessionId = this.unifiedSessionId;
1243
+ const capabilities = Array.from(this.clientCapabilities);
1205
1244
  try {
1206
1245
  const { callState, fastReconnectDeadlineSeconds, publishOptions } =
1207
- await sfuClient.join({
1208
- unifiedSessionId: this.unifiedSessionId,
1209
- subscriberSdp,
1210
- publisherSdp,
1211
- clientDetails,
1212
- fastReconnect: performingFastReconnect,
1213
- reconnectDetails,
1214
- preferredPublishOptions,
1215
- preferredSubscribeOptions,
1216
- capabilities: Array.from(this.clientCapabilities),
1217
- source: ParticipantSource.WEBRTC_UNSPECIFIED,
1218
- });
1246
+ await this.clientEventReporter.track(this.cid, 'WSJoin', () =>
1247
+ sfuClient.join({
1248
+ unifiedSessionId,
1249
+ subscriberSdp,
1250
+ publisherSdp,
1251
+ clientDetails,
1252
+ fastReconnect: performingFastReconnect,
1253
+ reconnectDetails,
1254
+ preferredPublishOptions,
1255
+ preferredSubscribeOptions,
1256
+ capabilities,
1257
+ source: ParticipantSource.WEBRTC_UNSPECIFIED,
1258
+ }),
1259
+ );
1219
1260
 
1220
1261
  this.currentPublishOptions = publishOptions;
1221
1262
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
@@ -1486,6 +1527,18 @@ export class Call {
1486
1527
  // "ICE never connected" failure budget can be cleared.
1487
1528
  this.iceFailuresWithoutConnect = 0;
1488
1529
  },
1530
+ onPeerConnectionStateChange: (event) => {
1531
+ this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
1532
+ },
1533
+ onRemoteTrackUnmute: (trackType, trackId) => {
1534
+ const reportable =
1535
+ trackType === TrackType.AUDIO ||
1536
+ (isReactNative() && trackType === TrackType.VIDEO);
1537
+
1538
+ if (!reportable) return;
1539
+
1540
+ this.clientEventReporter.reportFirstFrame(this.cid, trackType, trackId);
1541
+ },
1489
1542
  };
1490
1543
 
1491
1544
  this.subscriber = new Subscriber(basePeerConnectionOptions);
@@ -1544,6 +1597,7 @@ export class Call {
1544
1597
  JoinCallResponse,
1545
1598
  JoinCallRequest
1546
1599
  >(`${this.streamClientBasePath}/join`, request);
1600
+
1547
1601
  this.state.updateFromCallResponse(joinResponse.call);
1548
1602
  this.state.setMembers(joinResponse.members);
1549
1603
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -1717,7 +1771,9 @@ export class Call {
1717
1771
  await this.networkAvailableTask?.promise;
1718
1772
 
1719
1773
  this.logger.info(
1720
- `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`,
1774
+ `[Reconnect] Reconnecting with strategy ${
1775
+ WebsocketReconnectStrategy[this.reconnectStrategy]
1776
+ }`,
1721
1777
  );
1722
1778
 
1723
1779
  switch (this.reconnectStrategy) {
@@ -1838,7 +1894,13 @@ export class Call {
1838
1894
  const reconnectStartTime = Date.now();
1839
1895
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
1840
1896
  this.state.setCallingState(CallingState.RECONNECTING);
1841
- await this.doJoin(this.joinCallData);
1897
+ const joinReason: JoinReason =
1898
+ this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
1899
+ ? 'network-available'
1900
+ : 'full-rejoin';
1901
+ await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
1902
+ this.doJoin(this.joinCallData),
1903
+ );
1842
1904
  await this.restorePublishedTracks();
1843
1905
  this.restoreSubscribedTracks();
1844
1906
  this.sfuStatsReporter?.sendReconnectionTime(
@@ -1870,11 +1932,16 @@ export class Call {
1870
1932
 
1871
1933
  try {
1872
1934
  const currentSfu = currentSfuClient.edgeName;
1873
- await this.doJoin({
1874
- ...this.joinCallData,
1875
- migrating_from: currentSfu,
1876
- migrating_from_list: [currentSfu],
1877
- });
1935
+ await this.clientEventReporter.withJoinLifecycle(
1936
+ this.cid,
1937
+ 'migration',
1938
+ () =>
1939
+ this.doJoin({
1940
+ ...this.joinCallData,
1941
+ migrating_from: currentSfu,
1942
+ migrating_from_list: [currentSfu],
1943
+ }),
1944
+ );
1878
1945
  } finally {
1879
1946
  // cleanup the migration_from field after the migration is complete or failed
1880
1947
  // as we don't want to keep dirty data in the join call data
@@ -1916,6 +1983,10 @@ export class Call {
1916
1983
  private registerReconnectHandlers = () => {
1917
1984
  // handles the legacy "goAway" event
1918
1985
  const unregisterGoAway = this.on('goAway', () => {
1986
+ this.clientEventReporter.captureWsError(this.cid, {
1987
+ code: 'SFU_GO_AWAY',
1988
+ reason: 'SFU goAway received during WS join',
1989
+ });
1919
1990
  this.reconnect(
1920
1991
  WebsocketReconnectStrategy.MIGRATE,
1921
1992
  ReconnectReason.GO_AWAY,
@@ -1925,6 +1996,13 @@ export class Call {
1925
1996
  // handles the "error" event, through which the SFU can request a reconnect
1926
1997
  const unregisterOnError = this.on('error', (e) => {
1927
1998
  const { reconnectStrategy: strategy, error } = e;
1999
+ if (!SfuJoinError.isJoinErrorCode(e)) {
2000
+ const code = error?.code ? ErrorCode[error.code] : undefined;
2001
+ this.clientEventReporter.captureWsError(this.cid, {
2002
+ code: code ?? 'SFU_ERROR',
2003
+ reason: error?.message || 'SFU error during WS join',
2004
+ });
2005
+ }
1928
2006
  // SFU_FULL is a join error, and when emitted, although it specifies a
1929
2007
  // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
1930
2008
  // This is now handled separately in the `call.join()` method.
@@ -2819,7 +2897,11 @@ export class Call {
2819
2897
  this.leave({
2820
2898
  reject: true,
2821
2899
  reason: 'timeout',
2822
- message: `ringing timeout - ${this.isCreatedByMe ? 'no one accepted' : `user didn't interact with incoming call screen`}`,
2900
+ message: `ringing timeout - ${
2901
+ this.isCreatedByMe
2902
+ ? 'no one accepted'
2903
+ : `user didn't interact with incoming call screen`
2904
+ }`,
2823
2905
  }).catch((err) => {
2824
2906
  this.logger.error('Failed to drop call', err);
2825
2907
  });
@@ -2880,7 +2962,9 @@ export class Call {
2880
2962
  filename: string,
2881
2963
  ): Promise<DeleteRecordingResponse> => {
2882
2964
  return this.streamClient.delete<DeleteRecordingResponse>(
2883
- `${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/recordings/${encodeURIComponent(filename)}`,
2965
+ `${this.streamClientBasePath}/${encodeURIComponent(
2966
+ callSessionId,
2967
+ )}/recordings/${encodeURIComponent(filename)}`,
2884
2968
  );
2885
2969
  };
2886
2970
 
@@ -2895,7 +2979,9 @@ export class Call {
2895
2979
  filename: string,
2896
2980
  ): Promise<DeleteTranscriptionResponse> => {
2897
2981
  return this.streamClient.delete<DeleteTranscriptionResponse>(
2898
- `${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/transcriptions/${encodeURIComponent(filename)}`,
2982
+ `${this.streamClientBasePath}/${encodeURIComponent(
2983
+ callSessionId,
2984
+ )}/transcriptions/${encodeURIComponent(filename)}`,
2899
2985
  );
2900
2986
  };
2901
2987
 
@@ -3121,13 +3207,25 @@ export class Call {
3121
3207
  sessionId: string,
3122
3208
  trackType: VideoTrackType,
3123
3209
  ) => {
3124
- const unbind = this.dynascaleManager?.bindVideoElement(
3210
+ const unbindDynascale = this.dynascaleManager?.bindVideoElement(
3125
3211
  videoElement,
3126
3212
  sessionId,
3127
3213
  trackType,
3128
3214
  );
3129
3215
 
3130
- if (!unbind) return;
3216
+ const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(
3217
+ videoElement,
3218
+ sessionId,
3219
+ trackType,
3220
+ );
3221
+
3222
+ if (!unbindDynascale && !stopFirstFrameDetector) return;
3223
+
3224
+ const unbind = () => {
3225
+ stopFirstFrameDetector?.();
3226
+ unbindDynascale?.();
3227
+ };
3228
+
3131
3229
  this.leaveCallHooks.add(unbind);
3132
3230
  return () => {
3133
3231
  this.leaveCallHooks.delete(unbind);
@@ -3135,6 +3233,32 @@ export class Call {
3135
3233
  };
3136
3234
  };
3137
3235
 
3236
+ private bindFirstVideoFrameDetector = (
3237
+ videoElement: HTMLVideoElement,
3238
+ sessionId: string,
3239
+ trackType: VideoTrackType,
3240
+ ) => {
3241
+ if (trackType !== 'videoTrack') return;
3242
+
3243
+ return createFirstVideoFrameDetector(videoElement, () => {
3244
+ this.reportFirstRenderedVideoFrame(sessionId);
3245
+ });
3246
+ };
3247
+
3248
+ private reportFirstRenderedVideoFrame = (sessionId: string) => {
3249
+ const participant = this.state.findParticipantBySessionId(sessionId);
3250
+ if (participant?.isLocalParticipant) return;
3251
+
3252
+ const trackId = participant?.videoStream?.getVideoTracks()[0]?.id;
3253
+ if (!trackId) return;
3254
+
3255
+ this.clientEventReporter.reportFirstFrame(
3256
+ this.cid,
3257
+ TrackType.VIDEO,
3258
+ trackId,
3259
+ );
3260
+ };
3261
+
3138
3262
  /**
3139
3263
  * Binds a DOM <audio> element to the given session id.
3140
3264
  *
@@ -40,7 +40,7 @@ import {
40
40
  import { withoutConcurrency } from './helpers/concurrency';
41
41
  import { getTimers } from './timers';
42
42
  import { Tracer, TraceSlice } from './stats';
43
- import { SfuJoinError } from './errors';
43
+ import { SfuJoinError, SfuTimeoutError } from './errors';
44
44
 
45
45
  export type StreamSfuClientConstructor = {
46
46
  /**
@@ -353,7 +353,7 @@ export class StreamSfuClient {
353
353
  timeoutId = setTimeout(() => {
354
354
  const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
355
355
  this.tracer?.trace('signal.timeout', message);
356
- reject(new Error(message));
356
+ reject(new SfuTimeoutError(message));
357
357
  }, this.joinResponseTimeout);
358
358
  }),
359
359
  ]),
@@ -644,7 +644,7 @@ export class StreamSfuClient {
644
644
  cleanupJoinSubscriptions();
645
645
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
646
646
  this.tracer?.trace('joinRequestTimeout', message);
647
- current.reject(new Error(message));
647
+ current.reject(new SfuTimeoutError(message));
648
648
  }, this.joinResponseTimeout);
649
649
 
650
650
  const joinRequest = SfuRequest.create({
@@ -42,6 +42,7 @@ import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
42
42
  import { isReactNative } from './helpers/platforms';
43
43
  import { withoutConcurrency } from './helpers/concurrency';
44
44
  import { enableTimerWorker } from './timers';
45
+ import { ClientEventReporter } from './reporting';
45
46
 
46
47
  /**
47
48
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
@@ -60,6 +61,7 @@ export class StreamVideoClient {
60
61
 
61
62
  protected readonly writeableStateStore: StreamVideoWriteableStateStore;
62
63
  streamClient: StreamClient;
64
+ readonly clientEventReporter: ClientEventReporter;
63
65
 
64
66
  private effectsRegistered = false;
65
67
  private eventHandlersToUnregister: Array<() => void> = [];
@@ -97,6 +99,9 @@ export class StreamVideoClient {
97
99
  this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
98
100
 
99
101
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
102
+ this.clientEventReporter = new ClientEventReporter({
103
+ streamClient: this.streamClient,
104
+ });
100
105
 
101
106
  this.writeableStateStore = new StreamVideoWriteableStateStore();
102
107
  this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(
@@ -214,6 +219,7 @@ export class StreamVideoClient {
214
219
 
215
220
  call = new Call({
216
221
  streamClient: this.streamClient,
222
+ clientEventReporter: this.clientEventReporter,
217
223
  type: e.call.type,
218
224
  id: e.call.id,
219
225
  members: e.members,
@@ -296,6 +302,9 @@ export class StreamVideoClient {
296
302
  return this.connectAnonymousUser(user as UserWithId, tokenOrProvider);
297
303
  }
298
304
 
305
+ const reporter = this.clientEventReporter;
306
+ reporter.startCoordinatorConnection(user.id);
307
+
299
308
  const connectUserResponse = await withoutConcurrency(
300
309
  this.connectionConcurrencyTag,
301
310
  async () => {
@@ -309,13 +318,16 @@ export class StreamVideoClient {
309
318
  for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
310
319
  try {
311
320
  this.logger.trace(`Connecting user (${attempt})`, user);
312
- return user.type === 'guest'
313
- ? await client.connectGuestUser(user)
314
- : await client.connectUser(user, tokenOrProvider);
321
+ return await reporter.trackCoordinatorWs(() =>
322
+ user.type === 'guest'
323
+ ? client.connectGuestUser(user)
324
+ : client.connectUser(user, tokenOrProvider),
325
+ );
315
326
  } catch (err) {
316
327
  this.logger.warn(`Failed to connect a user (${attempt})`, err);
317
328
  errorQueue.push(err as Error);
318
329
  if (attempt === maxConnectUserRetries - 1) {
330
+ reporter.closeCoordinatorWs();
319
331
  onConnectUserError?.(err as Error, errorQueue);
320
332
  throw err;
321
333
  }
@@ -415,6 +427,7 @@ export class StreamVideoClient {
415
427
  call ??
416
428
  new Call({
417
429
  streamClient: this.streamClient,
430
+ clientEventReporter: this.clientEventReporter,
418
431
  id: id,
419
432
  type: type,
420
433
  clientStore: this.writeableStateStore,
@@ -448,6 +461,7 @@ export class StreamVideoClient {
448
461
  for (const c of response.calls) {
449
462
  const call = new Call({
450
463
  streamClient: this.streamClient,
464
+ clientEventReporter: this.clientEventReporter,
451
465
  id: c.call.id,
452
466
  type: c.call.type,
453
467
  members: c.members,
@@ -591,6 +605,7 @@ export class StreamVideoClient {
591
605
  const [callType, callId] = call_cid.split(':');
592
606
  call = new Call({
593
607
  streamClient: this.streamClient,
608
+ clientEventReporter: this.clientEventReporter,
594
609
  type: callType,
595
610
  id: callId,
596
611
  clientStore: this.writeableStateStore,
@@ -3,6 +3,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { Call } from '../Call';
5
5
  import { StreamClient } from '../coordinator/connection/client';
6
+ import { ClientEventReporter } from '../reporting';
6
7
  import { generateUUIDv4 } from '../coordinator/connection/utils';
7
8
  import { CallingState, StreamVideoWriteableStateStore } from '../store';
8
9
 
@@ -14,10 +15,12 @@ describe('Auto drop ringing calls', () => {
14
15
  vi.useFakeTimers();
15
16
 
16
17
  const clientStore = new StreamVideoWriteableStateStore();
18
+ const streamClient = new StreamClient('abc');
17
19
  call = new Call({
18
20
  type: 'test',
19
21
  id: generateUUIDv4(),
20
- streamClient: new StreamClient('abc'),
22
+ streamClient,
23
+ clientEventReporter: new ClientEventReporter({ streamClient }),
21
24
  clientStore: clientStore,
22
25
  });
23
26
 
@@ -7,6 +7,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
7
7
  import { beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import { Call } from '../Call';
9
9
  import { StreamClient } from '../coordinator/connection/client';
10
+ import { ClientEventReporter } from '../reporting';
10
11
  import { generateUUIDv4 } from '../coordinator/connection/utils';
11
12
  import { StreamVideoWriteableStateStore } from '../store';
12
13
 
@@ -14,10 +15,12 @@ describe('Call lifecycle wiring', () => {
14
15
  let call: Call;
15
16
 
16
17
  beforeEach(() => {
18
+ const streamClient = new StreamClient('abc');
17
19
  call = new Call({
18
20
  type: 'test',
19
21
  id: generateUUIDv4(),
20
- streamClient: new StreamClient('abc'),
22
+ streamClient,
23
+ clientEventReporter: new ClientEventReporter({ streamClient }),
21
24
  clientStore: new StreamVideoWriteableStateStore(),
22
25
  });
23
26
  });
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { Call } from '../Call';
5
5
  import { Publisher } from '../rtc';
6
6
  import { StreamClient } from '../coordinator/connection/client';
7
+ import { ClientEventReporter } from '../reporting';
7
8
  import { generateUUIDv4 } from '../coordinator/connection/utils';
8
9
  import { PermissionsContext } from '../permissions';
9
10
  import { OwnCapability } from '../gen/coordinator';
@@ -15,10 +16,12 @@ describe('Publishing and Unpublishing tracks', () => {
15
16
  let call: Call;
16
17
 
17
18
  beforeEach(async () => {
19
+ const streamClient = new StreamClient('abc');
18
20
  call = new Call({
19
21
  type: 'test',
20
22
  id: generateUUIDv4(),
21
- streamClient: new StreamClient('abc'),
23
+ streamClient,
24
+ clientEventReporter: new ClientEventReporter({ streamClient }),
22
25
  clientStore: new StreamVideoWriteableStateStore(),
23
26
  });
24
27
 
@@ -300,6 +300,29 @@ describe('muting logic', () => {
300
300
  });
301
301
  });
302
302
 
303
+ describe('client event reporting', () => {
304
+ afterEach(() => {
305
+ vi.restoreAllMocks();
306
+ });
307
+
308
+ it('reports a client-aborted event and unregisters the call on leave', async () => {
309
+ const call = client.call('default', generateUUIDv4());
310
+ await call.getOrCreate();
311
+
312
+ const reporter = call.clientEventReporter;
313
+ const abortSpy = vi.spyOn(reporter, 'abort');
314
+ const unregisterSpy = vi.spyOn(reporter, 'unregisterCall');
315
+
316
+ await call.leave();
317
+
318
+ expect(abortSpy).toHaveBeenCalledWith(call.cid, {
319
+ code: 'CLIENT_ABORTED',
320
+ reason: expect.any(String),
321
+ });
322
+ expect(unregisterSpy).toHaveBeenCalledWith(call.cid);
323
+ });
324
+ });
325
+
303
326
  afterEach(() => {
304
327
  client.disconnectUser();
305
328
  });
@@ -617,6 +617,11 @@ export class StreamClient {
617
617
  return await this.wsConnection.connect(this.defaultWSTimeout);
618
618
  };
619
619
 
620
+ getSdkVersion = (): string =>
621
+ this.options.clientAppIdentifier?.sdkVersion ||
622
+ process.env.PKG_VERSION ||
623
+ '0.0.0';
624
+
620
625
  getUserAgent = (): string => {
621
626
  if (!this.cachedUserAgent) {
622
627
  const { clientAppIdentifier = {} } = this.options;
@@ -1,5 +1,6 @@
1
1
  import { Call } from '../../Call';
2
2
  import { StreamClient } from '../../coordinator/connection/client';
3
+ import { ClientEventReporter } from '../../reporting';
3
4
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
5
 
5
6
  import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
@@ -56,6 +57,12 @@ vi.mock('../../Call.ts', () => {
56
57
  };
57
58
  });
58
59
 
60
+ vi.mock('../../reporting/ClientEventReporter', () => ({
61
+ ClientEventReporter: vi.fn(function () {
62
+ return {};
63
+ }),
64
+ }));
65
+
59
66
  vi.mock('../../helpers/compatibility.ts', () => {
60
67
  console.log('MOCKING mobile device');
61
68
  return {
@@ -76,10 +83,12 @@ describe('CameraManager', () => {
76
83
 
77
84
  beforeEach(() => {
78
85
  const devicePersistence = { enabled: false, storageKey: '' };
86
+ const streamClient = new StreamClient('abc123', { devicePersistence });
79
87
  call = new Call({
80
88
  id: '',
81
89
  type: '',
82
- streamClient: new StreamClient('abc123', { devicePersistence }),
90
+ streamClient,
91
+ clientEventReporter: new ClientEventReporter({ streamClient }),
83
92
  clientStore: new StreamVideoWriteableStateStore(),
84
93
  });
85
94
  manager = new CameraManager(call, devicePersistence);