diff --git a/src/room/checkForParallelCalls.ts b/src/room/checkForParallelCalls.ts new file mode 100644 index 0000000..b065c87 --- /dev/null +++ b/src/room/checkForParallelCalls.ts @@ -0,0 +1,65 @@ +/* +Copyright 2023 New Vector Ltd + +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 { EventType } from "matrix-js-sdk/src/@types/event"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; + +import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; + +function isObject(x: unknown): x is Record { + return typeof x === "object" && x !== null; +} + +/** + * Checks the state of a room for multiple calls happening in parallel, sending + * the details to PostHog if that is indeed what's happening. (This is unwanted + * as it indicates a split-brain scenario.) + */ +export function checkForParallelCalls(state: RoomState): void { + const now = Date.now(); + const participantsPerCall = new Map(); + + // For each participant in each call, increment the participant count + for (const e of state.getStateEvents(EventType.GroupCallMemberPrefix)) { + const content = e.getContent>(); + const calls: unknown[] = Array.isArray(content["m.calls"]) + ? content["m.calls"] + : []; + + for (const call of calls) { + if (isObject(call) && typeof call["m.call_id"] === "string") { + const devices: unknown[] = Array.isArray(call["m.devices"]) + ? call["m.devices"] + : []; + + for (const device of devices) { + if (isObject(device) && (device["expires_ts"] as number) > now) { + const participantCount = + participantsPerCall.get(call["m.call_id"]) ?? 0; + participantsPerCall.set(call["m.call_id"], participantCount + 1); + } + } + } + } + } + + if (participantsPerCall.size > 1) { + PosthogAnalytics.instance.trackEvent({ + eventName: "ParallelCalls", + participantsPerCall: Object.fromEntries(participantsPerCall), + }); + } +} diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 48b8d8f..0126e1c 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -29,7 +29,7 @@ import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { useTranslation } from "react-i18next"; import { IWidgetApiRequest } from "matrix-widget-api"; -import { MatrixClient } from "matrix-js-sdk"; +import { MatrixClient, RoomStateEvent } from "matrix-js-sdk"; import { ByteSentStatsReport, ConnectionStatsReport, @@ -42,6 +42,7 @@ import { TranslatedError, translatedError } from "../TranslatedError"; import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { ElementCallOpenTelemetry } from "../otel/otel"; +import { checkForParallelCalls } from "./checkForParallelCalls"; export enum ConnectionState { EstablishingCall = "establishing call", // call hasn't been established yet @@ -377,18 +378,19 @@ export function useGroupCall( groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged); groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); groupCall.on(GroupCallEvent.Error, onError); - groupCall.on( GroupCallStatsReportEvent.ConnectionStats, onConnectionStatsReport ); - groupCall.on( GroupCallStatsReportEvent.ByteSentStats, onByteSentStatsReport ); - groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport); + groupCall.room.currentState.on( + RoomStateEvent.Update, + checkForParallelCalls + ); updateState({ error: null, @@ -448,6 +450,10 @@ export function useGroupCall( GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport ); + groupCall.room.currentState.off( + RoomStateEvent.Update, + checkForParallelCalls + ); leaveCall(); }; }, [groupCall, updateState, leaveCall]); diff --git a/test/room/checkForParallelCalls-test.ts b/test/room/checkForParallelCalls-test.ts new file mode 100644 index 0000000..6b5f016 --- /dev/null +++ b/test/room/checkForParallelCalls-test.ts @@ -0,0 +1,171 @@ +/* +Copyright 2023 New Vector Ltd + +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 { Mocked, mocked } from "jest-mock"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics"; +import { checkForParallelCalls } from "../../src/room/checkForParallelCalls"; + +const withFakeTimers = (continuation: () => void) => { + jest.useFakeTimers(); + try { + continuation(); + } finally { + jest.useRealTimers(); + } +}; + +const withMockedPosthog = ( + continuation: (posthog: Mocked) => void +) => { + const posthog = mocked({ + trackEvent: jest.fn(), + } as unknown as PosthogAnalytics); + const instanceSpy = jest + .spyOn(PosthogAnalytics, "instance", "get") + .mockReturnValue(posthog); + try { + continuation(posthog); + } finally { + instanceSpy.mockRestore(); + } +}; + +const mockRoomState = ( + groupCallMemberContents: Record[] +): RoomState => { + const stateEvents = groupCallMemberContents.map((content) => ({ + getContent: () => content, + })); + return { getStateEvents: () => stateEvents } as unknown as RoomState; +}; + +test("checkForParallelCalls does nothing if all participants are in the same call", () => { + withFakeTimers(() => { + withMockedPosthog((posthog) => { + const roomState = mockRoomState([ + { + "m.calls": [ + { + "m.call_id": "1", + "m.devices": [ + { + device_id: "Element Call", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + { + "m.call_id": null, // invalid + "m.devices": [ + { + device_id: "Element Android", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + null, // invalid + ], + }, + { + "m.calls": [ + { + "m.call_id": "1", + "m.devices": [ + { + device_id: "Element Desktop", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + ], + }, + ]); + + checkForParallelCalls(roomState); + expect(posthog.trackEvent).not.toHaveBeenCalled(); + }); + }); +}); + +test("checkForParallelCalls sends diagnostics to PostHog if there is a split-brain", () => { + withFakeTimers(() => { + withMockedPosthog((posthog) => { + const roomState = mockRoomState([ + { + "m.calls": [ + { + "m.call_id": "1", + "m.devices": [ + { + device_id: "Element Call", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + { + "m.call_id": "2", + "m.devices": [ + { + device_id: "Element Android", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + ], + }, + { + "m.calls": [ + { + "m.call_id": "1", + "m.devices": [ + { + device_id: "Element Desktop", + session_id: "a", + expires_ts: Date.now() + 1000, + }, + ], + }, + { + "m.call_id": "2", + "m.devices": [ + { + device_id: "Element Call", + session_id: "a", + expires_ts: Date.now() - 1000, + }, + ], + }, + ], + }, + ]); + + checkForParallelCalls(roomState); + expect(posthog.trackEvent).toHaveBeenCalledWith({ + eventName: "ParallelCalls", + participantsPerCall: { + "1": 2, + "2": 1, + }, + }); + }); + }); +});