You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2026-01-03 23:22:30 +03:00
Expand webrtc stats with connection and call feed track information (#3421)
* Refactor names in webrtc stats * Refactor summary stats reporter to gatherer * Add call and opponent member id to call stats reports * Update opponent member when we know them * Add missing return type * remove async in test * add call feed webrtc report * add logger for error case in stats gathering * gather connection track report * expand call feed stats with call feed * formation code and fix lint issues * clean up new track stats * set label for call feed stats and * remove stream in track stats * transceiver stats based on mid * call feed stats based on stream id * fix lint and test issues * Fix merge issues * Add test for expanding call feed stats in group call * Fix export issue from prv PR * explain test data and fixed some linter issues * convert tests to snapshot tests
This commit is contained in:
1114
spec/test-utils/webrtcReports.ts
Normal file
1114
spec/test-utils/webrtcReports.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,25 +18,25 @@ import { mocked } from "jest-mock";
|
||||
|
||||
import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
|
||||
import { GroupCall, GroupCallEvent, GroupCallState, GroupCallStatsReportEvent } from "../../../src/webrtc/groupCall";
|
||||
import { IMyDevice, MatrixClient } from "../../../src/client";
|
||||
import {
|
||||
FAKE_CONF_ID,
|
||||
FAKE_DEVICE_ID_1,
|
||||
FAKE_DEVICE_ID_2,
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_SESSION_ID_1,
|
||||
FAKE_SESSION_ID_2,
|
||||
FAKE_USER_ID_1,
|
||||
FAKE_USER_ID_2,
|
||||
FAKE_USER_ID_3,
|
||||
installWebRTCMocks,
|
||||
MockCallFeed,
|
||||
MockCallMatrixClient,
|
||||
MockMatrixCall,
|
||||
MockMediaStream,
|
||||
MockMediaStreamTrack,
|
||||
MockRTCPeerConnection,
|
||||
MockMatrixCall,
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_USER_ID_1,
|
||||
FAKE_CONF_ID,
|
||||
FAKE_DEVICE_ID_2,
|
||||
FAKE_SESSION_ID_2,
|
||||
FAKE_USER_ID_2,
|
||||
FAKE_DEVICE_ID_1,
|
||||
FAKE_SESSION_ID_1,
|
||||
FAKE_USER_ID_3,
|
||||
} from "../../test-utils/webrtc";
|
||||
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
|
||||
import { sleep } from "../../../src/utils";
|
||||
@@ -44,6 +44,9 @@ import { CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
|
||||
import { CallFeed } from "../../../src/webrtc/callFeed";
|
||||
import { CallEvent, CallState } from "../../../src/webrtc/call";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { CallFeedReport } from "../../../src/webrtc/stats/statsReport";
|
||||
import { CallFeedStatsReporter } from "../../../src/webrtc/stats/callFeedStatsReporter";
|
||||
import { StatsReportEmitter } from "../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
const FAKE_STATE_EVENTS = [
|
||||
{
|
||||
@@ -1726,4 +1729,89 @@ describe("Group Call", function () {
|
||||
expect(start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("as stats event listener and a CallFeedReport was triggered", () => {
|
||||
let groupCall: GroupCall;
|
||||
let reportEmitter: StatsReportEmitter;
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
beforeEach(async () => {
|
||||
CallFeedStatsReporter.expandCallFeedReport = jest.fn().mockReturnValue(report);
|
||||
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
||||
const mockClient = typedMockClient.typed();
|
||||
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
||||
room.currentState.members[FAKE_USER_ID_1] = {
|
||||
userId: FAKE_USER_ID_1,
|
||||
membership: "join",
|
||||
} as unknown as RoomMember;
|
||||
room.currentState.members[FAKE_USER_ID_2] = {
|
||||
userId: FAKE_USER_ID_2,
|
||||
membership: "join",
|
||||
} as unknown as RoomMember;
|
||||
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
|
||||
groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
reportEmitter = groupCall.getGroupCallStats().reports;
|
||||
});
|
||||
|
||||
it("should not extends with feed stats if no call exists", async () => {
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith({}, [], "from-call-feed");
|
||||
done();
|
||||
});
|
||||
});
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
|
||||
it("and a CallFeedReport was triggered then it should extends with local feed", async () => {
|
||||
const localCallFeed = {} as CallFeed;
|
||||
groupCall.localCallFeed = localCallFeed;
|
||||
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[localCallFeed],
|
||||
"from-local-feed",
|
||||
);
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[],
|
||||
"from-call-feed",
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
|
||||
it("and a CallFeedReport was triggered then it should extends with remote feed", async () => {
|
||||
const localCallFeed = {} as CallFeed;
|
||||
groupCall.localCallFeed = localCallFeed;
|
||||
// @ts-ignore Suppress error because access to private property
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
report.callId = call.callId;
|
||||
const feeds = call.getFeeds();
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[localCallFeed],
|
||||
"from-local-feed",
|
||||
);
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
feeds,
|
||||
"from-call-feed",
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
|
||||
{
|
||||
"callFeeds": [],
|
||||
"callId": "CALL_ID",
|
||||
"opponentMemberId": "USER_ID",
|
||||
"transceiver": [
|
||||
{
|
||||
"currentDirection": "sendonly",
|
||||
"direction": "sendrecv",
|
||||
"mid": "0",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_audio_0",
|
||||
"enabled": true,
|
||||
"id": "receiver_audio_0",
|
||||
"kind": "audio",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_audio_0",
|
||||
},
|
||||
"sender": {
|
||||
"constrainDeviceId": "constrainDeviceId-sender_audio_0",
|
||||
"enabled": true,
|
||||
"id": "sender_audio_0",
|
||||
"kind": "audio",
|
||||
"label": "sender",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-sender_audio_0",
|
||||
},
|
||||
},
|
||||
{
|
||||
"currentDirection": "sendrecv",
|
||||
"direction": "recvonly",
|
||||
"mid": "1",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_video_1",
|
||||
"enabled": true,
|
||||
"id": "receiver_video_1",
|
||||
"kind": "video",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_video_1",
|
||||
},
|
||||
"sender": {
|
||||
"constrainDeviceId": "constrainDeviceId-sender_video_1",
|
||||
"enabled": true,
|
||||
"id": "sender_video_1",
|
||||
"kind": "video",
|
||||
"label": "sender",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-sender_video_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"currentDirection": "recvonly",
|
||||
"direction": "recvonly",
|
||||
"mid": "2",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_video_2",
|
||||
"enabled": true,
|
||||
"id": "receiver_video_2",
|
||||
"kind": "video",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_video_2",
|
||||
},
|
||||
"sender": null,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`CallFeedStatsReporter should extends CallFeedReport with call feeds 1`] = `
|
||||
[
|
||||
{
|
||||
"audio": {
|
||||
"constrainDeviceId": "constrainDeviceId-video-1",
|
||||
"enabled": true,
|
||||
"id": "video-1",
|
||||
"kind": "video",
|
||||
"label": "--",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-video-1",
|
||||
},
|
||||
"isAudioMuted": true,
|
||||
"isVideoMuted": false,
|
||||
"prefix": "unknown",
|
||||
"purpose": undefined,
|
||||
"stream": "stream-1",
|
||||
"type": "local",
|
||||
"video": {
|
||||
"constrainDeviceId": "constrainDeviceId-audio-1",
|
||||
"enabled": true,
|
||||
"id": "audio-1",
|
||||
"kind": "audio",
|
||||
"label": "--",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-audio-1",
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
117
spec/unit/webrtc/stats/callFeedStatsReporter.spec.ts
Normal file
117
spec/unit/webrtc/stats/callFeedStatsReporter.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { CallFeedStatsReporter } from "../../../../src/webrtc/stats/callFeedStatsReporter";
|
||||
import { CallFeedReport } from "../../../../src/webrtc/stats/statsReport";
|
||||
import { CallFeed } from "../../../../src/webrtc/callFeed";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
describe("CallFeedStatsReporter", () => {
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
beforeEach(() => {
|
||||
rtcSpy = {} as RTCPeerConnection;
|
||||
rtcSpy.getTransceivers = jest.fn().mockReturnValue(buildTransceiverMocks());
|
||||
});
|
||||
|
||||
describe("should", () => {
|
||||
it("builds CallFeedReport", async () => {
|
||||
expect(CallFeedStatsReporter.buildCallFeedReport(CALL_ID, USER_ID, rtcSpy)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("extends CallFeedReport with call feeds", async () => {
|
||||
const feed = buildCallFeedMock("1");
|
||||
const callFeedList: CallFeed[] = [feed];
|
||||
const report = {
|
||||
callId: "callId",
|
||||
opponentMemberId: "opponentMemberId",
|
||||
transceiver: [],
|
||||
callFeeds: [],
|
||||
} as CallFeedReport;
|
||||
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport(report, callFeedList).callFeeds).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
const buildTransceiverMocks = (): RTCRtpTransceiver[] => {
|
||||
const trans1 = {
|
||||
mid: "0",
|
||||
direction: "sendrecv",
|
||||
currentDirection: "sendonly",
|
||||
sender: buildSenderMock("sender_audio_0", "audio"),
|
||||
receiver: buildReceiverMock("receiver_audio_0", "audio"),
|
||||
} as RTCRtpTransceiver;
|
||||
const trans2 = {
|
||||
mid: "1",
|
||||
direction: "recvonly",
|
||||
currentDirection: "sendrecv",
|
||||
sender: buildSenderMock("sender_video_1", "video"),
|
||||
receiver: buildReceiverMock("receiver_video_1", "video"),
|
||||
} as RTCRtpTransceiver;
|
||||
const trans3 = {
|
||||
mid: "2",
|
||||
direction: "recvonly",
|
||||
currentDirection: "recvonly",
|
||||
sender: { track: null } as RTCRtpSender,
|
||||
receiver: buildReceiverMock("receiver_video_2", "video"),
|
||||
} as RTCRtpTransceiver;
|
||||
return [trans1, trans2, trans3];
|
||||
};
|
||||
|
||||
const buildSenderMock = (id: string, kind: "audio" | "video"): RTCRtpSender => {
|
||||
const track = buildTrackMock(id, kind);
|
||||
return {
|
||||
track,
|
||||
} as RTCRtpSender;
|
||||
};
|
||||
|
||||
const buildReceiverMock = (id: string, kind: "audio" | "video"): RTCRtpReceiver => {
|
||||
const track = buildTrackMock(id, kind);
|
||||
return {
|
||||
track,
|
||||
} as RTCRtpReceiver;
|
||||
};
|
||||
|
||||
const buildTrackMock = (id: string, kind: "audio" | "video"): MediaStreamTrack => {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
enabled: true,
|
||||
label: "--",
|
||||
muted: false,
|
||||
readyState: "live",
|
||||
getSettings: () => ({ deviceId: `settingDeviceId-${id}` }),
|
||||
getConstraints: () => ({ deviceId: `constrainDeviceId-${id}` }),
|
||||
} as MediaStreamTrack;
|
||||
};
|
||||
|
||||
const buildCallFeedMock = (id: string, isLocal = true): CallFeed => {
|
||||
const stream = {
|
||||
id: `stream-${id}`,
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return [buildTrackMock(`video-${id}`, "video")];
|
||||
},
|
||||
getVideoTracks(): MediaStreamTrack[] {
|
||||
return [buildTrackMock(`audio-${id}`, "audio")];
|
||||
},
|
||||
} as MediaStream;
|
||||
return {
|
||||
stream,
|
||||
isLocal: () => isLocal,
|
||||
isVideoMuted: () => false,
|
||||
isAudioMuted: () => true,
|
||||
} as CallFeed;
|
||||
};
|
||||
});
|
||||
@@ -17,6 +17,8 @@ limitations under the License.
|
||||
import { CallStatsReportGatherer } from "../../../../src/webrtc/stats/callStatsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import { MediaSsrcHandler } from "../../../../src/webrtc/stats/media/mediaSsrcHandler";
|
||||
import { currentChromeReport, prevChromeReport } from "../../../test-utils/webrtcReports";
|
||||
import { MockRTCPeerConnection } from "../../../test-utils/webrtc";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
@@ -28,6 +30,7 @@ describe("CallStatsReportGatherer", () => {
|
||||
beforeEach(() => {
|
||||
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
|
||||
rtcSpy.addEventListener = jest.fn();
|
||||
rtcSpy.getTransceivers = jest.fn().mockReturnValue([]);
|
||||
emitter = new StatsReportEmitter();
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
@@ -145,6 +148,70 @@ describe("CallStatsReportGatherer", () => {
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("should not only produce a call summary stat but also", () => {
|
||||
const wantedSummaryReport = {
|
||||
isFirstCollection: false,
|
||||
audioTrackSummary: {
|
||||
concealedAudio: 0,
|
||||
count: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
muted: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
receivedAudioMedia: 0,
|
||||
receivedMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
videoTrackSummary: {
|
||||
concealedAudio: 0,
|
||||
count: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
muted: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
rtcSpy = new MockRTCPeerConnection() as unknown as RTCPeerConnection;
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
|
||||
const previous = prevChromeReport as unknown as RTCStatsReport;
|
||||
previous.get = (id: string) => {
|
||||
return prevChromeReport.find((data) => data.id === id);
|
||||
};
|
||||
// @ts-ignore
|
||||
collector.previousStatsReport = previous;
|
||||
|
||||
const current = currentChromeReport as unknown as RTCStatsReport;
|
||||
current.get = (id: string) => {
|
||||
return currentChromeReport.find((data) => data.id === id);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
getStats.mockResolvedValue(current);
|
||||
});
|
||||
|
||||
it("emit byteSentStatsReport", async () => {
|
||||
const emitByteSendReport = jest.spyOn(emitter, "emitByteSendReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitByteSendReport).toHaveBeenCalled();
|
||||
});
|
||||
it("emit emitConnectionStatsReport", async () => {
|
||||
const emitConnectionStatsReport = jest.spyOn(emitter, "emitConnectionStatsReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitConnectionStatsReport).toHaveBeenCalled();
|
||||
});
|
||||
it("emit callFeedStatsReport", async () => {
|
||||
const emitCallFeedReport = jest.spyOn(emitter, "emitCallFeedReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitCallFeedReport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on signal state change event", () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
CallFeedReport,
|
||||
ConnectionStatsReport,
|
||||
StatsReport,
|
||||
SummaryStatsReport,
|
||||
@@ -62,4 +63,16 @@ describe("StatsReportEmitter", () => {
|
||||
emitter.emitSummaryStatsReport(report);
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit and receive CallFeedReports", async () => {
|
||||
const report = {} as CallFeedReport;
|
||||
return new Promise((resolve, _) => {
|
||||
emitter.on(StatsReport.CALL_FEED_REPORT, (r) => {
|
||||
expect(r).toBe(report);
|
||||
resolve(null);
|
||||
return;
|
||||
});
|
||||
emitter.emitCallFeedReport(report);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,15 @@ import { GroupCallEventHandlerEvent } from "./groupCallEventHandler";
|
||||
import { IScreensharingOpts } from "./mediaHandler";
|
||||
import { mapsEqual } from "../utils";
|
||||
import { GroupCallStats } from "./stats/groupCallStats";
|
||||
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport, SummaryStatsReport } from "./stats/statsReport";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
CallFeedReport,
|
||||
ConnectionStatsReport,
|
||||
StatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "./stats/statsReport";
|
||||
import { SummaryStatsReportGatherer } from "./stats/summaryStatsReportGatherer";
|
||||
import { CallFeedStatsReporter } from "./stats/callFeedStatsReporter";
|
||||
|
||||
export enum GroupCallIntent {
|
||||
Ring = "m.ring",
|
||||
@@ -97,6 +104,7 @@ export enum GroupCallStatsReportEvent {
|
||||
ConnectionStats = "GroupCall.connection_stats",
|
||||
ByteSentStats = "GroupCall.byte_sent_stats",
|
||||
SummaryStats = "GroupCall.summary_stats",
|
||||
CallFeedStats = "GroupCall.call_feed_stats",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +114,7 @@ export type GroupCallStatsReportEventHandlerMap = {
|
||||
[GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport<ConnectionStatsReport>) => void;
|
||||
[GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport<ByteSentStatsReport>) => void;
|
||||
[GroupCallStatsReportEvent.SummaryStats]: (report: GroupCallStatsReport<SummaryStatsReport>) => void;
|
||||
[GroupCallStatsReportEvent.CallFeedStats]: (report: GroupCallStatsReport<CallFeedReport>) => void;
|
||||
};
|
||||
|
||||
export enum GroupCallErrorCode {
|
||||
@@ -114,7 +123,9 @@ export enum GroupCallErrorCode {
|
||||
PlaceCallFailed = "place_call_failed",
|
||||
}
|
||||
|
||||
export interface GroupCallStatsReport<T extends ConnectionStatsReport | ByteSentStatsReport | SummaryStatsReport> {
|
||||
export interface GroupCallStatsReport<
|
||||
T extends ConnectionStatsReport | ByteSentStatsReport | SummaryStatsReport | CallFeedReport,
|
||||
> {
|
||||
report: T;
|
||||
}
|
||||
|
||||
@@ -288,6 +299,22 @@ export class GroupCall extends TypedEventEmitter<
|
||||
this.emit(GroupCallStatsReportEvent.SummaryStats, { report });
|
||||
};
|
||||
|
||||
private onCallFeedReport = (report: CallFeedReport): void => {
|
||||
if (this.localCallFeed) {
|
||||
report = CallFeedStatsReporter.expandCallFeedReport(report, [this.localCallFeed], "from-local-feed");
|
||||
}
|
||||
|
||||
const callFeeds: CallFeed[] = [];
|
||||
this.forEachCall((call) => {
|
||||
if (call.callId === report.callId) {
|
||||
call.getFeeds().forEach((f) => callFeeds.push(f));
|
||||
}
|
||||
});
|
||||
|
||||
report = CallFeedStatsReporter.expandCallFeedReport(report, callFeeds, "from-call-feed");
|
||||
this.emit(GroupCallStatsReportEvent.CallFeedStats, { report });
|
||||
};
|
||||
|
||||
public async create(): Promise<GroupCall> {
|
||||
this.creationTs = Date.now();
|
||||
this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this);
|
||||
@@ -1642,6 +1669,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats);
|
||||
this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats);
|
||||
this.stats.reports.on(StatsReport.SUMMARY_STATS, this.onSummaryStats);
|
||||
this.stats.reports.on(StatsReport.CALL_FEED_REPORT, this.onCallFeedReport);
|
||||
}
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
94
src/webrtc/stats/callFeedStatsReporter.ts
Normal file
94
src/webrtc/stats/callFeedStatsReporter.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { CallFeedReport, CallFeedStats, TrackStats, TransceiverStats } from "./statsReport";
|
||||
import { CallFeed } from "../callFeed";
|
||||
|
||||
export class CallFeedStatsReporter {
|
||||
public static buildCallFeedReport(callId: string, opponentMemberId: string, pc: RTCPeerConnection): CallFeedReport {
|
||||
const rtpTransceivers = pc.getTransceivers();
|
||||
const transceiver: TransceiverStats[] = [];
|
||||
const callFeeds: CallFeedStats[] = [];
|
||||
|
||||
rtpTransceivers.forEach((t) => {
|
||||
const sender = t.sender?.track ? CallFeedStatsReporter.buildTrackStats(t.sender.track, "sender") : null;
|
||||
const receiver = CallFeedStatsReporter.buildTrackStats(t.receiver.track, "receiver");
|
||||
transceiver.push({
|
||||
mid: t.mid == null ? "null" : t.mid,
|
||||
direction: t.direction,
|
||||
currentDirection: t.currentDirection == null ? "null" : t.currentDirection,
|
||||
sender,
|
||||
receiver,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
callId,
|
||||
opponentMemberId,
|
||||
transceiver,
|
||||
callFeeds,
|
||||
};
|
||||
}
|
||||
|
||||
private static buildTrackStats(track: MediaStreamTrack, label = "--"): TrackStats {
|
||||
const settingDeviceId = track.getSettings()?.deviceId;
|
||||
const constrainDeviceId = track.getConstraints()?.deviceId;
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
kind: track.kind,
|
||||
settingDeviceId: settingDeviceId ? settingDeviceId : "unknown",
|
||||
constrainDeviceId: constrainDeviceId ? constrainDeviceId : "unknown",
|
||||
muted: track.muted,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
label,
|
||||
} as TrackStats;
|
||||
}
|
||||
|
||||
public static expandCallFeedReport(
|
||||
report: CallFeedReport,
|
||||
callFeeds: CallFeed[],
|
||||
prefix = "unknown",
|
||||
): CallFeedReport {
|
||||
if (!report.callFeeds) {
|
||||
report.callFeeds = [];
|
||||
}
|
||||
callFeeds.forEach((feed) => {
|
||||
const audioTracks = feed.stream.getAudioTracks();
|
||||
const videoTracks = feed.stream.getVideoTracks();
|
||||
const audio =
|
||||
audioTracks.length > 0
|
||||
? CallFeedStatsReporter.buildTrackStats(feed.stream.getAudioTracks()[0], feed.purpose)
|
||||
: null;
|
||||
const video =
|
||||
videoTracks.length > 0
|
||||
? CallFeedStatsReporter.buildTrackStats(feed.stream.getVideoTracks()[0], feed.purpose)
|
||||
: null;
|
||||
const feedStats = {
|
||||
stream: feed.stream.id,
|
||||
type: feed.isLocal() ? "local" : "remote",
|
||||
audio,
|
||||
video,
|
||||
purpose: feed.purpose,
|
||||
prefix,
|
||||
isVideoMuted: feed.isVideoMuted(),
|
||||
isAudioMuted: feed.isAudioMuted(),
|
||||
} as CallFeedStats;
|
||||
report.callFeeds.push(feedStats);
|
||||
});
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { TrackStatsBuilder } from "./trackStatsBuilder";
|
||||
import { ConnectionStatsReportBuilder } from "./connectionStatsReportBuilder";
|
||||
import { ValueFormatter } from "./valueFormatter";
|
||||
import { CallStatsReportSummary } from "./callStatsReportSummary";
|
||||
import { logger } from "../../logger";
|
||||
import { CallFeedStatsReporter } from "./callFeedStatsReporter";
|
||||
|
||||
export class CallStatsReportGatherer {
|
||||
private isActive = true;
|
||||
@@ -62,10 +64,11 @@ export class CallStatsReportGatherer {
|
||||
.then((report) => {
|
||||
// @ts-ignore
|
||||
this.currentStatsReport = typeof report?.result === "function" ? report.result() : report;
|
||||
|
||||
try {
|
||||
this.processStatsReport(groupCallId, localUserId);
|
||||
} catch (error) {
|
||||
this.isActive = false;
|
||||
this.handleError(error);
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -161,6 +164,9 @@ export class CallStatsReportGatherer {
|
||||
});
|
||||
|
||||
this.emitter.emitByteSendReport(byteSentStatsReport);
|
||||
this.emitter.emitCallFeedReport(
|
||||
CallFeedStatsReporter.buildCallFeedReport(this.callId, this.opponentMemberId, this.pc),
|
||||
);
|
||||
this.processAndEmitConnectionStatsReport();
|
||||
}
|
||||
|
||||
@@ -172,8 +178,9 @@ export class CallStatsReportGatherer {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
private handleError(_: any): void {
|
||||
private handleError(error: any): void {
|
||||
this.isActive = false;
|
||||
logger.warn(`CallStatsReportGatherer ${this.callId} processStatsReport fails and set to inactive ${error}`);
|
||||
}
|
||||
|
||||
private processAndEmitConnectionStatsReport(): void {
|
||||
|
||||
@@ -20,19 +20,22 @@ import { Resolution } from "./media/mediaTrackStats";
|
||||
|
||||
export enum StatsReport {
|
||||
CONNECTION_STATS = "StatsReport.connection_stats",
|
||||
CALL_FEED_REPORT = "StatsReport.call_feed_report",
|
||||
BYTE_SENT_STATS = "StatsReport.byte_sent_stats",
|
||||
SUMMARY_STATS = "StatsReport.summary_stats",
|
||||
}
|
||||
|
||||
export type TrackID = string;
|
||||
export type ByteSend = number;
|
||||
|
||||
/// ByteSentStatsReport ################################################################################################
|
||||
export interface ByteSentStatsReport extends Map<TrackID, ByteSend> {
|
||||
callId?: string;
|
||||
opponentMemberId?: string;
|
||||
// is a map: `local trackID` => byte send
|
||||
}
|
||||
|
||||
export type TrackID = string;
|
||||
export type ByteSend = number;
|
||||
|
||||
/// ConnectionStatsReport ##############################################################################################
|
||||
export interface ConnectionStatsReport {
|
||||
callId?: string;
|
||||
opponentMemberId?: string;
|
||||
@@ -68,6 +71,7 @@ export interface CodecMap {
|
||||
remote: Map<TrackID, string>;
|
||||
}
|
||||
|
||||
/// SummaryStatsReport #################################################################################################
|
||||
export interface SummaryStatsReport {
|
||||
/**
|
||||
* Aggregated the information for percentage of received media
|
||||
@@ -89,3 +93,41 @@ export interface SummaryStatsReport {
|
||||
ratioPeerConnectionToDevices?: number;
|
||||
// Todo: Decide if we want an index (or a timestamp) of this report in relation to the group call, to help differenciate when issues occur and ignore/track initial connection delays.
|
||||
}
|
||||
|
||||
/// CallFeedReport #####################################################################################################
|
||||
export interface CallFeedReport {
|
||||
callId: string;
|
||||
opponentMemberId: string;
|
||||
transceiver: TransceiverStats[];
|
||||
callFeeds: CallFeedStats[];
|
||||
}
|
||||
|
||||
export interface CallFeedStats {
|
||||
stream: string;
|
||||
type: "remote" | "local";
|
||||
audio: TrackStats | null;
|
||||
video: TrackStats | null;
|
||||
purpose: string;
|
||||
prefix: string;
|
||||
isVideoMuted: boolean;
|
||||
isAudioMuted: boolean;
|
||||
}
|
||||
|
||||
export interface TransceiverStats {
|
||||
readonly mid: string;
|
||||
readonly sender: TrackStats | null;
|
||||
readonly receiver: TrackStats | null;
|
||||
readonly direction: string;
|
||||
readonly currentDirection: string;
|
||||
}
|
||||
|
||||
export interface TrackStats {
|
||||
readonly id: string;
|
||||
readonly kind: "audio" | "video";
|
||||
readonly settingDeviceId: string;
|
||||
readonly constrainDeviceId: string;
|
||||
readonly muted: boolean;
|
||||
readonly enabled: boolean;
|
||||
readonly readyState: "ended" | "live";
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,18 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { TypedEventEmitter } from "../../models/typed-event-emitter";
|
||||
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport, SummaryStatsReport } from "./statsReport";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
CallFeedReport,
|
||||
ConnectionStatsReport,
|
||||
StatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "./statsReport";
|
||||
|
||||
export type StatsReportHandlerMap = {
|
||||
[StatsReport.BYTE_SENT_STATS]: (report: ByteSentStatsReport) => void;
|
||||
[StatsReport.CONNECTION_STATS]: (report: ConnectionStatsReport) => void;
|
||||
[StatsReport.CALL_FEED_REPORT]: (report: CallFeedReport) => void;
|
||||
[StatsReport.SUMMARY_STATS]: (report: SummaryStatsReport) => void;
|
||||
};
|
||||
|
||||
@@ -32,6 +39,10 @@ export class StatsReportEmitter extends TypedEventEmitter<StatsReport, StatsRepo
|
||||
this.emit(StatsReport.CONNECTION_STATS, report);
|
||||
}
|
||||
|
||||
public emitCallFeedReport(report: CallFeedReport): void {
|
||||
this.emit(StatsReport.CALL_FEED_REPORT, report);
|
||||
}
|
||||
|
||||
public emitSummaryStatsReport(report: SummaryStatsReport): void {
|
||||
this.emit(StatsReport.SUMMARY_STATS, report);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user