@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.
- package/CHANGELOG.md +10 -0
- package/dist/index.browser.es.js +796 -51
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +796 -50
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +796 -51
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +5 -1
- package/dist/src/StreamVideoClient.d.ts +2 -0
- package/dist/src/coordinator/connection/client.d.ts +1 -0
- package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
- package/dist/src/errors/index.d.ts +1 -0
- package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
- package/dist/src/reporting/index.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
- package/dist/src/rtc/types.d.ts +24 -1
- package/dist/src/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/Call.ts +184 -60
- package/src/StreamSfuClient.ts +3 -3
- package/src/StreamVideoClient.ts +18 -3
- package/src/__tests__/Call.autodrop.test.ts +4 -1
- package/src/__tests__/Call.lifecycle.test.ts +4 -1
- package/src/__tests__/Call.publishing.test.ts +4 -1
- package/src/__tests__/Call.test.ts +23 -0
- package/src/coordinator/connection/client.ts +5 -0
- package/src/devices/__tests__/CameraManager.test.ts +10 -1
- package/src/devices/__tests__/DeviceManager.test.ts +10 -1
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
- package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
- package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
- package/src/errors/SfuTimeoutError.ts +7 -0
- package/src/errors/index.ts +1 -0
- package/src/events/__tests__/call.test.ts +2 -0
- package/src/events/__tests__/mutes.test.ts +4 -1
- package/src/events/call.ts +8 -0
- package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
- package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
- package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
- package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
- package/src/helpers/firstVideoFrame.ts +38 -0
- package/src/reporting/ClientEventReporter.ts +859 -0
- package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
- package/src/reporting/index.ts +1 -0
- package/src/rtc/BasePeerConnection.ts +30 -0
- package/src/rtc/Subscriber.ts +1 -0
- package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
- package/src/rtc/types.ts +34 -0
- package/src/types.ts +6 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import { of } from 'rxjs';
|
|
4
|
+
import { ClientEventReporter, CallReportContext } from '../ClientEventReporter';
|
|
5
|
+
import type { StreamClient } from '../../coordinator/connection/client';
|
|
6
|
+
import { ErrorFromResponse } from '../../coordinator/connection/types';
|
|
7
|
+
import { SfuTimeoutError } from '../../errors';
|
|
8
|
+
import type { AxiosResponse } from 'axios';
|
|
9
|
+
import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
|
|
10
|
+
|
|
11
|
+
vi.mock('../../devices', () => ({
|
|
12
|
+
getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
|
|
13
|
+
getVideoBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
17
|
+
|
|
18
|
+
describe('ClientEventReporter', () => {
|
|
19
|
+
const cid = 'default:call-1';
|
|
20
|
+
let doAxiosRequest: ReturnType<typeof vi.fn>;
|
|
21
|
+
let reporter: ClientEventReporter;
|
|
22
|
+
let connectId: string;
|
|
23
|
+
|
|
24
|
+
const postedEvents = (): Array<Record<string, any>> =>
|
|
25
|
+
doAxiosRequest.mock.calls.map((call) => call[2].events[0]);
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
doAxiosRequest = vi.fn().mockResolvedValue({});
|
|
29
|
+
const streamClient = fromPartial<StreamClient>({
|
|
30
|
+
userID: 'user-1',
|
|
31
|
+
doAxiosRequest,
|
|
32
|
+
getUserAgent: () => 'test-agent',
|
|
33
|
+
getSdkVersion: () => '1.0.0',
|
|
34
|
+
});
|
|
35
|
+
reporter = new ClientEventReporter({ streamClient });
|
|
36
|
+
connectId = reporter.startCoordinatorConnection('user-1');
|
|
37
|
+
|
|
38
|
+
const ctx: CallReportContext = {
|
|
39
|
+
callType: 'default',
|
|
40
|
+
callId: 'call-1',
|
|
41
|
+
getCallSessionId: () => 'session-1',
|
|
42
|
+
getSfuId: () => 'sfu-1',
|
|
43
|
+
getUserSessionId: () => 'user-session-1',
|
|
44
|
+
};
|
|
45
|
+
reporter.registerCall(cid, ctx);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('emits an initiated then a completed event on success', async () => {
|
|
49
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
50
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
51
|
+
await flush();
|
|
52
|
+
|
|
53
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
54
|
+
expect(events).toHaveLength(2);
|
|
55
|
+
expect(events[0]).toMatchObject({
|
|
56
|
+
event_type: 'initiated',
|
|
57
|
+
call_cid: cid,
|
|
58
|
+
user_id: 'user-1',
|
|
59
|
+
coordinator_connect_id: connectId,
|
|
60
|
+
join_reason: 'first-attempt',
|
|
61
|
+
});
|
|
62
|
+
expect(events[1]).toMatchObject({
|
|
63
|
+
event_type: 'completed',
|
|
64
|
+
outcome: 'success',
|
|
65
|
+
retry_count_attempt: 0,
|
|
66
|
+
join_reason: 'first-attempt',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(events[0].stage_id).toBe(events[1].stage_id);
|
|
70
|
+
expect(events[0].join_attempt_id).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('carries the join_reason given to startCorrelation', async () => {
|
|
74
|
+
reporter.startCorrelation(cid, 'migration');
|
|
75
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
76
|
+
await flush();
|
|
77
|
+
|
|
78
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
79
|
+
expect(events).toHaveLength(2);
|
|
80
|
+
expect(events[0]).toMatchObject({ join_reason: 'migration' });
|
|
81
|
+
expect(events[1]).toMatchObject({ join_reason: 'migration' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('folds in-stage retries into a single pair', async () => {
|
|
85
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
86
|
+
await expect(
|
|
87
|
+
reporter.track(cid, 'CoordinatorJoin', () =>
|
|
88
|
+
Promise.reject(new Error('boom')),
|
|
89
|
+
),
|
|
90
|
+
).rejects.toThrow('boom');
|
|
91
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
92
|
+
await flush();
|
|
93
|
+
|
|
94
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
95
|
+
const initiated = events.filter((e) => e.event_type === 'initiated');
|
|
96
|
+
const completed = events.filter((e) => e.event_type === 'completed');
|
|
97
|
+
|
|
98
|
+
expect(initiated).toHaveLength(1);
|
|
99
|
+
expect(completed).toHaveLength(1);
|
|
100
|
+
expect(completed[0]).toMatchObject({
|
|
101
|
+
outcome: 'success',
|
|
102
|
+
retry_count_attempt: 1,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reports the latest error when folded retries fail differently', async () => {
|
|
107
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
108
|
+
const attempt = (err: Error) =>
|
|
109
|
+
expect(
|
|
110
|
+
reporter.track(cid, 'CoordinatorJoin', () => Promise.reject(err)),
|
|
111
|
+
).rejects.toThrow();
|
|
112
|
+
|
|
113
|
+
await attempt(new Error('error A'));
|
|
114
|
+
await attempt(new Error('error B'));
|
|
115
|
+
await attempt(new Error('error B'));
|
|
116
|
+
reporter.close(cid);
|
|
117
|
+
await flush();
|
|
118
|
+
|
|
119
|
+
const completed = postedEvents().filter(
|
|
120
|
+
(e) => e.stage === 'CoordinatorJoin' && e.event_type === 'completed',
|
|
121
|
+
);
|
|
122
|
+
expect(completed).toHaveLength(1);
|
|
123
|
+
expect(completed[0]).toMatchObject({
|
|
124
|
+
outcome: 'failure',
|
|
125
|
+
retry_count_attempt: 2,
|
|
126
|
+
retry_failure_reason: 'error B',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('emits a failure completion on abort', async () => {
|
|
131
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
132
|
+
void reporter.track(cid, 'CoordinatorJoin', () => new Promise(() => {}));
|
|
133
|
+
reporter.abort(cid, { code: 'CLIENT_ABORTED', reason: 'user left' });
|
|
134
|
+
await flush();
|
|
135
|
+
|
|
136
|
+
const completed = postedEvents().filter(
|
|
137
|
+
(e) => e.stage === 'CoordinatorJoin' && e.event_type === 'completed',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(completed).toHaveLength(1);
|
|
141
|
+
expect(completed[0]).toMatchObject({
|
|
142
|
+
outcome: 'failure',
|
|
143
|
+
retry_failure_code: 'CLIENT_ABORTED',
|
|
144
|
+
retry_failure_reason: 'user left',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('emits a JoinInitiated event when correlation starts', async () => {
|
|
149
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
150
|
+
await flush();
|
|
151
|
+
|
|
152
|
+
const join = postedEvents().filter((e) => e.stage === 'JoinInitiated');
|
|
153
|
+
expect(join).toHaveLength(1);
|
|
154
|
+
expect(join[0]).toMatchObject({
|
|
155
|
+
event_type: 'initiated',
|
|
156
|
+
coordinator_connect_id: connectId,
|
|
157
|
+
});
|
|
158
|
+
expect(join[0].join_attempt_id).toBeTruthy();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not retry events rejected with a 4xx', async () => {
|
|
162
|
+
doAxiosRequest.mockRejectedValue({ response: { status: 400 } });
|
|
163
|
+
reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
|
|
164
|
+
await flush();
|
|
165
|
+
|
|
166
|
+
expect(doAxiosRequest).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('tracks the coordinator websocket connection', async () => {
|
|
170
|
+
await reporter.trackCoordinatorWs(() => Promise.resolve('ok'));
|
|
171
|
+
await flush();
|
|
172
|
+
|
|
173
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorWS');
|
|
174
|
+
expect(events).toHaveLength(2);
|
|
175
|
+
expect(events[0]).toMatchObject({
|
|
176
|
+
event_type: 'initiated',
|
|
177
|
+
coordinator_connect_id: connectId,
|
|
178
|
+
});
|
|
179
|
+
expect(events[1]).toMatchObject({
|
|
180
|
+
event_type: 'completed',
|
|
181
|
+
outcome: 'success',
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports a failed coordinator websocket connection on close', async () => {
|
|
186
|
+
await expect(
|
|
187
|
+
reporter.trackCoordinatorWs(() => Promise.reject(new Error('ws down'))),
|
|
188
|
+
).rejects.toThrow('ws down');
|
|
189
|
+
reporter.closeCoordinatorWs();
|
|
190
|
+
await flush();
|
|
191
|
+
|
|
192
|
+
const completed = postedEvents().filter(
|
|
193
|
+
(e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
|
|
194
|
+
);
|
|
195
|
+
expect(completed).toHaveLength(1);
|
|
196
|
+
expect(completed[0]).toMatchObject({
|
|
197
|
+
outcome: 'failure',
|
|
198
|
+
retry_failure_code: 'SERVER_ERROR',
|
|
199
|
+
retry_failure_reason: 'ws down',
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('reports an API-rejected websocket connection with the backend code', async () => {
|
|
204
|
+
const rejection = new Error(
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
code: 41,
|
|
207
|
+
StatusCode: 401,
|
|
208
|
+
message: 'bad token',
|
|
209
|
+
isWSFailure: false,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
await expect(
|
|
213
|
+
reporter.trackCoordinatorWs(() => Promise.reject(rejection)),
|
|
214
|
+
).rejects.toThrow();
|
|
215
|
+
reporter.closeCoordinatorWs();
|
|
216
|
+
await flush();
|
|
217
|
+
|
|
218
|
+
const completed = postedEvents().filter(
|
|
219
|
+
(e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
|
|
220
|
+
);
|
|
221
|
+
expect(completed).toHaveLength(1);
|
|
222
|
+
expect(completed[0]).toMatchObject({
|
|
223
|
+
outcome: 'failure',
|
|
224
|
+
retry_failure_code: '41',
|
|
225
|
+
retry_failure_reason: 'bad token',
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reports a transient websocket failure with the default code', async () => {
|
|
230
|
+
const failure = new Error(
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
code: '',
|
|
233
|
+
StatusCode: '',
|
|
234
|
+
message: 'initial WS connection could not be established',
|
|
235
|
+
isWSFailure: true,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
await expect(
|
|
239
|
+
reporter.trackCoordinatorWs(() => Promise.reject(failure)),
|
|
240
|
+
).rejects.toThrow();
|
|
241
|
+
reporter.closeCoordinatorWs();
|
|
242
|
+
await flush();
|
|
243
|
+
|
|
244
|
+
const completed = postedEvents().filter(
|
|
245
|
+
(e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
|
|
246
|
+
);
|
|
247
|
+
expect(completed).toHaveLength(1);
|
|
248
|
+
expect(completed[0]).toMatchObject({
|
|
249
|
+
outcome: 'failure',
|
|
250
|
+
retry_failure_code: 'SERVER_ERROR',
|
|
251
|
+
retry_failure_reason: 'initial WS connection could not be established',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('forwards the backend error code and status on a server error', async () => {
|
|
256
|
+
const err = new ErrorFromResponse({
|
|
257
|
+
message: 'server boom',
|
|
258
|
+
code: 16,
|
|
259
|
+
status: 500,
|
|
260
|
+
response: fromPartial<AxiosResponse>({}),
|
|
261
|
+
unrecoverable: false,
|
|
262
|
+
});
|
|
263
|
+
await expect(
|
|
264
|
+
reporter.trackCoordinatorWs(() => Promise.reject(err)),
|
|
265
|
+
).rejects.toBe(err);
|
|
266
|
+
reporter.closeCoordinatorWs();
|
|
267
|
+
await flush();
|
|
268
|
+
|
|
269
|
+
const completed = postedEvents().filter(
|
|
270
|
+
(e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
|
|
271
|
+
);
|
|
272
|
+
expect(completed[0]).toMatchObject({
|
|
273
|
+
outcome: 'failure',
|
|
274
|
+
retry_failure_code: '16',
|
|
275
|
+
retry_failure_reason: 'server boom',
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('includes sfu_id and call_session_id on a WSJoin completion', async () => {
|
|
280
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
281
|
+
await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
|
|
282
|
+
await flush();
|
|
283
|
+
|
|
284
|
+
const events = postedEvents().filter((e) => e.stage === 'WSJoin');
|
|
285
|
+
expect(events).toHaveLength(2);
|
|
286
|
+
expect(events[1]).toMatchObject({
|
|
287
|
+
event_type: 'completed',
|
|
288
|
+
outcome: 'success',
|
|
289
|
+
sfu_id: 'sfu-1',
|
|
290
|
+
call_session_id: 'session-1',
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('folds in-stage WSJoin retries into a single pair', async () => {
|
|
295
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
296
|
+
await expect(
|
|
297
|
+
reporter.track(cid, 'WSJoin', () => Promise.reject(new Error('boom'))),
|
|
298
|
+
).rejects.toThrow('boom');
|
|
299
|
+
await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
|
|
300
|
+
await flush();
|
|
301
|
+
|
|
302
|
+
const events = postedEvents().filter((e) => e.stage === 'WSJoin');
|
|
303
|
+
const initiated = events.filter((e) => e.event_type === 'initiated');
|
|
304
|
+
const completed = events.filter((e) => e.event_type === 'completed');
|
|
305
|
+
|
|
306
|
+
expect(initiated).toHaveLength(1);
|
|
307
|
+
expect(completed).toHaveLength(1);
|
|
308
|
+
expect(completed[0]).toMatchObject({
|
|
309
|
+
outcome: 'success',
|
|
310
|
+
retry_count_attempt: 1,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('reports a WSJoin failure with the captured SFU error', async () => {
|
|
315
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
316
|
+
void reporter.track(cid, 'WSJoin', () => new Promise(() => {}));
|
|
317
|
+
reporter.captureWsError(cid, { code: 'SFU_ERROR', reason: 'sfu closed' });
|
|
318
|
+
reporter.close(cid);
|
|
319
|
+
await flush();
|
|
320
|
+
|
|
321
|
+
const completed = postedEvents().filter(
|
|
322
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
323
|
+
);
|
|
324
|
+
expect(completed).toHaveLength(1);
|
|
325
|
+
expect(completed[0]).toMatchObject({
|
|
326
|
+
outcome: 'failure',
|
|
327
|
+
retry_failure_code: 'SFU_ERROR',
|
|
328
|
+
retry_failure_reason: 'sfu closed',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('keeps the captured SFU error over CLIENT_ABORTED on abort', async () => {
|
|
333
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
334
|
+
void reporter.track(cid, 'WSJoin', () => new Promise(() => {}));
|
|
335
|
+
reporter.captureWsError(cid, {
|
|
336
|
+
code: 'SFU_ERROR',
|
|
337
|
+
reason: 'sfu disconnect',
|
|
338
|
+
});
|
|
339
|
+
reporter.abort(cid, {
|
|
340
|
+
code: 'CLIENT_ABORTED',
|
|
341
|
+
reason: 'SFU instructed to disconnect',
|
|
342
|
+
});
|
|
343
|
+
await flush();
|
|
344
|
+
|
|
345
|
+
const completed = postedEvents().filter(
|
|
346
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
347
|
+
);
|
|
348
|
+
expect(completed).toHaveLength(1);
|
|
349
|
+
expect(completed[0]).toMatchObject({
|
|
350
|
+
outcome: 'failure',
|
|
351
|
+
retry_failure_code: 'SFU_ERROR',
|
|
352
|
+
retry_failure_reason: 'sfu disconnect',
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('keeps a captured SFU error over a later WSJoin timeout', async () => {
|
|
357
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
358
|
+
await expect(
|
|
359
|
+
reporter.track(cid, 'WSJoin', async () => {
|
|
360
|
+
reporter.captureWsError(cid, {
|
|
361
|
+
code: 'SFU_ERROR',
|
|
362
|
+
reason: 'unauthenticated',
|
|
363
|
+
});
|
|
364
|
+
throw new SfuTimeoutError(
|
|
365
|
+
'SFU WS connection failed to open after 5000ms',
|
|
366
|
+
);
|
|
367
|
+
}),
|
|
368
|
+
).rejects.toThrow();
|
|
369
|
+
reporter.close(cid);
|
|
370
|
+
await flush();
|
|
371
|
+
|
|
372
|
+
const completed = postedEvents().filter(
|
|
373
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
374
|
+
);
|
|
375
|
+
expect(completed).toHaveLength(1);
|
|
376
|
+
expect(completed[0]).toMatchObject({
|
|
377
|
+
outcome: 'failure',
|
|
378
|
+
retry_failure_code: 'SFU_ERROR',
|
|
379
|
+
retry_failure_reason: 'unauthenticated',
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('lets a later WSJoin attempt override a previous attempt captured error', async () => {
|
|
384
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
385
|
+
await expect(
|
|
386
|
+
reporter.track(cid, 'WSJoin', async () => {
|
|
387
|
+
reporter.captureWsError(cid, {
|
|
388
|
+
code: 'SFU_ERROR',
|
|
389
|
+
reason: 'attempt 1 sfu error',
|
|
390
|
+
});
|
|
391
|
+
throw new SfuTimeoutError('attempt 1 timeout');
|
|
392
|
+
}),
|
|
393
|
+
).rejects.toThrow();
|
|
394
|
+
await expect(
|
|
395
|
+
reporter.track(cid, 'WSJoin', () =>
|
|
396
|
+
Promise.reject(new SfuTimeoutError('attempt 2 timeout')),
|
|
397
|
+
),
|
|
398
|
+
).rejects.toThrow();
|
|
399
|
+
reporter.close(cid);
|
|
400
|
+
await flush();
|
|
401
|
+
|
|
402
|
+
const completed = postedEvents().filter(
|
|
403
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
404
|
+
);
|
|
405
|
+
expect(completed).toHaveLength(1);
|
|
406
|
+
expect(completed[0]).toMatchObject({
|
|
407
|
+
outcome: 'failure',
|
|
408
|
+
retry_count_attempt: 1,
|
|
409
|
+
retry_failure_code: 'REQUEST_TIMEOUT',
|
|
410
|
+
retry_failure_reason: 'attempt 2 timeout',
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('reports a WSJoin timeout as REQUEST_TIMEOUT', async () => {
|
|
415
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
416
|
+
await expect(
|
|
417
|
+
reporter.track(cid, 'WSJoin', () =>
|
|
418
|
+
Promise.reject(
|
|
419
|
+
new SfuTimeoutError('SFU WS connection failed to open after 5000ms'),
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
).rejects.toThrow();
|
|
423
|
+
reporter.close(cid);
|
|
424
|
+
await flush();
|
|
425
|
+
|
|
426
|
+
const completed = postedEvents().filter(
|
|
427
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
428
|
+
);
|
|
429
|
+
expect(completed).toHaveLength(1);
|
|
430
|
+
expect(completed[0]).toMatchObject({
|
|
431
|
+
outcome: 'failure',
|
|
432
|
+
retry_failure_code: 'REQUEST_TIMEOUT',
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('reports media device permission status on correlation start', async () => {
|
|
437
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
438
|
+
await flush();
|
|
439
|
+
|
|
440
|
+
const perm = postedEvents().filter(
|
|
441
|
+
(e) => e.stage === 'MediaDevicePermission',
|
|
442
|
+
);
|
|
443
|
+
expect(perm).toHaveLength(1);
|
|
444
|
+
expect(perm[0]).toMatchObject({
|
|
445
|
+
event_type: 'initiated',
|
|
446
|
+
microphone_permission_status: 'GRANTED',
|
|
447
|
+
camera_permission_status: 'GRANTED',
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('reports the first video frame only once', async () => {
|
|
452
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
453
|
+
reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
|
|
454
|
+
reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
|
|
455
|
+
await flush();
|
|
456
|
+
|
|
457
|
+
const frames = postedEvents().filter((e) => e.stage === 'FirstVideoFrame');
|
|
458
|
+
expect(frames).toHaveLength(1);
|
|
459
|
+
expect(frames[0]).toMatchObject({
|
|
460
|
+
event_type: 'initiated',
|
|
461
|
+
track_id: 'track-1',
|
|
462
|
+
sfu_id: 'sfu-1',
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('tracks a publisher peer connection connect', async () => {
|
|
467
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
468
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
469
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
470
|
+
stateType: 'peerConnection',
|
|
471
|
+
state: 'connecting',
|
|
472
|
+
});
|
|
473
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
474
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
475
|
+
stateType: 'peerConnection',
|
|
476
|
+
state: 'connected',
|
|
477
|
+
});
|
|
478
|
+
await flush();
|
|
479
|
+
|
|
480
|
+
const events = postedEvents().filter(
|
|
481
|
+
(e) => e.stage === 'PeerConnectionConnect',
|
|
482
|
+
);
|
|
483
|
+
expect(events).toHaveLength(2);
|
|
484
|
+
expect(events[0]).toMatchObject({
|
|
485
|
+
event_type: 'initiated',
|
|
486
|
+
peer_connection: 'publish',
|
|
487
|
+
was_previously_connected: false,
|
|
488
|
+
sfu_id: 'sfu-1',
|
|
489
|
+
user_session_id: 'user-session-1',
|
|
490
|
+
});
|
|
491
|
+
expect(events[1]).toMatchObject({
|
|
492
|
+
event_type: 'completed',
|
|
493
|
+
outcome: 'success',
|
|
494
|
+
peer_connection: 'publish',
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('reports an ICE failure on the peer connection', async () => {
|
|
499
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
500
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
501
|
+
peerType: PeerType.SUBSCRIBER,
|
|
502
|
+
stateType: 'peerConnection',
|
|
503
|
+
state: 'connecting',
|
|
504
|
+
});
|
|
505
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
506
|
+
peerType: PeerType.SUBSCRIBER,
|
|
507
|
+
stateType: 'ice',
|
|
508
|
+
state: 'failed',
|
|
509
|
+
});
|
|
510
|
+
await flush();
|
|
511
|
+
|
|
512
|
+
const completed = postedEvents().filter(
|
|
513
|
+
(e) =>
|
|
514
|
+
e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
|
|
515
|
+
);
|
|
516
|
+
expect(completed).toHaveLength(1);
|
|
517
|
+
expect(completed[0]).toMatchObject({
|
|
518
|
+
outcome: 'failure',
|
|
519
|
+
peer_connection: 'subscribe',
|
|
520
|
+
retry_failure_code: 'ICE_CONNECTIVITY_FAILED',
|
|
521
|
+
ice_state: 'FAILED',
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('opens a fresh peer connection pair after a new correlation', async () => {
|
|
526
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
527
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
528
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
529
|
+
stateType: 'peerConnection',
|
|
530
|
+
state: 'connecting',
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
reporter.startCorrelation(cid, 'full-rejoin');
|
|
534
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
535
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
536
|
+
stateType: 'peerConnection',
|
|
537
|
+
state: 'connecting',
|
|
538
|
+
});
|
|
539
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
540
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
541
|
+
stateType: 'peerConnection',
|
|
542
|
+
state: 'connected',
|
|
543
|
+
});
|
|
544
|
+
await flush();
|
|
545
|
+
|
|
546
|
+
const pc = postedEvents().filter(
|
|
547
|
+
(e) => e.stage === 'PeerConnectionConnect',
|
|
548
|
+
);
|
|
549
|
+
const initiated = pc.filter((e) => e.event_type === 'initiated');
|
|
550
|
+
const completed = pc.filter((e) => e.event_type === 'completed');
|
|
551
|
+
|
|
552
|
+
expect(initiated).toHaveLength(2);
|
|
553
|
+
// the pair superseded by the new correlation closes with a failure,
|
|
554
|
+
// the fresh pair closes with a success
|
|
555
|
+
expect(completed).toHaveLength(2);
|
|
556
|
+
expect(completed[0]).toMatchObject({
|
|
557
|
+
outcome: 'failure',
|
|
558
|
+
retry_failure_code: 'CLIENT_ABORTED',
|
|
559
|
+
retry_failure_reason: 'superseded by a new join attempt',
|
|
560
|
+
});
|
|
561
|
+
expect(completed[0].stage_id).toBe(initiated[0].stage_id);
|
|
562
|
+
expect(completed[1]).toMatchObject({ outcome: 'success' });
|
|
563
|
+
expect(completed[1].stage_id).toBe(initiated[1].stage_id);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('marks a peer connection as previously connected on reconnect', async () => {
|
|
567
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
568
|
+
const connect = () => {
|
|
569
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
570
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
571
|
+
stateType: 'peerConnection',
|
|
572
|
+
state: 'connecting',
|
|
573
|
+
});
|
|
574
|
+
reporter.onPeerConnectionStateChange(cid, {
|
|
575
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
576
|
+
stateType: 'peerConnection',
|
|
577
|
+
state: 'connected',
|
|
578
|
+
});
|
|
579
|
+
};
|
|
580
|
+
connect();
|
|
581
|
+
connect();
|
|
582
|
+
await flush();
|
|
583
|
+
|
|
584
|
+
const initiated = postedEvents().filter(
|
|
585
|
+
(e) =>
|
|
586
|
+
e.stage === 'PeerConnectionConnect' && e.event_type === 'initiated',
|
|
587
|
+
);
|
|
588
|
+
expect(initiated).toHaveLength(2);
|
|
589
|
+
expect(initiated[0]).toMatchObject({ was_previously_connected: false });
|
|
590
|
+
expect(initiated[1]).toMatchObject({ was_previously_connected: true });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('keeps reporting state isolated between concurrent calls', async () => {
|
|
594
|
+
const cid2 = 'default:call-2';
|
|
595
|
+
reporter.registerCall(cid2, {
|
|
596
|
+
callType: 'default',
|
|
597
|
+
callId: 'call-2',
|
|
598
|
+
getCallSessionId: () => 'session-2',
|
|
599
|
+
getSfuId: () => 'sfu-2',
|
|
600
|
+
getUserSessionId: () => 'user-session-2',
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
604
|
+
reporter.startCorrelation(cid2, 'first-attempt');
|
|
605
|
+
await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
|
|
606
|
+
await reporter.track(cid2, 'WSJoin', () => Promise.resolve('ok'));
|
|
607
|
+
await flush();
|
|
608
|
+
|
|
609
|
+
const ws1 = postedEvents().filter(
|
|
610
|
+
(e) => e.stage === 'WSJoin' && e.call_cid === cid,
|
|
611
|
+
);
|
|
612
|
+
const ws2 = postedEvents().filter(
|
|
613
|
+
(e) => e.stage === 'WSJoin' && e.call_cid === cid2,
|
|
614
|
+
);
|
|
615
|
+
expect(ws1).toHaveLength(2);
|
|
616
|
+
expect(ws2).toHaveLength(2);
|
|
617
|
+
expect(ws1.every((e) => e.sfu_id === 'sfu-1')).toBe(true);
|
|
618
|
+
expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ClientEventReporter';
|
|
@@ -16,7 +16,9 @@ import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
|
|
|
16
16
|
import {
|
|
17
17
|
BasePeerConnectionOpts,
|
|
18
18
|
OnIceConnected,
|
|
19
|
+
OnPeerConnectionStateChange,
|
|
19
20
|
OnReconnectionNeeded,
|
|
21
|
+
OnRemoteTrackUnmute,
|
|
20
22
|
ReconnectReason,
|
|
21
23
|
} from './types';
|
|
22
24
|
import type { ClientPublishOptions } from '../types';
|
|
@@ -37,6 +39,8 @@ export abstract class BasePeerConnection {
|
|
|
37
39
|
|
|
38
40
|
private onReconnectionNeeded?: OnReconnectionNeeded;
|
|
39
41
|
private onIceConnected?: OnIceConnected;
|
|
42
|
+
private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
|
|
43
|
+
protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
40
44
|
private readonly iceRestartDelay: number;
|
|
41
45
|
private iceHasEverConnected = false;
|
|
42
46
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
@@ -65,6 +69,8 @@ export abstract class BasePeerConnection {
|
|
|
65
69
|
dispatcher,
|
|
66
70
|
onReconnectionNeeded,
|
|
67
71
|
onIceConnected,
|
|
72
|
+
onPeerConnectionStateChange,
|
|
73
|
+
onRemoteTrackUnmute,
|
|
68
74
|
tag,
|
|
69
75
|
enableTracing,
|
|
70
76
|
clientPublishOptions,
|
|
@@ -81,6 +87,8 @@ export abstract class BasePeerConnection {
|
|
|
81
87
|
this.tag = tag;
|
|
82
88
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
83
89
|
this.onIceConnected = onIceConnected;
|
|
90
|
+
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
91
|
+
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
84
92
|
this.logger = videoLoggerSystem.getLogger(
|
|
85
93
|
peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
|
|
86
94
|
{ tags: [tag] },
|
|
@@ -128,6 +136,8 @@ export abstract class BasePeerConnection {
|
|
|
128
136
|
this.preConnectStuckTimeout = undefined;
|
|
129
137
|
this.onReconnectionNeeded = undefined;
|
|
130
138
|
this.onIceConnected = undefined;
|
|
139
|
+
this.onPeerConnectionStateChange = undefined;
|
|
140
|
+
this.onRemoteTrackUnmute = undefined;
|
|
131
141
|
this.isDisposed = true;
|
|
132
142
|
this.detachEventHandlers();
|
|
133
143
|
this.pc.close();
|
|
@@ -327,6 +337,10 @@ export abstract class BasePeerConnection {
|
|
|
327
337
|
private onConnectionStateChange = async () => {
|
|
328
338
|
const state = this.pc.connectionState;
|
|
329
339
|
this.logger.debug(`Connection state changed`, state);
|
|
340
|
+
this.fireOnPeerConnectionStateChange({
|
|
341
|
+
stateType: 'peerConnection',
|
|
342
|
+
state,
|
|
343
|
+
});
|
|
330
344
|
if (this.tracer && (state === 'connected' || state === 'failed')) {
|
|
331
345
|
try {
|
|
332
346
|
const stats = await this.stats.get();
|
|
@@ -355,9 +369,25 @@ export abstract class BasePeerConnection {
|
|
|
355
369
|
private onIceConnectionStateChange = () => {
|
|
356
370
|
const state = this.pc.iceConnectionState;
|
|
357
371
|
this.logger.debug(`ICE connection state changed`, state);
|
|
372
|
+
this.fireOnPeerConnectionStateChange({ stateType: 'ice', state });
|
|
358
373
|
this.handleConnectionStateUpdate(state);
|
|
359
374
|
};
|
|
360
375
|
|
|
376
|
+
private fireOnPeerConnectionStateChange = (
|
|
377
|
+
event:
|
|
378
|
+
| { stateType: 'ice'; state: RTCIceConnectionState }
|
|
379
|
+
| { stateType: 'peerConnection'; state: RTCPeerConnectionState },
|
|
380
|
+
) => {
|
|
381
|
+
try {
|
|
382
|
+
this.onPeerConnectionStateChange?.({
|
|
383
|
+
peerType: this.peerType,
|
|
384
|
+
...event,
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
this.logger.warn('onPeerConnectionStateChange listener threw', err);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
361
391
|
private handleConnectionStateUpdate = (
|
|
362
392
|
state: RTCIceConnectionState | RTCPeerConnectionState,
|
|
363
393
|
) => {
|