@stream-io/video-client 1.59.1 → 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.
- package/CHANGELOG.md +7 -0
- package/dist/index.browser.es.js +495 -105
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +495 -105
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +495 -105
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +23 -3
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/package.json +1 -1
- package/src/Call.ts +93 -61
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +23 -0
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
package/dist/index.browser.es.js
CHANGED
|
@@ -5081,7 +5081,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5081
5081
|
}
|
|
5082
5082
|
};
|
|
5083
5083
|
|
|
5084
|
-
const version = "1.
|
|
5084
|
+
const version = "1.60.0";
|
|
5085
5085
|
const [major, minor, patch] = version.split('.');
|
|
5086
5086
|
let sdkInfo = {
|
|
5087
5087
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7114,6 +7114,30 @@ class CallState {
|
|
|
7114
7114
|
this.setCurrentValue(this.captioningSubject, call.captioning);
|
|
7115
7115
|
this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
|
|
7116
7116
|
};
|
|
7117
|
+
/**
|
|
7118
|
+
* Merges a polled ring state into the current session. Only the ring fields
|
|
7119
|
+
* are touched: the endpoint does not return the session roster.
|
|
7120
|
+
*
|
|
7121
|
+
* @internal
|
|
7122
|
+
*
|
|
7123
|
+
* @param ringState the ring state as returned by the coordinator.
|
|
7124
|
+
*/
|
|
7125
|
+
updateFromRingState = (ringState) => {
|
|
7126
|
+
this.setCurrentValue(this.sessionSubject, (session) => {
|
|
7127
|
+
if (!session || session.id !== ringState.session_id)
|
|
7128
|
+
return session;
|
|
7129
|
+
return {
|
|
7130
|
+
...session,
|
|
7131
|
+
accepted_by: ringState.accepted_by,
|
|
7132
|
+
rejected_by: ringState.rejected_by,
|
|
7133
|
+
missed_by: ringState.missed_by,
|
|
7134
|
+
ended_at: ringState.session_ended_at ?? session.ended_at,
|
|
7135
|
+
};
|
|
7136
|
+
});
|
|
7137
|
+
if (ringState.call_ended_at) {
|
|
7138
|
+
this.setEndedAt(new Date(ringState.call_ended_at));
|
|
7139
|
+
}
|
|
7140
|
+
};
|
|
7117
7141
|
/**
|
|
7118
7142
|
* Updates the call state with the data received from the SFU server.
|
|
7119
7143
|
*
|
|
@@ -7425,6 +7449,19 @@ const getSdkName = (sdk) => {
|
|
|
7425
7449
|
const getSdkVersion = (sdk) => {
|
|
7426
7450
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
7427
7451
|
};
|
|
7452
|
+
/**
|
|
7453
|
+
* Returns the identifier the SFU receives in the `X-Stream-Client` header,
|
|
7454
|
+
* e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
|
|
7455
|
+
* already gets through the client's user agent.
|
|
7456
|
+
*/
|
|
7457
|
+
const getStreamClientId = (sdk) => {
|
|
7458
|
+
const sdkPlatforms = {
|
|
7459
|
+
[SdkType.REACT]: 'react',
|
|
7460
|
+
[SdkType.REACT_NATIVE]: 'react-native',
|
|
7461
|
+
};
|
|
7462
|
+
const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
|
|
7463
|
+
return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
|
|
7464
|
+
};
|
|
7428
7465
|
|
|
7429
7466
|
/**
|
|
7430
7467
|
* Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
|
|
@@ -10306,7 +10343,10 @@ class StreamSfuClient {
|
|
|
10306
10343
|
this.rpc = createSignalClient({
|
|
10307
10344
|
baseUrl: server.url,
|
|
10308
10345
|
interceptors: [
|
|
10309
|
-
withHeaders({
|
|
10346
|
+
withHeaders({
|
|
10347
|
+
Authorization: `Bearer ${token}`,
|
|
10348
|
+
'X-Stream-Client': getStreamClientId(getSdkInfo()),
|
|
10349
|
+
}),
|
|
10310
10350
|
this.tracer && withRequestTracer(this.tracer.trace),
|
|
10311
10351
|
this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
|
|
10312
10352
|
withTimeout(rpcRequestTimeout, this.tracer?.trace),
|
|
@@ -10643,64 +10683,370 @@ class StreamSfuClient {
|
|
|
10643
10683
|
}
|
|
10644
10684
|
|
|
10645
10685
|
/**
|
|
10646
|
-
*
|
|
10647
|
-
*
|
|
10648
|
-
|
|
10649
|
-
|
|
10650
|
-
|
|
10651
|
-
|
|
10652
|
-
|
|
10653
|
-
|
|
10654
|
-
|
|
10655
|
-
|
|
10656
|
-
|
|
10657
|
-
|
|
10686
|
+
* Decides what a ringing call should do next, based on the current call state.
|
|
10687
|
+
*
|
|
10688
|
+
* The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
|
|
10689
|
+
* state poller both run this. They differ only in how the state got there: the
|
|
10690
|
+
* handlers rely on `CallState.updateFromEvent`, which runs before them, and the
|
|
10691
|
+
* poller applies the polled ring state itself.
|
|
10692
|
+
*
|
|
10693
|
+
* @param call the call to reconcile.
|
|
10694
|
+
* @param joinSource which of the two triggered this run, reported on the
|
|
10695
|
+
* caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
|
|
10696
|
+
* poller.
|
|
10697
|
+
* @returns whether the ring reached a terminal state. A failed join is not
|
|
10698
|
+
* terminal: the caller should keep trying while the ring is open.
|
|
10699
|
+
*/
|
|
10700
|
+
const reconcileRingState = async (call, joinSource) => {
|
|
10701
|
+
if (call.state.callingState !== CallingState.RINGING)
|
|
10702
|
+
return true;
|
|
10703
|
+
return call.isCreatedByMe
|
|
10704
|
+
? reconcileAsCaller(call, joinSource)
|
|
10705
|
+
: reconcileAsCallee(call);
|
|
10706
|
+
};
|
|
10707
|
+
const reconcileAsCaller = async (call, joinSource) => {
|
|
10708
|
+
const { session, members, endedAt } = call.state;
|
|
10709
|
+
const currentUserId = call.currentUserId;
|
|
10710
|
+
// checked before `accepted_by`: an ended session cannot be joined
|
|
10711
|
+
if (endedAt || session?.ended_at) {
|
|
10712
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
10713
|
+
// `leave` reports the remote end to callingx off `reason: 'ended'`
|
|
10714
|
+
return leave(call, {
|
|
10715
|
+
reject: false,
|
|
10716
|
+
reason: 'ended',
|
|
10717
|
+
message: 'ring: call ended',
|
|
10718
|
+
});
|
|
10719
|
+
}
|
|
10720
|
+
const acceptedBy = session?.accepted_by ?? {};
|
|
10721
|
+
if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
|
|
10722
|
+
call.logger.info('ring: the call was accepted, joining');
|
|
10723
|
+
try {
|
|
10724
|
+
await call.join({ joinSource });
|
|
10658
10725
|
}
|
|
10659
|
-
|
|
10726
|
+
catch (err) {
|
|
10727
|
+
// `doJoin` restores the ringing state when a join fails, so the ring is
|
|
10728
|
+
// still open. Report it unsettled and let the next poll retry.
|
|
10729
|
+
call.logger.error('Failed to join an accepted call', err);
|
|
10730
|
+
return false;
|
|
10731
|
+
}
|
|
10732
|
+
return true;
|
|
10733
|
+
}
|
|
10734
|
+
const otherMembers = members
|
|
10735
|
+
.filter((member) => member.user_id !== currentUserId)
|
|
10736
|
+
.map((member) => member.user_id);
|
|
10737
|
+
if (otherMembers.length === 0)
|
|
10738
|
+
return false;
|
|
10739
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
10740
|
+
if (otherMembers.every((userId) => rejectedBy[userId])) {
|
|
10741
|
+
call.logger.info('ring: everyone rejected, leaving');
|
|
10742
|
+
return leave(call, {
|
|
10743
|
+
reject: true,
|
|
10744
|
+
reason: 'cancel',
|
|
10745
|
+
message: 'ring: everyone rejected',
|
|
10746
|
+
});
|
|
10747
|
+
}
|
|
10748
|
+
return false;
|
|
10660
10749
|
};
|
|
10750
|
+
// the current user's own accept or reject, on this or another device, is
|
|
10751
|
+
// handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
|
|
10752
|
+
const reconcileAsCallee = async (call) => {
|
|
10753
|
+
const createdById = call.state.createdBy?.id;
|
|
10754
|
+
const rejectedBy = call.state.session?.rejected_by ?? {};
|
|
10755
|
+
if (createdById && rejectedBy[createdById]) {
|
|
10756
|
+
call.logger.info('ring: the caller cancelled, leaving');
|
|
10757
|
+
return leave(call, {
|
|
10758
|
+
reason: 'ended',
|
|
10759
|
+
message: 'ring: creator rejected',
|
|
10760
|
+
});
|
|
10761
|
+
}
|
|
10762
|
+
return false;
|
|
10763
|
+
};
|
|
10764
|
+
// `false` when the call could not be left, so the ring stays open for a retry.
|
|
10765
|
+
const leave = async (call, options) => {
|
|
10766
|
+
try {
|
|
10767
|
+
await call.leave(options);
|
|
10768
|
+
return true;
|
|
10769
|
+
}
|
|
10770
|
+
catch (err) {
|
|
10771
|
+
call.logger.error('Failed to leave a ringing call', err);
|
|
10772
|
+
return false;
|
|
10773
|
+
}
|
|
10774
|
+
};
|
|
10775
|
+
|
|
10776
|
+
// `doJoin` restores the previous calling state when a join fails, so JOINING can
|
|
10777
|
+
// go back to RINGING. Only a state the ring cannot return from ends the poller.
|
|
10778
|
+
const ringIsOver = (callingState) => callingState !== CallingState.RINGING &&
|
|
10779
|
+
callingState !== CallingState.JOINING;
|
|
10661
10780
|
/**
|
|
10662
|
-
*
|
|
10663
|
-
*
|
|
10781
|
+
* Polls the coordinator for the outcome of a ring the current user started.
|
|
10782
|
+
*
|
|
10783
|
+
* `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
|
|
10784
|
+
* with no store-and-forward, so a caller that drops one is left on a ringing
|
|
10785
|
+
* screen while the callee is already in the call. After a quiet period this
|
|
10786
|
+
* reads the ring state until a terminal outcome or the end of the ring window.
|
|
10664
10787
|
*/
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10788
|
+
class RingStatePoller {
|
|
10789
|
+
logger = videoLoggerSystem.getLogger('RingStatePoller');
|
|
10790
|
+
call;
|
|
10791
|
+
startAfterMs;
|
|
10792
|
+
intervalMs;
|
|
10793
|
+
sessionId;
|
|
10794
|
+
deadlineAt = 0;
|
|
10795
|
+
idleTimeoutId;
|
|
10796
|
+
intervalId;
|
|
10797
|
+
stopped = false;
|
|
10798
|
+
wasPolling = false;
|
|
10799
|
+
inFlight = false;
|
|
10800
|
+
unsubscribe = [];
|
|
10801
|
+
constructor(call, options = {}) {
|
|
10802
|
+
this.call = call;
|
|
10803
|
+
this.startAfterMs = options.startAfterMs ?? 15_000;
|
|
10804
|
+
this.intervalMs = options.intervalMs ?? 5_000;
|
|
10805
|
+
}
|
|
10806
|
+
/**
|
|
10807
|
+
* Starts polling. Does nothing when the call has no session yet.
|
|
10808
|
+
*/
|
|
10809
|
+
start = () => {
|
|
10810
|
+
if (this.stopped || this.sessionId)
|
|
10669
10811
|
return;
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10812
|
+
if (this.call.state.callingState !== CallingState.RINGING)
|
|
10813
|
+
return;
|
|
10814
|
+
// captured once: `call.ended` clears the call's current session
|
|
10815
|
+
const sessionId = this.call.state.session?.id;
|
|
10816
|
+
if (!sessionId) {
|
|
10817
|
+
this.logger.warn('the call has no session');
|
|
10674
10818
|
return;
|
|
10675
10819
|
}
|
|
10676
|
-
|
|
10677
|
-
const
|
|
10678
|
-
|
|
10679
|
-
|
|
10820
|
+
this.sessionId = sessionId;
|
|
10821
|
+
const ring = this.call.state.settings?.ring;
|
|
10822
|
+
const maxDurationMs = ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
|
|
10823
|
+
this.deadlineAt = Date.now() + maxDurationMs;
|
|
10824
|
+
// an incoming event proves the socket is alive, so the quiet period starts
|
|
10825
|
+
// over: in a group ring a single rejection does not settle the ring.
|
|
10826
|
+
this.unsubscribe.push(this.call.on('call.accepted', () => this.armIdleWindow()), this.call.on('call.rejected', () => this.armIdleWindow()), this.call.on('call.missed', () => this.armIdleWindow()),
|
|
10827
|
+
// the ring is over, whichever way it went. Joining an accepted call
|
|
10828
|
+
// never goes through `leave`, so this is the only signal for it.
|
|
10829
|
+
createSubscription(this.call.state.callingState$, (callingState) => {
|
|
10830
|
+
if (ringIsOver(callingState))
|
|
10831
|
+
this.stop();
|
|
10832
|
+
}));
|
|
10833
|
+
this.armIdleWindow();
|
|
10834
|
+
};
|
|
10835
|
+
/**
|
|
10836
|
+
* Pauses polling, keeping the captured session, the deadline and the event
|
|
10837
|
+
* subscriptions. Resuming does not extend the ring window.
|
|
10838
|
+
*/
|
|
10839
|
+
pause = () => {
|
|
10840
|
+
if (this.stopped)
|
|
10841
|
+
return;
|
|
10842
|
+
this.wasPolling = this.intervalId !== undefined;
|
|
10843
|
+
const timers = getTimers();
|
|
10844
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10845
|
+
timers.clearInterval(this.intervalId);
|
|
10846
|
+
this.idleTimeoutId = undefined;
|
|
10847
|
+
this.intervalId = undefined;
|
|
10848
|
+
};
|
|
10849
|
+
/**
|
|
10850
|
+
* Resumes a paused poller. Goes straight back to polling if it was already
|
|
10851
|
+
* past the quiet period, so a pause does not buy the ring another one.
|
|
10852
|
+
*/
|
|
10853
|
+
resume = () => {
|
|
10854
|
+
if (this.stopped || !this.sessionId)
|
|
10855
|
+
return;
|
|
10856
|
+
if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
|
|
10680
10857
|
return;
|
|
10681
10858
|
}
|
|
10682
|
-
if (
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
.every((m) => rejectedBy[m.user_id]);
|
|
10686
|
-
if (everyoneElseRejected) {
|
|
10687
|
-
call.logger.info('everyone rejected, leaving the call');
|
|
10688
|
-
await call.leave({
|
|
10689
|
-
reject: true,
|
|
10690
|
-
reason: 'cancel',
|
|
10691
|
-
message: 'ring: everyone rejected',
|
|
10692
|
-
});
|
|
10693
|
-
}
|
|
10859
|
+
if (!this.wasPolling) {
|
|
10860
|
+
this.armIdleWindow();
|
|
10861
|
+
return;
|
|
10694
10862
|
}
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10699
|
-
|
|
10863
|
+
this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
|
|
10864
|
+
this.runTick();
|
|
10865
|
+
};
|
|
10866
|
+
/**
|
|
10867
|
+
* Stops polling. The poller cannot be restarted.
|
|
10868
|
+
*/
|
|
10869
|
+
stop = () => {
|
|
10870
|
+
if (this.stopped)
|
|
10871
|
+
return;
|
|
10872
|
+
this.stopped = true;
|
|
10873
|
+
const timers = getTimers();
|
|
10874
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10875
|
+
timers.clearInterval(this.intervalId);
|
|
10876
|
+
this.idleTimeoutId = undefined;
|
|
10877
|
+
this.intervalId = undefined;
|
|
10878
|
+
this.unsubscribe.forEach((off) => off());
|
|
10879
|
+
this.unsubscribe = [];
|
|
10880
|
+
};
|
|
10881
|
+
armIdleWindow = () => {
|
|
10882
|
+
if (this.stopped)
|
|
10883
|
+
return;
|
|
10884
|
+
const timers = getTimers();
|
|
10885
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10886
|
+
timers.clearInterval(this.intervalId);
|
|
10887
|
+
this.intervalId = undefined;
|
|
10888
|
+
this.idleTimeoutId = timers.setTimeout(() => {
|
|
10889
|
+
this.idleTimeoutId = undefined;
|
|
10890
|
+
if (this.stopped)
|
|
10891
|
+
return;
|
|
10892
|
+
this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
|
|
10893
|
+
this.runTick();
|
|
10894
|
+
}, this.startAfterMs);
|
|
10895
|
+
};
|
|
10896
|
+
runTick = () => {
|
|
10897
|
+
this.tick().catch((err) => {
|
|
10898
|
+
this.logger.warn('Failed to poll the ring state', err);
|
|
10899
|
+
});
|
|
10900
|
+
};
|
|
10901
|
+
tick = async () => {
|
|
10902
|
+
if (this.stopped || this.inFlight || !this.sessionId)
|
|
10903
|
+
return;
|
|
10904
|
+
const { callingState } = this.call.state;
|
|
10905
|
+
if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
|
|
10906
|
+
this.stop();
|
|
10907
|
+
return;
|
|
10908
|
+
}
|
|
10909
|
+
// mid-join: nothing to reconcile until we know whether it succeeded
|
|
10910
|
+
if (callingState !== CallingState.RINGING)
|
|
10911
|
+
return;
|
|
10912
|
+
this.inFlight = true;
|
|
10913
|
+
try {
|
|
10914
|
+
const ringState = await this.call.getRingState(this.sessionId);
|
|
10915
|
+
if (this.stopped)
|
|
10916
|
+
return;
|
|
10917
|
+
this.call.state.updateFromRingState(ringState);
|
|
10918
|
+
if (await reconcileRingState(this.call, 'ring-poll-api'))
|
|
10919
|
+
this.stop();
|
|
10920
|
+
}
|
|
10921
|
+
catch (err) {
|
|
10922
|
+
// a missing session, or one of another call, will never resolve
|
|
10923
|
+
const status = err instanceof ErrorFromResponse ? err.status : undefined;
|
|
10924
|
+
if (status === 400 || status === 404) {
|
|
10925
|
+
this.logger.warn('Stopped polling the ring state', err);
|
|
10926
|
+
this.stop();
|
|
10700
10927
|
}
|
|
10928
|
+
else {
|
|
10929
|
+
this.logger.debug('Failed to poll the ring state', err);
|
|
10930
|
+
}
|
|
10931
|
+
}
|
|
10932
|
+
finally {
|
|
10933
|
+
this.inFlight = false;
|
|
10701
10934
|
}
|
|
10702
10935
|
};
|
|
10936
|
+
}
|
|
10937
|
+
|
|
10938
|
+
/**
|
|
10939
|
+
* Drops a call that has been ringing for too long: the caller cancels after
|
|
10940
|
+
* `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
|
|
10941
|
+
*
|
|
10942
|
+
* Either timeout being `0` means the call rings until something else settles it.
|
|
10943
|
+
*/
|
|
10944
|
+
class RingTimeout {
|
|
10945
|
+
call;
|
|
10946
|
+
timeoutId;
|
|
10947
|
+
deadlineAt;
|
|
10948
|
+
stopped = false;
|
|
10949
|
+
constructor(call) {
|
|
10950
|
+
this.call = call;
|
|
10951
|
+
}
|
|
10952
|
+
/**
|
|
10953
|
+
* Schedules an auto-drop timeout based on the call settings.
|
|
10954
|
+
* Applicable only for ringing calls.
|
|
10955
|
+
*/
|
|
10956
|
+
start = () => {
|
|
10957
|
+
if (this.stopped || this.timeoutId !== undefined)
|
|
10958
|
+
return;
|
|
10959
|
+
// ignore if the call is not ringing
|
|
10960
|
+
if (this.call.state.callingState !== CallingState.RINGING)
|
|
10961
|
+
return;
|
|
10962
|
+
const ring = this.call.state.settings?.ring;
|
|
10963
|
+
if (!ring)
|
|
10964
|
+
return;
|
|
10965
|
+
const isCaller = this.call.isCreatedByMe;
|
|
10966
|
+
const timeoutMs = isCaller
|
|
10967
|
+
? ring.auto_cancel_timeout_ms
|
|
10968
|
+
: ring.incoming_call_timeout_ms;
|
|
10969
|
+
// 0 means no auto-drop
|
|
10970
|
+
if (timeoutMs <= 0)
|
|
10971
|
+
return;
|
|
10972
|
+
const now = Date.now();
|
|
10973
|
+
this.deadlineAt ??= now + timeoutMs;
|
|
10974
|
+
const delayMs = Math.max(0, this.deadlineAt - now);
|
|
10975
|
+
const timers = getTimers();
|
|
10976
|
+
this.timeoutId = timers.setTimeout(() => {
|
|
10977
|
+
this.timeoutId = undefined;
|
|
10978
|
+
// A failed timeout-triggered leave can arm a fresh retry window.
|
|
10979
|
+
this.deadlineAt = undefined;
|
|
10980
|
+
if (this.stopped)
|
|
10981
|
+
return;
|
|
10982
|
+
// the call might have stopped ringing by this point, e.g. it was already
|
|
10983
|
+
// accepted and joined
|
|
10984
|
+
if (this.call.state.callingState !== CallingState.RINGING)
|
|
10985
|
+
return;
|
|
10986
|
+
this.call
|
|
10987
|
+
.leave({
|
|
10988
|
+
reject: true,
|
|
10989
|
+
reason: 'timeout',
|
|
10990
|
+
message: `ringing timeout - ${isCaller
|
|
10991
|
+
? 'no one accepted'
|
|
10992
|
+
: `user didn't interact with incoming call screen`}`,
|
|
10993
|
+
})
|
|
10994
|
+
.catch((err) => {
|
|
10995
|
+
videoLoggerSystem
|
|
10996
|
+
.getLogger('RingTimeout')
|
|
10997
|
+
.error('Failed to drop the call', err);
|
|
10998
|
+
});
|
|
10999
|
+
}, delayMs);
|
|
11000
|
+
};
|
|
11001
|
+
/**
|
|
11002
|
+
* Pauses the timeout while preserving its original deadline.
|
|
11003
|
+
*/
|
|
11004
|
+
pause = () => {
|
|
11005
|
+
if (this.stopped || this.timeoutId === undefined)
|
|
11006
|
+
return;
|
|
11007
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
11008
|
+
this.timeoutId = undefined;
|
|
11009
|
+
};
|
|
11010
|
+
/**
|
|
11011
|
+
* Cancels a scheduled auto-drop timeout. It cannot be armed again.
|
|
11012
|
+
*/
|
|
11013
|
+
stop = () => {
|
|
11014
|
+
if (this.stopped)
|
|
11015
|
+
return;
|
|
11016
|
+
this.stopped = true;
|
|
11017
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
11018
|
+
this.timeoutId = undefined;
|
|
11019
|
+
this.deadlineAt = undefined;
|
|
11020
|
+
};
|
|
11021
|
+
}
|
|
11022
|
+
|
|
11023
|
+
/**
|
|
11024
|
+
* Decides what a ringing call should do about the current user's own accept or
|
|
11025
|
+
* reject, which may have happened on another device.
|
|
11026
|
+
*
|
|
11027
|
+
* Accepting on *this* device also lands in `accepted_by`, so an acceptance only
|
|
11028
|
+
* means another device took the call when this one is still ringing.
|
|
11029
|
+
*
|
|
11030
|
+
* Only meaningful for a ringing call; the caller checks that.
|
|
11031
|
+
*/
|
|
11032
|
+
const resolveOwnRingOutcome = ({ session, currentUserId, callingState, }) => {
|
|
11033
|
+
if (!currentUserId)
|
|
11034
|
+
return { settledByMe: false };
|
|
11035
|
+
const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
|
|
11036
|
+
const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
|
|
11037
|
+
if (!acceptedByMe && !rejectedByMe)
|
|
11038
|
+
return { settledByMe: false };
|
|
11039
|
+
const answeredElsewhere = acceptedByMe && callingState === CallingState.RINGING;
|
|
11040
|
+
return {
|
|
11041
|
+
settledByMe: true,
|
|
11042
|
+
leaveReason: answeredElsewhere
|
|
11043
|
+
? 'answeredElsewhere'
|
|
11044
|
+
: rejectedByMe
|
|
11045
|
+
? 'rejected'
|
|
11046
|
+
: undefined,
|
|
11047
|
+
};
|
|
10703
11048
|
};
|
|
11049
|
+
|
|
10704
11050
|
/**
|
|
10705
11051
|
* Event handler that watches the delivery of `call.ended` Websocket event.
|
|
10706
11052
|
*/
|
|
@@ -11101,14 +11447,18 @@ const registerEventHandlers = (call, dispatcher) => {
|
|
|
11101
11447
|
* @param call the call to register event handlers for.
|
|
11102
11448
|
*/
|
|
11103
11449
|
const registerRingingCallEventHandlers = (call) => {
|
|
11104
|
-
const
|
|
11105
|
-
'
|
|
11106
|
-
|
|
11450
|
+
const reconcile = () => {
|
|
11451
|
+
reconcileRingState(call, 'ring-ws').catch((err) => {
|
|
11452
|
+
call.logger.error('Failed to reconcile the ring state', err);
|
|
11453
|
+
});
|
|
11107
11454
|
};
|
|
11108
|
-
|
|
11109
|
-
|
|
11110
|
-
|
|
11111
|
-
|
|
11455
|
+
// each event needs its own closure. `call.missed` is deliberately absent:
|
|
11456
|
+
// nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
|
|
11457
|
+
// "nobody answered" case until the server-owned ring timeout lands.
|
|
11458
|
+
const eventHandlers = [
|
|
11459
|
+
call.on('call.accepted', () => reconcile()),
|
|
11460
|
+
call.on('call.rejected', () => reconcile()),
|
|
11461
|
+
];
|
|
11112
11462
|
return () => {
|
|
11113
11463
|
eventHandlers.forEach((unsubscribe) => unsubscribe());
|
|
11114
11464
|
};
|
|
@@ -15156,7 +15506,8 @@ class Call {
|
|
|
15156
15506
|
statsReporter;
|
|
15157
15507
|
sfuStatsReporter;
|
|
15158
15508
|
lastStatsOptions;
|
|
15159
|
-
|
|
15509
|
+
ringTimeout;
|
|
15510
|
+
ringStatePoller;
|
|
15160
15511
|
clientStore;
|
|
15161
15512
|
streamClient;
|
|
15162
15513
|
clientEventReporter;
|
|
@@ -15334,22 +15685,19 @@ class Call {
|
|
|
15334
15685
|
createSubscription(this.state.session$, (session) => {
|
|
15335
15686
|
if (!this.ringing)
|
|
15336
15687
|
return;
|
|
15337
|
-
const
|
|
15338
|
-
|
|
15339
|
-
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
if (
|
|
15688
|
+
const { settledByMe, leaveReason } = resolveOwnRingOutcome({
|
|
15689
|
+
session,
|
|
15690
|
+
currentUserId: this.currentUserId,
|
|
15691
|
+
callingState: this.state.callingState,
|
|
15692
|
+
});
|
|
15693
|
+
if (settledByMe)
|
|
15343
15694
|
this.cancelAutoDrop();
|
|
15344
|
-
|
|
15345
|
-
|
|
15346
|
-
|
|
15347
|
-
|
|
15348
|
-
|
|
15349
|
-
|
|
15350
|
-
this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
|
|
15351
|
-
});
|
|
15352
|
-
}
|
|
15695
|
+
if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag))
|
|
15696
|
+
return;
|
|
15697
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
|
|
15698
|
+
this.leave().catch(() => {
|
|
15699
|
+
this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
|
|
15700
|
+
});
|
|
15353
15701
|
}));
|
|
15354
15702
|
};
|
|
15355
15703
|
handleRingingCall = () => {
|
|
@@ -15395,6 +15743,7 @@ class Call {
|
|
|
15395
15743
|
this.state.setCallingState(CallingState.RINGING);
|
|
15396
15744
|
}
|
|
15397
15745
|
this.scheduleAutoDrop();
|
|
15746
|
+
this.scheduleRingStatePolling();
|
|
15398
15747
|
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
15399
15748
|
}
|
|
15400
15749
|
};
|
|
@@ -15481,6 +15830,12 @@ class Call {
|
|
|
15481
15830
|
if (this.state.callingState === CallingState.LEFT) {
|
|
15482
15831
|
throw new Error('Cannot leave call that has already been left.');
|
|
15483
15832
|
}
|
|
15833
|
+
// before the first await: the calling state stays RINGING well into the
|
|
15834
|
+
// teardown, so pause both watchdogs before they can race this leave. They
|
|
15835
|
+
// keep their deadlines, so a failed leave resumes them without handing the
|
|
15836
|
+
// ring another window.
|
|
15837
|
+
this.ringTimeout?.pause();
|
|
15838
|
+
this.ringStatePoller?.pause();
|
|
15484
15839
|
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
15485
15840
|
const callingState = this.state.callingState;
|
|
15486
15841
|
if (callingState === CallingState.LEFT) {
|
|
@@ -15570,6 +15925,7 @@ class Call {
|
|
|
15570
15925
|
this.unifiedSessionId = undefined;
|
|
15571
15926
|
this.ringingSubject.next(false);
|
|
15572
15927
|
this.cancelAutoDrop();
|
|
15928
|
+
this.cancelRingStatePolling();
|
|
15573
15929
|
this.clientStore.unregisterCall(this);
|
|
15574
15930
|
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
15575
15931
|
isRingingTypeCall: this.ringing,
|
|
@@ -15608,6 +15964,15 @@ class Call {
|
|
|
15608
15964
|
this.logger.warn('Failed to dispose media engine', err);
|
|
15609
15965
|
});
|
|
15610
15966
|
}
|
|
15967
|
+
}).catch((err) => {
|
|
15968
|
+
if (!hasPending(this.joinLeaveConcurrencyTag) &&
|
|
15969
|
+
this.state.callingState === CallingState.RINGING) {
|
|
15970
|
+
// resume, never re-arm: a fresh watchdog would restart the ring
|
|
15971
|
+
// window this leave was already most of the way through
|
|
15972
|
+
this.ringTimeout?.start();
|
|
15973
|
+
this.ringStatePoller?.resume();
|
|
15974
|
+
}
|
|
15975
|
+
throw err;
|
|
15611
15976
|
});
|
|
15612
15977
|
};
|
|
15613
15978
|
/**
|
|
@@ -15758,6 +16123,21 @@ class Call {
|
|
|
15758
16123
|
ring = async (data = {}) => {
|
|
15759
16124
|
return this.streamClient.post(`${this.streamClientBasePath}/ring`, data);
|
|
15760
16125
|
};
|
|
16126
|
+
/**
|
|
16127
|
+
* Returns who accepted, rejected or missed the ring for a call session.
|
|
16128
|
+
* Safe to poll: it performs no writes and emits no events.
|
|
16129
|
+
*
|
|
16130
|
+
* @param callSessionId the call session to read. Defaults to the current one.
|
|
16131
|
+
* Pass it explicitly to read a session that has already ended, as ending a
|
|
16132
|
+
* call clears its current session.
|
|
16133
|
+
*/
|
|
16134
|
+
getRingState = async (callSessionId) => {
|
|
16135
|
+
const sessionId = callSessionId ?? this.state.session?.id;
|
|
16136
|
+
if (!sessionId) {
|
|
16137
|
+
throw new Error('Cannot read the ring state: the call has no session');
|
|
16138
|
+
}
|
|
16139
|
+
return this.streamClient.get(`${this.streamClientBasePath}/ring_state`, { call_session_id: sessionId });
|
|
16140
|
+
};
|
|
15761
16141
|
/**
|
|
15762
16142
|
* A shortcut for {@link Call.get} with `notify` parameter set to `true`.
|
|
15763
16143
|
* Will send a `call.notification` event to the call members.
|
|
@@ -15798,7 +16178,7 @@ class Call {
|
|
|
15798
16178
|
*
|
|
15799
16179
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
15800
16180
|
*/
|
|
15801
|
-
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
|
|
16181
|
+
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
|
|
15802
16182
|
const callingState = this.state.callingState;
|
|
15803
16183
|
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
|
|
15804
16184
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
@@ -15830,7 +16210,7 @@ class Call {
|
|
|
15830
16210
|
const joinData = data;
|
|
15831
16211
|
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
15832
16212
|
try {
|
|
15833
|
-
await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
|
|
16213
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
|
|
15834
16214
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
15835
16215
|
try {
|
|
15836
16216
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
@@ -16556,7 +16936,7 @@ class Call {
|
|
|
16556
16936
|
const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
|
|
16557
16937
|
? 'network-available'
|
|
16558
16938
|
: 'full-rejoin';
|
|
16559
|
-
await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
|
|
16939
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
|
|
16560
16940
|
await this.restorePublishedTracks();
|
|
16561
16941
|
this.restoreSubscribedTracks();
|
|
16562
16942
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
|
|
@@ -16580,7 +16960,7 @@ class Call {
|
|
|
16580
16960
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
16581
16961
|
try {
|
|
16582
16962
|
const currentSfu = currentSfuClient.edgeName;
|
|
16583
|
-
await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
|
|
16963
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
|
|
16584
16964
|
...this.joinCallData,
|
|
16585
16965
|
migrating_from: currentSfu,
|
|
16586
16966
|
migrating_from_list: [currentSfu],
|
|
@@ -17353,40 +17733,36 @@ class Call {
|
|
|
17353
17733
|
*/
|
|
17354
17734
|
scheduleAutoDrop = () => {
|
|
17355
17735
|
this.cancelAutoDrop();
|
|
17356
|
-
|
|
17357
|
-
|
|
17358
|
-
return;
|
|
17359
|
-
// ignore if the call is not ringing
|
|
17360
|
-
if (this.state.callingState !== CallingState.RINGING)
|
|
17361
|
-
return;
|
|
17362
|
-
const timeoutInMs = this.isCreatedByMe
|
|
17363
|
-
? settings.ring.auto_cancel_timeout_ms
|
|
17364
|
-
: settings.ring.incoming_call_timeout_ms;
|
|
17365
|
-
// 0 means no auto-drop
|
|
17366
|
-
if (timeoutInMs <= 0)
|
|
17367
|
-
return;
|
|
17368
|
-
this.dropTimeout = setTimeout(() => {
|
|
17369
|
-
// the call might have stopped ringing by this point,
|
|
17370
|
-
// e.g. it was already accepted and joined
|
|
17371
|
-
if (this.state.callingState !== CallingState.RINGING)
|
|
17372
|
-
return;
|
|
17373
|
-
this.leave({
|
|
17374
|
-
reject: true,
|
|
17375
|
-
reason: 'timeout',
|
|
17376
|
-
message: `ringing timeout - ${this.isCreatedByMe
|
|
17377
|
-
? 'no one accepted'
|
|
17378
|
-
: `user didn't interact with incoming call screen`}`,
|
|
17379
|
-
}).catch((err) => {
|
|
17380
|
-
this.logger.error('Failed to drop call', err);
|
|
17381
|
-
});
|
|
17382
|
-
}, timeoutInMs);
|
|
17736
|
+
this.ringTimeout = new RingTimeout(this);
|
|
17737
|
+
this.ringTimeout.start();
|
|
17383
17738
|
};
|
|
17384
17739
|
/**
|
|
17385
17740
|
* Cancels a scheduled auto-drop timeout.
|
|
17386
17741
|
*/
|
|
17387
17742
|
cancelAutoDrop = () => {
|
|
17388
|
-
|
|
17389
|
-
this.
|
|
17743
|
+
this.ringTimeout?.stop();
|
|
17744
|
+
this.ringTimeout = undefined;
|
|
17745
|
+
};
|
|
17746
|
+
/**
|
|
17747
|
+
* Starts polling for the ring outcome. Applicable only to ringing calls the
|
|
17748
|
+
* current user created.
|
|
17749
|
+
*/
|
|
17750
|
+
scheduleRingStatePolling = () => {
|
|
17751
|
+
this.cancelRingStatePolling();
|
|
17752
|
+
if (!this.isCreatedByMe)
|
|
17753
|
+
return;
|
|
17754
|
+
const options = this.streamClient.options.ringStatePolling;
|
|
17755
|
+
if (options === false)
|
|
17756
|
+
return;
|
|
17757
|
+
this.ringStatePoller = new RingStatePoller(this, options);
|
|
17758
|
+
this.ringStatePoller.start();
|
|
17759
|
+
};
|
|
17760
|
+
/**
|
|
17761
|
+
* Cancels the ring state polling.
|
|
17762
|
+
*/
|
|
17763
|
+
cancelRingStatePolling = () => {
|
|
17764
|
+
this.ringStatePoller?.stop();
|
|
17765
|
+
this.ringStatePoller = undefined;
|
|
17390
17766
|
};
|
|
17391
17767
|
/**
|
|
17392
17768
|
* Retrieves the list of recordings for the current call or call session.
|
|
@@ -19059,11 +19435,11 @@ class StreamClient {
|
|
|
19059
19435
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19060
19436
|
};
|
|
19061
19437
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19062
|
-
"1.
|
|
19438
|
+
"1.60.0";
|
|
19063
19439
|
getUserAgent = () => {
|
|
19064
19440
|
if (!this.cachedUserAgent) {
|
|
19065
19441
|
const { clientAppIdentifier = {} } = this.options;
|
|
19066
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19442
|
+
const { sdkName = 'js', sdkVersion = "1.60.0", ...extras } = clientAppIdentifier;
|
|
19067
19443
|
this.cachedUserAgent = [
|
|
19068
19444
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19069
19445
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -19196,6 +19572,7 @@ class ClientEventReporter {
|
|
|
19196
19572
|
callContexts = new Map();
|
|
19197
19573
|
joinAttemptIds = new Map();
|
|
19198
19574
|
joinReasons = new Map();
|
|
19575
|
+
joinSources = new Map();
|
|
19199
19576
|
coordinatorPairs = new Map();
|
|
19200
19577
|
wsPairs = new Map();
|
|
19201
19578
|
peerConnectionPairs = new Map();
|
|
@@ -19310,6 +19687,7 @@ class ClientEventReporter {
|
|
|
19310
19687
|
this.callContexts.delete(cid);
|
|
19311
19688
|
this.joinAttemptIds.delete(cid);
|
|
19312
19689
|
this.joinReasons.delete(cid);
|
|
19690
|
+
this.joinSources.delete(cid);
|
|
19313
19691
|
this.coordinatorPairs.delete(cid);
|
|
19314
19692
|
this.wsPairs.delete(cid);
|
|
19315
19693
|
this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
|
|
@@ -19334,7 +19712,12 @@ class ClientEventReporter {
|
|
|
19334
19712
|
this.logger.warn('Failed to start join correlation', err);
|
|
19335
19713
|
}
|
|
19336
19714
|
};
|
|
19337
|
-
withJoinLifecycle = async (cid,
|
|
19715
|
+
withJoinLifecycle = async (cid, options, op) => {
|
|
19716
|
+
const { joinReason, joinSource } = options;
|
|
19717
|
+
if (joinSource)
|
|
19718
|
+
this.joinSources.set(cid, joinSource);
|
|
19719
|
+
else
|
|
19720
|
+
this.joinSources.delete(cid);
|
|
19338
19721
|
this.startCorrelation(cid, joinReason);
|
|
19339
19722
|
try {
|
|
19340
19723
|
return await op();
|
|
@@ -19343,6 +19726,9 @@ class ClientEventReporter {
|
|
|
19343
19726
|
this.closeCallPairs(cid);
|
|
19344
19727
|
throw err;
|
|
19345
19728
|
}
|
|
19729
|
+
finally {
|
|
19730
|
+
this.joinSources.delete(cid);
|
|
19731
|
+
}
|
|
19346
19732
|
};
|
|
19347
19733
|
track = async (cid, stage, op) => {
|
|
19348
19734
|
this.beginAttempt(cid, stage);
|
|
@@ -19466,6 +19852,7 @@ class ClientEventReporter {
|
|
|
19466
19852
|
startedAt: Date.now(),
|
|
19467
19853
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
19468
19854
|
joinReasonSnapshot: this.joinReasons.get(cid),
|
|
19855
|
+
joinSourceSnapshot: this.joinSources.get(cid),
|
|
19469
19856
|
};
|
|
19470
19857
|
this.coordinatorPairs.set(cid, pair);
|
|
19471
19858
|
this.sendForCall(cid, {
|
|
@@ -19473,6 +19860,7 @@ class ClientEventReporter {
|
|
|
19473
19860
|
...(pair.joinReasonSnapshot && {
|
|
19474
19861
|
join_reason: pair.joinReasonSnapshot,
|
|
19475
19862
|
}),
|
|
19863
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19476
19864
|
event_type: 'initiated',
|
|
19477
19865
|
});
|
|
19478
19866
|
}
|
|
@@ -19487,6 +19875,7 @@ class ClientEventReporter {
|
|
|
19487
19875
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
19488
19876
|
...this.sessionIdField(cid),
|
|
19489
19877
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
19878
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19490
19879
|
event_type: 'completed',
|
|
19491
19880
|
outcome: 'success',
|
|
19492
19881
|
retry_count_attempt: pair.attempts - 1,
|
|
@@ -19505,6 +19894,7 @@ class ClientEventReporter {
|
|
|
19505
19894
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
19506
19895
|
...this.sessionIdField(cid),
|
|
19507
19896
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
19897
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19508
19898
|
event_type: 'completed',
|
|
19509
19899
|
outcome: 'failure',
|
|
19510
19900
|
retry_count_attempt: pair.attempts - 1,
|