@stream-io/video-client 1.59.0 → 1.60.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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +544 -111
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +544 -110
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +544 -111
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +23 -3
  9. package/dist/src/coordinator/connection/types.d.ts +16 -0
  10. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/sorting/participants.d.ts +13 -0
  20. package/dist/src/stats/utils.d.ts +6 -0
  21. package/dist/src/store/CallState.d.ts +10 -1
  22. package/package.json +1 -1
  23. package/src/Call.ts +93 -61
  24. package/src/StreamSfuClient.ts +6 -2
  25. package/src/__tests__/Call.autodrop.test.ts +116 -64
  26. package/src/__tests__/Call.getRingState.test.ts +87 -0
  27. package/src/__tests__/Call.lifecycle.test.ts +23 -0
  28. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  29. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +32 -20
  31. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  32. package/src/coordinator/connection/client.ts +3 -2
  33. package/src/coordinator/connection/types.ts +19 -0
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/events/__tests__/call.test.ts +2 -217
  36. package/src/events/call.ts +1 -73
  37. package/src/events/callEventHandlers.ts +12 -23
  38. package/src/gen/coordinator/index.ts +67 -0
  39. package/src/reporting/ClientEventReporter.ts +30 -4
  40. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  41. package/src/ringing/RingStatePoller.ts +177 -0
  42. package/src/ringing/RingTimeout.ts +90 -0
  43. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  44. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  45. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  46. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  47. package/src/ringing/index.ts +4 -0
  48. package/src/ringing/reconcileRingState.ts +108 -0
  49. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  50. package/src/sorting/__tests__/presets.test.ts +188 -3
  51. package/src/sorting/__tests__/sorting.test.ts +22 -1
  52. package/src/sorting/participants.ts +20 -0
  53. package/src/sorting/presets.ts +4 -0
  54. package/src/stats/__tests__/utils.test.ts +33 -0
  55. package/src/stats/utils.ts +15 -0
  56. package/src/store/CallState.ts +25 -0
  57. package/src/store/__tests__/CallState.test.ts +61 -2
@@ -1,16 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { fromPartial } from '@total-typescript/shoehorn';
3
2
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
3
+ import { watchCallEnded, watchSfuCallEnded } from '../call';
4
4
  import {
5
- watchCallAccepted,
6
- watchCallEnded,
7
- watchCallRejected,
8
- watchSfuCallEnded,
9
- } from '../call';
10
- import {
11
- CallAcceptedEvent,
12
5
  CallEndedEvent,
13
- CallResponse,
14
6
  OwnCapability,
15
7
  RejectCallResponse,
16
8
  } from '../../gen/coordinator';
@@ -20,189 +12,7 @@ import { ClientEventReporter } from '../../reporting';
20
12
  import { SfuEvent } from '../../gen/video/sfu/event/events';
21
13
  import { CallEndedReason } from '../../gen/video/sfu/models/models';
22
14
 
23
- describe('Call ringing events', () => {
24
- describe(`call.accepted`, () => {
25
- it(`will ignore events from the current user`, async () => {
26
- const call = fakeCall();
27
- vi.spyOn(call, 'join');
28
- const handler = watchCallAccepted(call);
29
- const event: CallAcceptedEvent = {
30
- type: 'call.accepted',
31
- // @ts-expect-error incomplete data
32
- user: { id: 'test-user-id' },
33
- };
34
- await handler(event);
35
-
36
- expect(call.join).not.toHaveBeenCalled();
37
- });
38
-
39
- it(`will join the call for the caller if atleast one callee has accepted`, async () => {
40
- const call = fakeCall({ currentUserId: 'test-user' });
41
- vi.spyOn(call, 'join').mockImplementation(async () => {
42
- console.log(`TEST: join() called`);
43
- });
44
- const handler = watchCallAccepted(call);
45
- const event: CallAcceptedEvent = {
46
- type: 'call.accepted',
47
- // @ts-expect-error incomplete data
48
- user: { id: 'test-user-id-callee' },
49
- // @ts-expect-error incomplete data
50
- call: { created_by: { id: 'test-user' } },
51
- };
52
- await handler(event);
53
-
54
- expect(call.join).toHaveBeenCalled();
55
- });
56
- });
57
-
58
- it('will not join the call for the other callee automatically when someone accepts', async () => {
59
- const call = fakeCall({ currentUserId: 'test-user-id-callee-2' });
60
- vi.spyOn(call, 'join').mockImplementation(async () => {
61
- console.log(`TEST: join() called`);
62
- });
63
- const handler = watchCallAccepted(call);
64
- const event: CallAcceptedEvent = {
65
- type: 'call.accepted',
66
- // @ts-expect-error incomplete data
67
- user: { id: 'test-user-id-callee-1' },
68
- // @ts-expect-error incomplete data
69
- call: { created_by: { id: 'test-user-id-caller' } },
70
- };
71
-
72
- await handler(event);
73
-
74
- expect(call.join).not.toHaveBeenCalled();
75
- });
76
-
77
- describe(`call.rejected`, () => {
78
- it(`caller will leave the call if all callees have rejected`, async () => {
79
- const call = fakeCall({ currentUserId: 'm1' });
80
- call.state.updateFromCallResponse({
81
- ...fakeMetadata(),
82
- // @ts-expect-error type issue
83
- created_by: { id: 'm1' },
84
- });
85
- call.state.setMembers([
86
- // @ts-expect-error incomplete data
87
- { user_id: 'm1' },
88
- // @ts-expect-error incomplete data
89
- { user_id: 'm2' },
90
- // @ts-expect-error incomplete data
91
- { user_id: 'm3' },
92
- ]);
93
- call.state.setCallingState(CallingState.RINGING);
94
- vi.spyOn(call, 'leave').mockImplementation(async () => {
95
- console.log(`TEST: leave() called`);
96
- });
97
-
98
- const handler = watchCallRejected(call);
99
- // all members reject the call
100
- await handler({
101
- type: 'call.rejected',
102
- // @ts-expect-error type issue
103
- user: {
104
- id: 'm2',
105
- },
106
- call: {
107
- // @ts-expect-error type issue
108
- created_by: {
109
- id: 'm1',
110
- },
111
- // @ts-expect-error type issue
112
- session: {
113
- rejected_by: {
114
- m2: new Date().toISOString(),
115
- m3: new Date().toISOString(),
116
- },
117
- },
118
- },
119
- });
120
- expect(call.leave).toHaveBeenCalledWith({
121
- reject: true,
122
- reason: 'cancel',
123
- message: 'ring: everyone rejected',
124
- });
125
- });
126
-
127
- it(`caller will not leave the call if only one callee rejects`, async () => {
128
- const call = fakeCall();
129
- call.state.updateFromCallResponse({
130
- ...fakeMetadata(),
131
- // @ts-expect-error type issue
132
- created_by: { id: 'm0' },
133
- });
134
- // @ts-expect-error incomplete data
135
- call.state.setMembers([{ user_id: 'm1' }, { user_id: 'm2' }]);
136
- vi.spyOn(call, 'leave').mockImplementation(async () => {
137
- console.log(`TEST: leave() called`);
138
- });
139
- const handler = watchCallRejected(call);
140
-
141
- // only one member rejects the call
142
- const event: CallAcceptedEvent = {
143
- type: 'call.rejected',
144
- // @ts-expect-error type issue
145
- user: {
146
- id: 'm2',
147
- },
148
- call: {
149
- // @ts-expect-error type issue
150
- created_by: {
151
- id: 'm0',
152
- },
153
- // @ts-expect-error type issue
154
- session: {
155
- rejected_by: {
156
- m2: new Date().toISOString(),
157
- },
158
- },
159
- },
160
- };
161
- await handler(event);
162
-
163
- expect(call.leave).not.toHaveBeenCalled();
164
- });
165
-
166
- it('callee will leave the call if caller rejects', async () => {
167
- const call = fakeCall({ currentUserId: 'm1' });
168
- call.state.updateFromCallResponse({
169
- ...fakeMetadata(),
170
- // @ts-expect-error type issue
171
- created_by: { id: 'm0' },
172
- });
173
- // @ts-expect-error incomplete data
174
- call.state.setMembers([{ user_id: 'm1' }, { user_id: 'm2' }]);
175
- vi.spyOn(call, 'leave').mockImplementation(async () => {
176
- console.log(`TEST: leave() called`);
177
- });
178
- const handler = watchCallRejected(call);
179
-
180
- // only one member rejects the call
181
- const event: CallAcceptedEvent = {
182
- type: 'call.rejected',
183
- // @ts-expect-error type issue
184
- user: {
185
- id: 'm0',
186
- },
187
- call: {
188
- // @ts-expect-error type issue
189
- created_by: {
190
- id: 'm0',
191
- },
192
- // @ts-expect-error type issue
193
- session: {
194
- rejected_by: {
195
- m0: new Date().toISOString(),
196
- },
197
- },
198
- },
199
- };
200
- await handler(event);
201
-
202
- expect(call.leave).toHaveBeenCalled();
203
- });
204
- });
205
-
15
+ describe('Call lifecycle events', () => {
206
16
  describe(`call.ended`, () => {
207
17
  it(`will leave the call unless joined`, async () => {
208
18
  const call = fakeCall();
@@ -377,28 +187,3 @@ const fakeCall = ({ ring = true, currentUserId = 'test-user-id' } = {}) => {
377
187
  ringing: ring,
378
188
  });
379
189
  };
380
-
381
- const fakeMetadata = (): CallResponse => {
382
- return fromPartial({
383
- id: '12345',
384
- type: 'development',
385
- cid: 'development:12345',
386
-
387
- created_by: {
388
- id: 'test-user-id',
389
- },
390
- blocked_user_ids: [],
391
- egress: {},
392
-
393
- settings: {
394
- ring: {
395
- auto_cancel_timeout_ms: 30000,
396
- incoming_call_timeout_ms: 30000,
397
- missed_call_timeout_ms: 30000,
398
- },
399
- screensharing: {
400
- target_resolution: undefined,
401
- },
402
- },
403
- });
404
- };
@@ -1,81 +1,9 @@
1
1
  import { CallingState } from '../store';
2
2
  import { Call } from '../Call';
3
- import {
4
- CallAcceptedEvent,
5
- CallRejectedEvent,
6
- OwnCapability,
7
- } from '../gen/coordinator';
3
+ import { OwnCapability } from '../gen/coordinator';
8
4
  import { CallEnded } from '../gen/video/sfu/event/events';
9
5
  import { CallEndedReason } from '../gen/video/sfu/models/models';
10
6
 
11
- /**
12
- * Event handler that watched the delivery of `call.accepted`.
13
- * Once the event is received, the call is joined.
14
- */
15
- export const watchCallAccepted = (call: Call) => {
16
- return async function onCallAccepted(event: CallAcceptedEvent) {
17
- // We want to discard the event if it's from the current user
18
- if (event.user.id === call.currentUserId) return;
19
- const { state } = call;
20
- if (
21
- event.call.created_by.id === call.currentUserId &&
22
- state.callingState === CallingState.RINGING
23
- ) {
24
- await call.join();
25
- }
26
- };
27
- };
28
-
29
- /**
30
- * Event handler that watches delivery of `call.rejected` Websocket event.
31
- * Once the event is received, the call is left.
32
- */
33
- export const watchCallRejected = (call: Call) => {
34
- return async function onCallRejected(event: CallRejectedEvent) {
35
- // We want to discard the event if it's from the current user
36
- if (event.user.id === call.currentUserId) return;
37
- const { call: eventCall } = event;
38
- const { session: callSession } = eventCall;
39
-
40
- if (!callSession) {
41
- call.logger.warn(
42
- 'No call session provided. Ignoring call.rejected event.',
43
- event,
44
- );
45
- return;
46
- }
47
-
48
- const rejectedBy = callSession.rejected_by;
49
- const { members, callingState } = call.state;
50
- if (callingState !== CallingState.RINGING) {
51
- call.logger.info(
52
- 'Call is not in ringing mode (it is either accepted or rejected already). Ignoring call.rejected event.',
53
- event,
54
- );
55
- return;
56
- }
57
- if (call.isCreatedByMe) {
58
- const everyoneElseRejected = members
59
- .filter((m) => m.user_id !== call.currentUserId)
60
- .every((m) => rejectedBy[m.user_id]);
61
- if (everyoneElseRejected) {
62
- call.logger.info('everyone rejected, leaving the call');
63
- await call.leave({
64
- reject: true,
65
- reason: 'cancel',
66
- message: 'ring: everyone rejected',
67
- });
68
- }
69
- } else {
70
- if (rejectedBy[eventCall.created_by.id]) {
71
- call.logger.info('call creator rejected, leaving call');
72
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
73
- await call.leave({ message: 'ring: creator rejected' });
74
- }
75
- }
76
- };
77
- };
78
-
79
7
  /**
80
8
  * Event handler that watches the delivery of `call.ended` Websocket event.
81
9
  */
@@ -1,12 +1,11 @@
1
1
  import { Call } from '../Call';
2
+ import { reconcileRingState } from '../ringing';
2
3
  import { Dispatcher } from '../rtc';
3
4
  import {
4
5
  handleRemoteSoftMute,
5
6
  watchAudioLevelChanged,
6
- watchCallAccepted,
7
7
  watchCallEnded,
8
8
  watchCallGrantsUpdated,
9
- watchCallRejected,
10
9
  watchConnectionQualityChanged,
11
10
  watchDominantSpeakerChanged,
12
11
  watchInboundStateNotification,
@@ -21,16 +20,6 @@ import {
21
20
  watchTrackPublished,
22
21
  watchTrackUnpublished,
23
22
  } from '../events';
24
- import {
25
- AllCallEvents,
26
- AllClientCallEvents,
27
- CallEventListener,
28
- } from '../coordinator/connection/types';
29
-
30
- type RingCallEvents = Extract<
31
- AllClientCallEvents,
32
- 'call.accepted' | 'call.rejected' | 'call.missed'
33
- >;
34
23
 
35
24
  /**
36
25
  * Registers the default event handlers for a call during its lifecycle.
@@ -83,19 +72,19 @@ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
83
72
  * @param call the call to register event handlers for.
84
73
  */
85
74
  export const registerRingingCallEventHandlers = (call: Call) => {
86
- const coordinatorRingEvents: {
87
- [key in RingCallEvents]: (
88
- call: Call,
89
- ) => CallEventListener<AllCallEvents[key]>;
90
- } = {
91
- 'call.accepted': watchCallAccepted(call),
92
- 'call.rejected': watchCallRejected(call),
75
+ const reconcile = () => {
76
+ reconcileRingState(call, 'ring-ws').catch((err) => {
77
+ call.logger.error('Failed to reconcile the ring state', err);
78
+ });
93
79
  };
94
80
 
95
- const eventHandlers = Object.keys(coordinatorRingEvents).map((event) => {
96
- const eventName = event as RingCallEvents;
97
- return call.on(eventName, coordinatorRingEvents[eventName]);
98
- });
81
+ // each event needs its own closure. `call.missed` is deliberately absent:
82
+ // nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
83
+ // "nobody answered" case until the server-owned ring timeout lands.
84
+ const eventHandlers = [
85
+ call.on('call.accepted', () => reconcile()),
86
+ call.on('call.rejected', () => reconcile()),
87
+ ];
99
88
 
100
89
  return () => {
101
90
  eventHandlers.forEach((unsubscribe) => unsubscribe());
@@ -5130,6 +5130,73 @@ export interface GetCallResponse {
5130
5130
  */
5131
5131
  own_capabilities: Array<OwnCapability>;
5132
5132
  }
5133
+ /**
5134
+ * The ring state of a call session: who accepted, rejected, or missed the ring.
5135
+ * @export
5136
+ * @interface GetCallRingStateResponse
5137
+ */
5138
+ export interface GetCallRingStateResponse {
5139
+ /**
5140
+ * Users that accepted the call, mapped to when they accepted
5141
+ * @type {{ [key: string]: string; }}
5142
+ * @memberof GetCallRingStateResponse
5143
+ */
5144
+ accepted_by: { [key: string]: string };
5145
+ /**
5146
+ * The CID of the call
5147
+ * @type {string}
5148
+ * @memberof GetCallRingStateResponse
5149
+ */
5150
+ call_cid: string;
5151
+ /**
5152
+ * When the call ended
5153
+ * @type {string}
5154
+ * @memberof GetCallRingStateResponse
5155
+ */
5156
+ call_ended_at?: string;
5157
+ /**
5158
+ * The user that created the call, i.e. the caller
5159
+ * @type {string}
5160
+ * @memberof GetCallRingStateResponse
5161
+ */
5162
+ created_by_user_id: string;
5163
+ /**
5164
+ * Duration of the request in milliseconds
5165
+ * @type {string}
5166
+ * @memberof GetCallRingStateResponse
5167
+ */
5168
+ duration: string;
5169
+ /**
5170
+ * Users that missed the call, mapped to when they were marked as missed
5171
+ * @type {{ [key: string]: string; }}
5172
+ * @memberof GetCallRingStateResponse
5173
+ */
5174
+ missed_by: { [key: string]: string };
5175
+ /**
5176
+ * Users that rejected the call, mapped to when they rejected
5177
+ * @type {{ [key: string]: string; }}
5178
+ * @memberof GetCallRingStateResponse
5179
+ */
5180
+ rejected_by: { [key: string]: string };
5181
+ /**
5182
+ * When the call session ended
5183
+ * @type {string}
5184
+ * @memberof GetCallRingStateResponse
5185
+ */
5186
+ session_ended_at?: string;
5187
+ /**
5188
+ * The call session this state belongs to, empty when the call has never rung
5189
+ * @type {string}
5190
+ * @memberof GetCallRingStateResponse
5191
+ */
5192
+ session_id: string;
5193
+ /**
5194
+ * When the call session started
5195
+ * @type {string}
5196
+ * @memberof GetCallRingStateResponse
5197
+ */
5198
+ session_started_at?: string;
5199
+ }
5133
5200
  /**
5134
5201
  * Basic response information
5135
5202
  * @export
@@ -45,6 +45,13 @@ export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
45
45
  export type JoinReason =
46
46
  'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
47
47
 
48
+ /**
49
+ * What triggered an automatic join, reported as `source` on the call's
50
+ * CoordinatorJoin events. `ring-ws` is a ring WebSocket event, `ring-poll-api`
51
+ * is the ring state poller.
52
+ */
53
+ export type JoinSource = 'ring-ws' | 'ring-poll-api';
54
+
48
55
  export type ClientEventStandardCode =
49
56
  | 'CLIENT_ABORTED'
50
57
  | 'BACKEND_LEAVE'
@@ -67,6 +74,9 @@ export type ClientEventReporterOptions = {
67
74
  enabled?: boolean;
68
75
  };
69
76
 
77
+ // TODO OL: update OpenAPI
78
+ type ReportedClientEvent = ClientEvent & { source?: JoinSource };
79
+
70
80
  type StageError = {
71
81
  reason: string;
72
82
  code: string;
@@ -78,6 +88,7 @@ type StagePairState = {
78
88
  startedAt: number;
79
89
  joinAttemptIdSnapshot?: string;
80
90
  joinReasonSnapshot?: JoinReason;
91
+ joinSourceSnapshot?: JoinSource;
81
92
  userIdSnapshot?: string;
82
93
  lastError?: StageError;
83
94
  };
@@ -104,6 +115,7 @@ export class ClientEventReporter {
104
115
  private callContexts = new Map<string, CallReportContext>();
105
116
  private joinAttemptIds = new Map<string, string>();
106
117
  private joinReasons = new Map<string, JoinReason>();
118
+ private joinSources = new Map<string, JoinSource>();
107
119
  private coordinatorPairs = new Map<string, StagePairState>();
108
120
  private wsPairs = new Map<string, StagePairState>();
109
121
 
@@ -238,6 +250,7 @@ export class ClientEventReporter {
238
250
  this.callContexts.delete(cid);
239
251
  this.joinAttemptIds.delete(cid);
240
252
  this.joinReasons.delete(cid);
253
+ this.joinSources.delete(cid);
241
254
  this.coordinatorPairs.delete(cid);
242
255
  this.wsPairs.delete(cid);
243
256
 
@@ -269,15 +282,22 @@ export class ClientEventReporter {
269
282
 
270
283
  withJoinLifecycle = async <T>(
271
284
  cid: string,
272
- joinReason: JoinReason,
285
+ options: { joinReason: JoinReason; joinSource?: JoinSource },
273
286
  op: () => Promise<T>,
274
287
  ): Promise<T> => {
288
+ const { joinReason, joinSource } = options;
289
+
290
+ if (joinSource) this.joinSources.set(cid, joinSource);
291
+ else this.joinSources.delete(cid);
292
+
275
293
  this.startCorrelation(cid, joinReason);
276
294
  try {
277
295
  return await op();
278
296
  } catch (err) {
279
297
  this.closeCallPairs(cid);
280
298
  throw err;
299
+ } finally {
300
+ this.joinSources.delete(cid);
281
301
  }
282
302
  };
283
303
 
@@ -436,6 +456,7 @@ export class ClientEventReporter {
436
456
  startedAt: Date.now(),
437
457
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
438
458
  joinReasonSnapshot: this.joinReasons.get(cid),
459
+ joinSourceSnapshot: this.joinSources.get(cid),
439
460
  };
440
461
  this.coordinatorPairs.set(cid, pair);
441
462
  this.sendForCall(cid, {
@@ -443,6 +464,7 @@ export class ClientEventReporter {
443
464
  ...(pair.joinReasonSnapshot && {
444
465
  join_reason: pair.joinReasonSnapshot,
445
466
  }),
467
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
446
468
  event_type: 'initiated',
447
469
  });
448
470
  }
@@ -457,6 +479,7 @@ export class ClientEventReporter {
457
479
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
458
480
  ...this.sessionIdField(cid),
459
481
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
482
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
460
483
  event_type: 'completed',
461
484
  outcome: 'success',
462
485
  retry_count_attempt: pair.attempts - 1,
@@ -476,6 +499,7 @@ export class ClientEventReporter {
476
499
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
477
500
  ...this.sessionIdField(cid),
478
501
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
502
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
479
503
  event_type: 'completed',
480
504
  outcome: 'failure',
481
505
  retry_count_attempt: pair.attempts - 1,
@@ -705,17 +729,19 @@ export class ClientEventReporter {
705
729
  };
706
730
  };
707
731
 
708
- private send = (body: ClientEvent) => {
732
+ private send = (body: ReportedClientEvent) => {
709
733
  if (!this.enabled) return;
710
734
  void this.sendWithRetry(body);
711
735
  };
712
736
 
713
- private sendForCall = (cid: string, body: ClientEvent) => {
737
+ private sendForCall = (cid: string, body: ReportedClientEvent) => {
714
738
  if (!this.callContexts.has(cid)) return;
715
739
  this.send(body);
716
740
  };
717
741
 
718
- private sendWithRetry = async (body: ClientEvent): Promise<boolean> => {
742
+ private sendWithRetry = async (
743
+ body: ReportedClientEvent,
744
+ ): Promise<boolean> => {
719
745
  for (let attempt = 0; attempt < 5; attempt++) {
720
746
  try {
721
747
  await this.streamClient.doAxiosRequest<