diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index 2037767cc94..d4d50b458d7 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -745,3 +745,78 @@ export const REMOTE_SFU_DESCRIPTION = "a=sctp-port:5000\n" + "a=ice-ufrag:obZwzAcRtxwuozPZ\n" + "a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs"; + +export const groupCallParticipantsFourOtherDevices = new Map([ + [ + new RoomMember("roomId0", "user1"), + new Map([ + [ + "deviceId0", + { + sessionId: "0", + screensharing: false, + }, + ], + [ + "deviceId1", + { + sessionId: "1", + screensharing: false, + }, + ], + [ + "deviceId2", + { + sessionId: "2", + screensharing: false, + }, + ], + ]), + ], + [ + new RoomMember("roomId0", "user2"), + new Map([ + [ + "deviceId3", + { + sessionId: "0", + screensharing: false, + }, + ], + [ + "deviceId4", + { + sessionId: "1", + screensharing: false, + }, + ], + ]), + ], +]); + +export const groupCallParticipantsOneOtherDevice = new Map([ + [ + new RoomMember("roomId1", "thisMember"), + new Map([ + [ + "deviceId0", + { + sessionId: "0", + screensharing: false, + }, + ], + ]), + ], + [ + new RoomMember("roomId1", "opponentMember"), + new Map([ + [ + "deviceId1", + { + sessionId: "1", + screensharing: false, + }, + ], + ]), + ], +]); diff --git a/spec/unit/webrtc/stats/summaryStatsReportGatherer.spec.ts b/spec/unit/webrtc/stats/summaryStatsReportGatherer.spec.ts index eefff0d9b49..aca482e219c 100644 --- a/spec/unit/webrtc/stats/summaryStatsReportGatherer.spec.ts +++ b/spec/unit/webrtc/stats/summaryStatsReportGatherer.spec.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer"; import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter"; +import { groupCallParticipantsFourOtherDevices } from "../../../test-utils/webrtc"; describe("SummaryStatsReportGatherer", () => { let reporter: SummaryStatsReportGatherer; @@ -584,8 +585,118 @@ describe("SummaryStatsReportGatherer", () => { percentageReceivedVideoMedia: 1, maxJitter: 2, maxPacketLoss: 40, - peerConnections: 3, + peerConnections: 4, + percentageConcealedAudio: 0, + }); + }); + it("should report missing peer connections", async () => { + const summary = [ + { + isFirstCollection: true, + receivedMedia: 1, + receivedAudioMedia: 1, + receivedVideoMedia: 1, + audioTrackSummary: { + count: 1, + muted: 0, + maxJitter: 20, + maxPacketLoss: 5, + concealedAudio: 0, + totalAudio: 0, + }, + videoTrackSummary: { + count: 1, + muted: 0, + maxJitter: 0, + maxPacketLoss: 0, + concealedAudio: 0, + totalAudio: 0, + }, + }, + { + isFirstCollection: false, + receivedMedia: 1, + receivedAudioMedia: 1, + receivedVideoMedia: 1, + audioTrackSummary: { + count: 1, + muted: 0, + maxJitter: 2, + maxPacketLoss: 5, + concealedAudio: 0, + totalAudio: 0, + }, + videoTrackSummary: { + count: 1, + muted: 0, + maxJitter: 0, + maxPacketLoss: 40, + concealedAudio: 0, + totalAudio: 0, + }, + }, + ]; + reporter.build(summary); + expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({ + percentageReceivedMedia: 1, + percentageReceivedAudioMedia: 1, + percentageReceivedVideoMedia: 1, + maxJitter: 2, + maxPacketLoss: 40, + peerConnections: 2, + percentageConcealedAudio: 0, + }); + }); + }); + describe("extend Summary Stats Report", () => { + it("should extend the report with the appropriate data based on a user map", async () => { + const summary = { + percentageReceivedMedia: 1, + percentageReceivedAudioMedia: 1, + percentageReceivedVideoMedia: 1, + maxJitter: 2, + maxPacketLoss: 40, + peerConnections: 4, + percentageConcealedAudio: 0, + }; + SummaryStatsReportGatherer.extendSummaryReport(summary, groupCallParticipantsFourOtherDevices); + expect(summary).toStrictEqual({ + percentageReceivedMedia: 1, + percentageReceivedAudioMedia: 1, + percentageReceivedVideoMedia: 1, + maxJitter: 2, + maxPacketLoss: 40, + peerConnections: 4, + percentageConcealedAudio: 0, + opponentUsersInCall: 1, + opponentDevicesInCall: 4, + diffDevicesToPeerConnections: 0, + ratioPeerConnectionToDevices: 1, + }); + }); + it("should extend the report data based on a user map", async () => { + const summary = { + percentageReceivedMedia: 1, + percentageReceivedAudioMedia: 1, + percentageReceivedVideoMedia: 1, + maxJitter: 2, + maxPacketLoss: 40, + peerConnections: 4, + percentageConcealedAudio: 0, + }; + SummaryStatsReportGatherer.extendSummaryReport(summary, new Map()); + expect(summary).toStrictEqual({ + percentageReceivedMedia: 1, + percentageReceivedAudioMedia: 1, + percentageReceivedVideoMedia: 1, + maxJitter: 2, + maxPacketLoss: 40, + peerConnections: 4, percentageConcealedAudio: 0, + opponentUsersInCall: 0, + opponentDevicesInCall: 0, + diffDevicesToPeerConnections: -4, + ratioPeerConnectionToDevices: 0, }); }); }); diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index 6d98586c92b..d6f7b4f1ecf 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -26,6 +26,7 @@ import { IScreensharingOpts } from "./mediaHandler"; import { mapsEqual } from "../utils"; import { GroupCallStats } from "./stats/groupCallStats"; import { ByteSentStatsReport, ConnectionStatsReport, StatsReport, SummaryStatsReport } from "./stats/statsReport"; +import { SummaryStatsReportGatherer } from "./stats/summaryStatsReportGatherer"; export enum GroupCallIntent { Ring = "m.ring", @@ -98,6 +99,9 @@ export enum GroupCallStatsReportEvent { SummaryStats = "GroupCall.summary_stats", } +/** + * The final report-events that get consumed by client. + */ export type GroupCallStatsReportEventHandlerMap = { [GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport) => void; [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport) => void; @@ -269,14 +273,18 @@ export class GroupCall extends TypedEventEmitter< } private onConnectionStats = (report: ConnectionStatsReport): void => { + // Final emit of the summary event, to be consumed by the client this.emit(GroupCallStatsReportEvent.ConnectionStats, { report }); }; private onByteSentStats = (report: ByteSentStatsReport): void => { + // Final emit of the summary event, to be consumed by the client this.emit(GroupCallStatsReportEvent.ByteSentStats, { report }); }; private onSummaryStats = (report: SummaryStatsReport): void => { + SummaryStatsReportGatherer.extendSummaryReport(report, this.participants); + // Final emit of the summary event, to be consumed by the client this.emit(GroupCallStatsReportEvent.SummaryStats, { report }); }; @@ -1595,6 +1603,8 @@ export class GroupCall extends TypedEventEmitter< }); if (this.state === GroupCallState.Entered) this.placeOutgoingCalls(); + + // Update the participants stored in the stats object }; private onStateChanged = (newState: GroupCallState, oldState: GroupCallState): void => { diff --git a/src/webrtc/stats/groupCallStats.ts b/src/webrtc/stats/groupCallStats.ts index 40ee9797a44..de660f9909f 100644 --- a/src/webrtc/stats/groupCallStats.ts +++ b/src/webrtc/stats/groupCallStats.ts @@ -17,6 +17,7 @@ import { CallStatsReportGatherer } from "./callStatsReportGatherer"; import { StatsReportEmitter } from "./statsReportEmitter"; import { CallStatsReportSummary } from "./callStatsReportSummary"; import { SummaryStatsReportGatherer } from "./summaryStatsReportGatherer"; +import { logger } from "../../logger"; export class GroupCallStats { private timer: undefined | ReturnType; @@ -75,7 +76,11 @@ export class GroupCallStats { summary.push(c.processStats(this.groupCallId, this.userId)); }); - Promise.all(summary).then((s: Awaited[]) => this.summaryStatsReportGatherer.build(s)); + Promise.all(summary) + .then((s: Awaited[]) => this.summaryStatsReportGatherer.build(s)) + .catch((err) => { + logger.error("Could not build summary stats report", err); + }); } public setInterval(interval: number): void { diff --git a/src/webrtc/stats/statsReport.ts b/src/webrtc/stats/statsReport.ts index 750c26051bb..737e8db11f5 100644 --- a/src/webrtc/stats/statsReport.ts +++ b/src/webrtc/stats/statsReport.ts @@ -83,4 +83,9 @@ export interface SummaryStatsReport { maxPacketLoss: number; percentageConcealedAudio: number; peerConnections: number; + opponentUsersInCall?: number; + opponentDevicesInCall?: number; + diffDevicesToPeerConnections?: number; + 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. } diff --git a/src/webrtc/stats/summaryStatsReportGatherer.ts b/src/webrtc/stats/summaryStatsReportGatherer.ts index 87601dcac7a..b0227670d98 100644 --- a/src/webrtc/stats/summaryStatsReportGatherer.ts +++ b/src/webrtc/stats/summaryStatsReportGatherer.ts @@ -13,6 +13,8 @@ limitations under the License. import { StatsReportEmitter } from "./statsReportEmitter"; import { CallStatsReportSummary } from "./callStatsReportSummary"; import { SummaryStatsReport } from "./statsReport"; +import { ParticipantState } from "../groupCall"; +import { RoomMember } from "../../matrix"; interface CallStatsReportSummaryCounter { receivedAudio: number; @@ -31,9 +33,12 @@ export class SummaryStatsReportGatherer { // webrtcStats as basement all the calculation are 0. We don't want track the 0 stats. const summary = allSummary.filter((s) => !s.isFirstCollection); const summaryTotalCount = summary.length; + // For counting the peer connections we also want to consider the ignored summaries + const peerConnectionsCount = allSummary.length; if (summaryTotalCount === 0) { return; } + const summaryCounter: CallStatsReportSummaryCounter = { receivedAudio: 0, receivedVideo: 0, @@ -65,11 +70,33 @@ export class SummaryStatsReportGatherer { ? (summaryCounter.concealedAudio / summaryCounter.totalAudio).toFixed(decimalPlaces) : 0, ), - peerConnections: summaryTotalCount, + peerConnections: peerConnectionsCount, } as SummaryStatsReport; this.emitter.emitSummaryStatsReport(report); } + public static extendSummaryReport( + report: SummaryStatsReport, + callParticipants: Map>, + ): void { + // Calculate the actual number of devices based on the participants state event + // (this is used, to compare the expected participant count from the room state with the acutal peer connections) + // const devices = callParticipants.() + const devices: [string, ParticipantState][] = []; + const users: [RoomMember, Map][] = []; + for (const userEntry of callParticipants) { + users.push(userEntry); + for (const device of userEntry[1]) { + devices.push(device); + } + } + report.opponentDevicesInCall = Math.max(0, devices.length - 1); + report.opponentUsersInCall = Math.max(0, users.length - 1); + report.diffDevicesToPeerConnections = Math.max(0, devices.length - 1) - report.peerConnections; + report.ratioPeerConnectionToDevices = + Math.max(0, devices.length - 1) == 0 ? 0 : report.peerConnections / (devices.length - 1); + } + private countTrackListReceivedMedia(counter: CallStatsReportSummaryCounter, stats: CallStatsReportSummary): void { let hasReceivedAudio = false; let hasReceivedVideo = false;