From 3656bff4bdfd81001e8faaa55cebffb8c49d53f0 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Fri, 26 Jul 2024 10:14:33 +0200 Subject: [PATCH] [MM-57698] Implement client ICE candidate pairs metric (#8051) * Implement client ICE candidate pairs metric * Update calls package --- .../calls/connection/connection.test.ts | 130 ++++++++++++++++++ app/products/calls/connection/connection.ts | 62 ++++++++- package-lock.json | 6 +- package.json | 2 +- 4 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 app/products/calls/connection/connection.test.ts diff --git a/app/products/calls/connection/connection.test.ts b/app/products/calls/connection/connection.test.ts new file mode 100644 index 000000000..1cb602d16 --- /dev/null +++ b/app/products/calls/connection/connection.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {RTCMonitor, RTCPeer, parseRTCStats} from '@mattermost/calls/lib'; +import {Platform} from 'react-native'; +import InCallManager from 'react-native-incall-manager'; + +import NetworkManager from '@managers/network_manager'; + +import {newConnection} from './connection'; +import {WebSocketClient} from './websocket_client'; + +jest.mock('./websocket_client'); +jest.mock('@mattermost/calls/lib'); + +describe('newConnection', () => { + const mockClient = { + getWebSocketUrl: jest.fn(() => 'ws://localhost:8065'), + getCallsConfig: jest.fn(() => ({})), + }; + + const mockRTCStats = { + iceStats: { + succeeded: [ + { + id: 'candidatePairA', + timestamp: 45, + priority: 45, + state: 'succeeded', + local: { + candidateType: 'host', + protocol: 'udp', + port: 45000, + }, + remote: { + candidateType: 'host', + protocol: 'udp', + port: 8443, + }, + }, + ], + failed: [], + }, + }; + + beforeAll(() => { + // eslint-disable-next-line + // @ts-ignore + global.navigator = {}; + + // eslint-disable-next-line + // @ts-ignore + NetworkManager.getClient = jest.fn(() => mockClient); + + // eslint-disable-next-line + // @ts-ignore + RTCPeer.mockImplementation(() => { + return { + getStats: jest.fn(), + on: jest.fn(), + }; + }); + + // eslint-disable-next-line + // @ts-ignore + RTCMonitor.mockImplementation(() => { + return { + on: jest.fn(), + }; + }); + + Platform.OS = 'web'; + + InCallManager.start = jest.fn(); + InCallManager.stopProximitySensor = jest.fn(); + }); + + afterAll(() => { + // eslint-disable-next-line + // @ts-ignore + delete global.navigator; + + jest.resetAllMocks(); + }); + + it('collectICEStats', (done) => { + const wsSend = jest.fn(); + const joinHandler = jest.fn(async (event: string, cb: () => void) => { + if (event === 'join') { + await cb(); + expect(parseRTCStats).toHaveBeenCalled(); + expect(wsSend).toHaveBeenCalledWith('metric', { + metric_name: 'client_ice_candidate_pair', + data: JSON.stringify({ + state: 'succeeded', + local: { + type: 'host', + protocol: 'udp', + }, + remote: { + type: 'host', + protocol: 'udp', + }, + }), + }, + ); + done(); + } + }); + + // eslint-disable-next-line + // @ts-ignore + WebSocketClient.mockImplementation(() => { + return { + initialize: jest.fn(), + on: joinHandler, + send: wsSend, + }; + }); + + // eslint-disable-next-line + // @ts-ignore + parseRTCStats.mockImplementation(() => mockRTCStats); + + newConnection('http://localhost:8065', 'channelID', () => {}, () => {}, false). + then((connection) => { + expect(connection).toBeDefined(); + expect(joinHandler).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index ff1fb243a..677834759 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {RTCMonitor, RTCPeer} from '@mattermost/calls/lib'; +import {RTCMonitor, RTCPeer, parseRTCStats} from '@mattermost/calls/lib'; import {deflate} from 'pako'; import {DeviceEventEmitter, type EmitterSubscription, NativeEventEmitter, NativeModules, Platform} from 'react-native'; import InCallManager from 'react-native-incall-manager'; @@ -226,6 +226,64 @@ export async function newConnection( } }; + const collectICEStats = () => { + const start = Date.now(); + const seenMap: {[key: string]: string} = {}; + + const gatherStats = async () => { + if (!peer) { + return; + } + + try { + const stats = parseRTCStats(await peer.getStats()).iceStats; + for (const state of Object.keys(stats)) { + for (const pair of stats[state]) { + const seenState = seenMap[pair.id]; + seenMap[pair.id] = pair.state; + + if (seenState !== pair.state) { + logDebug('calls: ice candidate pair stats', JSON.stringify(pair)); + } + + if (seenState === 'succeeded' || state !== 'succeeded') { + continue; + } + + if (!pair.local || !pair.remote) { + continue; + } + + ws.send('metric', { + metric_name: 'client_ice_candidate_pair', + data: JSON.stringify({ + state: pair.state, + local: { + type: pair.local.candidateType, + protocol: pair.local.protocol, + }, + remote: { + type: pair.remote.candidateType, + protocol: pair.remote.protocol, + }, + }), + }); + } + } + } catch (err) { + logError('failed to parse ICE stats', err); + } + + // Repeat the check for at most 30 seconds. + if (Date.now() < start + 30000) { + // We check every two seconds. + setTimeout(gatherStats, 2000); + } + }; + + gatherStats(); + }; + ws.on('error', (err: Error) => { logDebug('calls: ws error', err); if (err === wsReconnectionTimeoutErr) { @@ -329,6 +387,8 @@ export async function newConnection( logger, }); + collectICEStats(); + rtcMonitor = new RTCMonitor({ peer, logger, diff --git a/package-lock.json b/package-lock.json index 736fcf69c..e1bc000d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@formatjs/intl-numberformat": "8.10.3", "@formatjs/intl-pluralrules": "5.2.14", "@gorhom/bottom-sheet": "4.6.3", - "@mattermost/calls": "github:mattermost/calls-common#09f1d4e715ccaf4f161e5e5340e251997336ee3d", + "@mattermost/calls": "github:mattermost/calls-common#5cc5598fe9574125a61a3b54501deb6b65fcc70f", "@mattermost/compass-icons": "0.1.45", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker", @@ -5564,8 +5564,8 @@ "node_modules/@mattermost/calls": { "name": "@mattermost/calls-common", "version": "0.27.2", - "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#09f1d4e715ccaf4f161e5e5340e251997336ee3d", - "integrity": "sha512-8nBf2upZbCa0GTkusCmmHlT8Tz4BmegAFoDNK82Y3o+wfZd3ZESIG6GEG6aKlrXt/fwU7iCaSQw59WxDXhun9w==" + "resolved": "git+ssh://git@github.com/mattermost/calls-common.git#5cc5598fe9574125a61a3b54501deb6b65fcc70f", + "integrity": "sha512-oJkw51naheYGDlxV4iRx0rnI1zAjWE/InRG6QXPvq2nbJ9U0QUPsk/H9/O6trP2ZuXR/v1u72z/OwqLud1blbQ==" }, "node_modules/@mattermost/commonmark": { "version": "0.30.1-2", diff --git a/package.json b/package.json index 31c046a9d..95ea9f914 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@formatjs/intl-numberformat": "8.10.3", "@formatjs/intl-pluralrules": "5.2.14", "@gorhom/bottom-sheet": "4.6.3", - "@mattermost/calls": "github:mattermost/calls-common#09f1d4e715ccaf4f161e5e5340e251997336ee3d", + "@mattermost/calls": "github:mattermost/calls-common#5cc5598fe9574125a61a3b54501deb6b65fcc70f", "@mattermost/compass-icons": "0.1.45", "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker",