From 78c01a74f71ebd520560d256c900f154e104b80e Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Wed, 12 Jun 2024 10:51:38 +0300 Subject: [PATCH] Add performance metrics to the app (#7953) (#8008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add performance metrics to the app * add batcher and improve handling * Add tests * Fix test * Address feedback * Address feedback * Address feedback * update podfile (cherry picked from commit 5f01f9e9af58c51f589ee56860997f96a7c570eb) Co-authored-by: Daniel Espino GarcĂ­a --- app/actions/remote/entry/notification.test.ts | 201 +++++++++++++++++ app/actions/remote/entry/notification.ts | 4 + app/actions/remote/performance.test.ts | 67 ++++++ app/actions/remote/performance.ts | 20 ++ app/actions/remote/post.ts | 4 +- app/client/rest/base.ts | 4 + app/client/rest/general.ts | 8 + app/components/loading/index.tsx | 7 +- app/components/post_list/post/index.ts | 1 + app/components/post_list/post/post.test.tsx | 75 +++++++ app/components/post_list/post/post.tsx | 52 ++++- .../team_list/team_item/team_item.test.tsx | 64 ++++++ .../team_list/team_item/team_item.tsx | 14 +- app/init/launch.ts | 5 + .../performance_metrics_manager/index.test.ts | 209 ++++++++++++++++++ .../performance_metrics_manager/index.ts | 85 +++++++ .../performance_metrics_batcher.test.ts | 195 ++++++++++++++++ .../performance_metrics_batcher.ts | 103 +++++++++ .../performance_metrics_manager/test_utils.ts | 16 ++ .../performance_metrics_manager/types.d.ts | 23 ++ .../categories/categories.test.tsx | 45 +++- .../categories_list/categories/categories.tsx | 11 + .../home/channel_list/channel_list.test.tsx | 51 +++++ .../home/channel_list/channel_list.tsx | 5 + ios/Podfile.lock | 6 + package-lock.json | 9 + package.json | 1 + test/intl-test-helper.tsx | 17 +- test/mock_api_client.ts | 11 + test/setup.ts | 39 +++- test/test_helper.ts | 58 ++++- types/api/config.d.ts | 1 + 32 files changed, 1383 insertions(+), 28 deletions(-) create mode 100644 app/actions/remote/entry/notification.test.ts create mode 100644 app/actions/remote/performance.test.ts create mode 100644 app/actions/remote/performance.ts create mode 100644 app/components/post_list/post/post.test.tsx create mode 100644 app/components/team_sidebar/team_list/team_item/team_item.test.tsx create mode 100644 app/managers/performance_metrics_manager/index.test.ts create mode 100644 app/managers/performance_metrics_manager/index.ts create mode 100644 app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts create mode 100644 app/managers/performance_metrics_manager/performance_metrics_batcher.ts create mode 100644 app/managers/performance_metrics_manager/test_utils.ts create mode 100644 app/managers/performance_metrics_manager/types.d.ts create mode 100644 app/screens/home/channel_list/channel_list.test.tsx create mode 100644 test/mock_api_client.ts diff --git a/app/actions/remote/entry/notification.test.ts b/app/actions/remote/entry/notification.test.ts new file mode 100644 index 00000000000..7b25eb41e46 --- /dev/null +++ b/app/actions/remote/entry/notification.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionType} from '@constants'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; +import NavigationStore from '@store/navigation_store'; +import {mockApiClient} from '@test/mock_api_client'; +import TestHelper from '@test/test_helper'; + +import {pushNotificationEntry} from './notification'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@managers/performance_metrics_manager'); +jest.mock('@store/navigation_store'); + +const mockedNavigationStore = jest.mocked(NavigationStore); + +describe('Performance metrics are set correctly', () => { + const serverUrl = 'http://www.someserverurl.com'; + let operator: ServerDataOperator; + let post: Post; + beforeAll(() => { + mockApiClient.get.mockImplementation((url: string) => { + if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/posts/)) { + return {status: 200, ok: true, data: {order: [], posts: {}}}; + } + if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/stats/)) { + return {status: 200, ok: true, data: {}}; + } + if (url.match(/\/api\/v4\/posts\/[a-z1-90-]*\/thread/)) { + return {status: 200, ok: true, data: {order: [], posts: {}}}; + } + console.log(`GET ${url} not registered in the mock`); + return {status: 404, ok: false}; + }); + + mockApiClient.post.mockImplementation((url: string) => { + if (url.match(/\/api\/v4\/channels\/members\/me\/view/)) { + return {status: 200, ok: true, data: {}}; + } + + console.log(`POST ${url} not registered in the mock`); + return {status: 404, ok: false}; + }); + mockedNavigationStore.waitUntilScreenIsTop.mockImplementation(() => Promise.resolve()); + + // There are no problems when running the tests for this file alone without this line + // but for some reason, when running several tests together, it fails if we don't add this. + mockedNavigationStore.getScreensInStack.mockImplementation(() => []); + }); + afterAll(() => { + mockApiClient.get.mockReset(); + mockApiClient.post.mockReset(); + }); + + beforeEach(async () => { + const client = await NetworkManager.createClient(serverUrl); + expect(client).toBeTruthy(); + operator = (await TestHelper.setupServerDatabase(serverUrl)).operator; + await DatabaseManager.setActiveServerDatabase(serverUrl); + post = TestHelper.fakePost(TestHelper.basicChannel!.id); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], true); + await operator.batchRecords(threadModels, 'test'); + }); + + afterEach(async () => { + await TestHelper.tearDown(); + NetworkManager.invalidateClient(serverUrl); + }); + + it('channel notification', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: 'default_on'}, + {id: 'FeatureFlagCollapsedThreads', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await pushNotificationEntry(serverUrl, { + channel_id: TestHelper.basicChannel!.id, + team_id: TestHelper.basicTeam!.id, + isCRTEnabled: false, // isCRTEnabled is not checked at this level + post_id: '', // Post ID is not checked at this level + type: '', // Type is not checked at this level + version: '', // Version is not checked at this level + }); + + expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL'); + }); + + it('thread notification', async () => { + const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); + commentPost.root_id = post.id; + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [commentPost.id], + posts: [commentPost], + prepareRecordsOnly: false, + }); + const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true); + await operator.batchRecords(threadModels, 'test'); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: 'default_on'}, + {id: 'FeatureFlagCollapsedThreads', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await pushNotificationEntry(serverUrl, { + root_id: post.id, + channel_id: TestHelper.basicChannel!.id, + team_id: TestHelper.basicTeam!.id, + isCRTEnabled: false, // isCRTEnabled is not checked at this level + post_id: '', // Post ID is not checked at this level + type: '', // Type is not checked at this level + version: '', // Version is not checked at this level + }); + + expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD'); + }); + + it('thread notification with wrong root id', async () => { + const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); + commentPost.root_id = post.id; + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [commentPost.id], + posts: [commentPost], + prepareRecordsOnly: false, + }); + const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true); + await operator.batchRecords(threadModels, 'test'); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: 'default_on'}, + {id: 'FeatureFlagCollapsedThreads', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await pushNotificationEntry(serverUrl, { + root_id: commentPost.id, + channel_id: TestHelper.basicChannel!.id, + team_id: TestHelper.basicTeam!.id, + isCRTEnabled: false, // isCRTEnabled is not checked at this level + post_id: '', // Post ID is not checked at this level + type: '', // Type is not checked at this level + version: '', // Version is not checked at this level + }); + + expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD'); + }); + + it('thread notification with non crt', async () => { + const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); + commentPost.root_id = post.id; + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [commentPost.id], + posts: [commentPost], + prepareRecordsOnly: false, + }); + const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true); + await operator.batchRecords(threadModels, 'test'); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: 'disabled'}, + {id: 'FeatureFlagCollapsedThreads', value: 'false'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await pushNotificationEntry(serverUrl, { + root_id: post.id, + channel_id: TestHelper.basicChannel!.id, + team_id: TestHelper.basicTeam!.id, + isCRTEnabled: false, // isCRTEnabled is not checked at this level + post_id: '', // Post ID is not checked at this level + type: '', // Type is not checked at this level + version: '', // Version is not checked at this level + }); + + expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL'); + }); +}); diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index 18063756477..2876c801526 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -7,6 +7,7 @@ import {fetchMyTeam} from '@actions/remote/team'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; import {getDefaultThemeByAppearance} from '@context/theme'; import DatabaseManager from '@database/manager'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getMyChannel} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; @@ -103,13 +104,16 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId); if (actualRootId) { + PerformanceMetricsManager.setLoadTarget('THREAD'); await fetchAndSwitchToThread(serverUrl, actualRootId, true); } else if (post) { + PerformanceMetricsManager.setLoadTarget('THREAD'); await fetchAndSwitchToThread(serverUrl, rootId, true); } else { emitNotificationError('Post'); } } else { + PerformanceMetricsManager.setLoadTarget('CHANNEL'); await switchToChannelById(serverUrl, channelId, teamId); } } diff --git a/app/actions/remote/performance.test.ts b/app/actions/remote/performance.test.ts new file mode 100644 index 00000000000..bf64faf789e --- /dev/null +++ b/app/actions/remote/performance.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {mockApiClient} from '@test/mock_api_client'; + +import {sendPerformanceReport} from './performance'; + +describe('sendPerformanceReport', () => { + const serverUrl = 'http://www.someserverurl.com'; + const report: PerformanceReport = { + counters: [], + start: 1234, + end: 1235, + histograms: [ + { + metric: 'metric1', + timestamp: 1234, + value: 123, + }, + { + metric: 'metric1', + timestamp: 1234, + value: 124, + }, + { + metric: 'metric2', + timestamp: 1234, + value: 125, + }, + ], + labels: { + agent: 'rnapp', + platform: 'ios', + }, + version: '0.1.0', + }; + + beforeAll(() => { + mockApiClient.post.mockImplementation(() => ({status: 200, ok: true})); + }); + + afterAll(() => { + mockApiClient.post.mockReset(); + }); + + beforeEach(async () => { + const client = await NetworkManager.createClient(serverUrl); + expect(client).toBeTruthy(); + }); + + afterEach(async () => { + NetworkManager.invalidateClient(serverUrl); + }); + + it('happy path', async () => { + const {error} = await sendPerformanceReport(serverUrl, report); + expect(error).toBeFalsy(); + expect(mockApiClient.post).toHaveBeenCalledWith(`${serverUrl}/api/v4/client_perf`, {body: report, headers: {}}); + }); + + it('properly returns error', async () => { + mockApiClient.post.mockImplementationOnce(() => ({status: 404, ok: false})); + const {error} = await sendPerformanceReport(serverUrl, report); + expect(error).toBeTruthy(); + }); +}); diff --git a/app/actions/remote/performance.ts b/app/actions/remote/performance.ts new file mode 100644 index 00000000000..4a68935d2d5 --- /dev/null +++ b/app/actions/remote/performance.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +import {forceLogoutIfNecessary} from './session'; + +export const sendPerformanceReport = async (serverUrl: string, report: PerformanceReport) => { + try { + const client = NetworkManager.getClient(serverUrl); + await client.sendPerformanceReport(report); + return {}; + } catch (error) { + logDebug('error on sendPerformanceReport', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index d557a7d12aa..8f80cf53e3e 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -52,7 +52,7 @@ type AuthorsRequest = { error?: unknown; } -export async function createPost(serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> { +export async function createPost(serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -571,7 +571,7 @@ export async function fetchPostThread(serverUrl: string, postId: string, options }); const result = processPostsFetched(data); let posts: Model[] = []; - if (!fetchOnly) { + if (result.posts.length && !fetchOnly) { const models: Model[] = []; posts = await operator.handlePosts({ ...result, diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 343cf8f3d36..a8f803ab3b0 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -226,6 +226,10 @@ export default class ClientBase { return this.getPluginRoute(Calls.PluginId); } + getPerformanceRoute() { + return `${this.urlVersion}/client_perf`; + } + doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => { let request; const method = options.method?.toLowerCase(); diff --git a/app/client/rest/general.ts b/app/client/rest/general.ts index c84f470864c..76d85a9cf93 100644 --- a/app/client/rest/general.ts +++ b/app/client/rest/general.ts @@ -24,6 +24,7 @@ export interface ClientGeneralMix { getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise>; getRolesByNames: (rolesNames: string[]) => Promise; getRedirectLocation: (urlParam: string) => Promise>; + sendPerformanceReport: (batch: PerformanceReport) => Promise<{}>; } const ClientGeneral = >(superclass: TBase) => class extends superclass { @@ -111,6 +112,13 @@ const ClientGeneral = >(superclass: TBase) const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`; return this.doFetch(url, {method: 'get'}); }; + + sendPerformanceReport = async (report: PerformanceReport) => { + return this.doFetch( + this.getPerformanceRoute(), + {method: 'post', body: report}, + ); + }; }; export default ClientGeneral; diff --git a/app/components/loading/index.tsx b/app/components/loading/index.tsx index 21b2f3169d6..f85d28bb231 100644 --- a/app/components/loading/index.tsx +++ b/app/components/loading/index.tsx @@ -13,6 +13,7 @@ type LoadingProps = { themeColor?: keyof Theme; footerText?: string; footerTextStyles?: TextStyle; + testID?: string; } const Loading = ({ @@ -22,12 +23,16 @@ const Loading = ({ themeColor, footerText, footerTextStyles, + testID, }: LoadingProps) => { const theme = useTheme(); const indicatorColor = themeColor ? theme[themeColor] : color; return ( - + { + let database: Database; + let post: PostModel; + + function getBaseProps(): ComponentProps { + return { + appsEnabled: false, + canDelete: false, + customEmojiNames: [], + differentThreadSequence: false, + hasFiles: false, + hasReactions: false, + hasReplies: false, + highlightReplyBar: false, + isEphemeral: false, + isPostAddChannelMember: false, + isPostPriorityEnabled: false, + location: 'Channel', + post, + isLastPost: true, + }; + } + + const serverUrl = 'http://www.someserverurl.com'; + beforeEach(async () => { + const client = await NetworkManager.createClient(serverUrl); + expect(client).toBeTruthy(); + database = (await TestHelper.setupServerDatabase(serverUrl)).database; + post = (await getPostById(database, TestHelper.basicPost!.id))!; + }); + + afterEach(async () => { + await TestHelper.tearDown(); + NetworkManager.invalidateClient(serverUrl); + }); + + it('do not call the performance metrics if it is not the last post', () => { + const props = getBaseProps(); + props.isLastPost = false; + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.finishLoad).not.toHaveBeenCalled(); + expect(PerformanceMetricsManager.endMetric).not.toHaveBeenCalled(); + }); + it('on channel', () => { + const props = getBaseProps(); + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('CHANNEL', serverUrl); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl); + }); + it('on thread', () => { + const props = getBaseProps(); + props.location = 'Thread'; + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('THREAD', serverUrl); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl); + }); +}); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 476e12b87b2..bca30018a7c 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -17,6 +17,7 @@ import * as Screens from '@constants/screens'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {openAsBottomSheet} from '@screens/navigation'; import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; @@ -60,6 +61,7 @@ type PostProps = { post: PostModel; rootId?: string; previousPost?: PostModel; + isLastPost: boolean; hasReactions: boolean; searchPatterns?: SearchPattern[]; shouldRenderReplyButton?: boolean; @@ -109,10 +111,39 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Post = ({ - appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, - isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled, - location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, - testID, thread, previousPost, + appsEnabled, + canDelete, + currentUser, + customEmojiNames, + differentThreadSequence, + hasFiles, + hasReplies, + highlight, + highlightPinnedOrSaved = true, + highlightReplyBar, + isCRTEnabled, + isConsecutivePost, + isEphemeral, + isFirstReply, + isSaved, + isLastReply, + isPostAcknowledgementEnabled, + isPostAddChannelMember, + isPostPriorityEnabled, + location, + post, + rootId, + hasReactions, + searchPatterns, + shouldRenderReplyButton, + skipSavedHeader, + skipPinnedHeader, + showAddReaction = true, + style, + testID, + thread, + previousPost, + isLastPost, }: PostProps) => { const pressDetected = useRef(false); const intl = useIntl(); @@ -216,6 +247,19 @@ const Post = ({ }; }, [post.id]); + useEffect(() => { + if (!isLastPost) { + return; + } + + if (location !== 'Channel' && location !== 'Thread') { + return; + } + + PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl); + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl); + }, []); + const highlightSaved = isSaved && !skipSavedHeader; const hightlightPinned = post.isPinned && !skipPinnedHeader; const itemTestID = `${testID}.${post.id}`; diff --git a/app/components/team_sidebar/team_list/team_item/team_item.test.tsx b/app/components/team_sidebar/team_list/team_item/team_item.test.tsx new file mode 100644 index 00000000000..ff4195d9783 --- /dev/null +++ b/app/components/team_sidebar/team_list/team_item/team_item.test.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render, screen, userEvent} from '@testing-library/react-native'; +import React, {type ComponentProps} from 'react'; + +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {getTeamById} from '@queries/servers/team'; +import TestHelper from '@test/test_helper'; + +import TeamItem from './team_item'; + +import type TeamModel from '@typings/database/models/servers/team'; + +jest.mock('@managers/performance_metrics_manager'); + +function getBaseProps(): ComponentProps { + return { + hasUnreads: false, + mentionCount: 0, + selected: false, + }; +} + +describe('performance metrics', () => { + const serverUrl = 'http://www.someserverurl.com'; + let team: TeamModel | undefined; + beforeAll(() => { + jest.useFakeTimers({legacyFakeTimers: true}); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + userEvent.setup({advanceTimers: jest.advanceTimersByTime}); + const {database} = await TestHelper.setupServerDatabase(serverUrl); + team = await getTeamById(database, TestHelper.basicTeam!.id); + }); + + afterEach(async () => { + await TestHelper.tearDown(); + }); + + it('happy path', async () => { + const baseProps = getBaseProps(); + baseProps.team = team; + render(); + const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.not_selected`); + await userEvent.press(button); + expect(PerformanceMetricsManager.startMetric).toHaveBeenCalledWith('mobile_team_switch'); + }); + + it('do not start when the team is already selected', async () => { + const baseProps = getBaseProps(); + baseProps.team = team; + baseProps.selected = true; + render(); + const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.selected`); + await userEvent.press(button); + expect(PerformanceMetricsManager.startMetric).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/team_sidebar/team_list/team_item/team_item.tsx b/app/components/team_sidebar/team_list/team_item/team_item.tsx index c3a204b26be..89d9b73fb8e 100644 --- a/app/components/team_sidebar/team_list/team_item/team_item.tsx +++ b/app/components/team_sidebar/team_list/team_item/team_item.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import {handleTeamChange} from '@actions/remote/team'; @@ -9,6 +9,7 @@ import Badge from '@components/badge'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {makeStyleSheetFromTheme} from '@utils/theme'; import TeamIcon from './team_icon'; @@ -62,6 +63,15 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); + const onPress = useCallback(() => { + if (!team || selected) { + return; + } + + PerformanceMetricsManager.startMetric('mobile_team_switch'); + handleTeamChange(serverUrl, team.id); + }, [selected, team?.id, serverUrl]); + if (!team) { return null; } @@ -92,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro <> handleTeamChange(serverUrl, team.id)} + onPress={onPress} type='opacity' testID={teamItemTestId} > diff --git a/app/init/launch.ts b/app/init/launch.ts index f03c6426c2e..c9cfb365c75 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -12,6 +12,7 @@ import LocalConfig from '@assets/config.json'; import {DeepLink, Events, Launch, PushNotification} from '@constants'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global'; import {getThemeForCurrentTeam} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; @@ -178,9 +179,13 @@ const launchToHome = async (props: LaunchProps) => { const lastViewedThread = await getLastViewedThreadIdAndServer(); if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) { + PerformanceMetricsManager.setLoadTarget('THREAD'); fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id); } else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) { + PerformanceMetricsManager.setLoadTarget('CHANNEL'); switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id); + } else { + PerformanceMetricsManager.setLoadTarget('HOME'); } appEntry(props.serverUrl!); diff --git a/app/managers/performance_metrics_manager/index.test.ts b/app/managers/performance_metrics_manager/index.test.ts new file mode 100644 index 00000000000..165c650649a --- /dev/null +++ b/app/managers/performance_metrics_manager/index.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import performance from 'react-native-performance'; + +import {mockApiClient} from '@test/mock_api_client'; +import TestHelper from '@test/test_helper'; + +import NetworkManager from '../network_manager'; + +import {getBaseReportRequest} from './test_utils'; + +import PerformanceMetricsManager from '.'; + +const TEST_EPOCH = 1577836800000; +jest.mock('@utils/log', () => ({ + logDebug: () => '', +})); + +performance.timeOrigin = TEST_EPOCH; + +describe('load metrics', () => { + const serverUrl = 'http://www.someserverurl.com/'; + const expectedUrl = `${serverUrl}/api/v4/client_perf`; + + const measure: PerformanceReportMeasure = { + metric: 'mobile_load', + timestamp: TEST_EPOCH + 61000, + value: 61000, + }; + + beforeEach(async () => { + NetworkManager.createClient(serverUrl); + const {operator} = await TestHelper.setupServerDatabase(serverUrl); + await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); + jest.useFakeTimers({doNotFake: [ + 'cancelAnimationFrame', + 'cancelIdleCallback', + 'clearImmediate', + 'clearInterval', + 'clearTimeout', + 'hrtime', + 'nextTick', + 'queueMicrotask', + 'requestAnimationFrame', + 'requestIdleCallback', + 'setImmediate', + 'setInterval', + ]}).setSystemTime(new Date(TEST_EPOCH)); + }); + afterEach(async () => { + jest.useRealTimers(); + NetworkManager.invalidateClient(serverUrl); + await TestHelper.tearDown(); + }); + + it('only load on target', async () => { + performance.mark('nativeLaunchStart'); + const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); + expectedRequest.body.histograms = [measure]; + + PerformanceMetricsManager.setLoadTarget('CHANNEL'); + PerformanceMetricsManager.finishLoad('HOME', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + }); +}); + +describe('other metrics', () => { + const serverUrl1 = 'http://www.someserverurl.com/'; + const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`; + + const serverUrl2 = 'http://www.otherserverurl.com/'; + const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`; + + const measure1: PerformanceReportMeasure = { + metric: 'mobile_channel_switch', + timestamp: TEST_EPOCH + 100, + value: 100, + }; + + const measure2: PerformanceReportMeasure = { + metric: 'mobile_team_switch', + timestamp: TEST_EPOCH + 150 + 50, + value: 150, + }; + + beforeEach(async () => { + NetworkManager.createClient(serverUrl1); + NetworkManager.createClient(serverUrl2); + const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1); + const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2); + await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); + await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); + jest.useFakeTimers({doNotFake: [ + 'cancelAnimationFrame', + 'cancelIdleCallback', + 'clearImmediate', + 'clearInterval', + 'clearTimeout', + 'hrtime', + 'nextTick', + 'queueMicrotask', + 'requestAnimationFrame', + 'requestIdleCallback', + 'setImmediate', + 'setInterval', + ]}).setSystemTime(new Date(TEST_EPOCH)); + }); + afterEach(async () => { + jest.useRealTimers(); + NetworkManager.invalidateClient(serverUrl1); + NetworkManager.invalidateClient(serverUrl2); + await TestHelper.tearDown(); + }); + + it('do not send metrics when we do not start them', async () => { + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('send metric after it has been started', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('a second end metric does not generate a second measure', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('different metrics do not interfere', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); + expectedRequest.body.histograms = [measure1, measure2]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(50); + PerformanceMetricsManager.startMetric('mobile_team_switch'); + jest.advanceTimersByTime(50); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('metrics to different servers do not interfere', async () => { + const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest1.body.histograms = [measure1]; + + const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1); + expectedRequest2.body.histograms = [measure2]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(50); + PerformanceMetricsManager.startMetric('mobile_team_switch'); + jest.advanceTimersByTime(50); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2); + await TestHelper.tick(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2); + }); +}); diff --git a/app/managers/performance_metrics_manager/index.ts b/app/managers/performance_metrics_manager/index.ts new file mode 100644 index 00000000000..db97b4ee4cd --- /dev/null +++ b/app/managers/performance_metrics_manager/index.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AppState, type AppStateStatus} from 'react-native'; +import performance from 'react-native-performance'; + +import Batcher from './performance_metrics_batcher'; + +type Target = 'HOME' | 'CHANNEL' | 'THREAD' | undefined; +type MetricName = 'mobile_channel_switch' | + 'mobile_team_switch'; + +class PerformanceMetricsManager { + private target: Target; + private batchers: {[serverUrl: string]: Batcher} = {}; + private hasRegisteredLoad = false; + private lastAppStateIsActive = AppState.currentState === 'active'; + + constructor() { + AppState.addEventListener('change', (appState) => this.onAppStateChange(appState)); + } + + private onAppStateChange(appState: AppStateStatus) { + const isAppStateActive = appState === 'active'; + if (this.lastAppStateIsActive !== isAppStateActive && !isAppStateActive) { + for (const batcher of Object.values(this.batchers)) { + batcher.forceSend(); + } + } + this.lastAppStateIsActive = isAppStateActive; + } + + private ensureBatcher(serverUrl: string) { + if (this.batchers[serverUrl]) { + return this.batchers[serverUrl]; + } + + this.batchers[serverUrl] = new Batcher(serverUrl); + return this.batchers[serverUrl]; + } + + public setLoadTarget(target: Target) { + this.target = target; + } + + public finishLoad(location: Target, serverUrl: string) { + if (this.target !== location || this.hasRegisteredLoad) { + return; + } + + const measure = performance.measure('mobile_load', 'nativeLaunchStart'); + this.ensureBatcher(serverUrl).addToBatch({ + metric: 'mobile_load', + value: measure.duration, + timestamp: Date.now(), + }); + performance.clearMeasures('mobile_load'); + this.hasRegisteredLoad = true; + } + + public startMetric(metricName: MetricName) { + performance.mark(metricName); + } + + public endMetric(metricName: MetricName, serverUrl: string) { + const marks = performance.getEntriesByName(metricName, 'mark'); + if (!marks.length) { + return; + } + + const measureName = `${metricName}_measure`; + const measure = performance.measure(measureName, metricName); + + this.ensureBatcher(serverUrl).addToBatch({ + metric: metricName, + value: measure.duration, + timestamp: Date.now(), + }); + + performance.clearMarks(metricName); + performance.clearMeasures(measureName); + } +} + +export default new PerformanceMetricsManager(); diff --git a/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts b/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts new file mode 100644 index 00000000000..4092909cdcb --- /dev/null +++ b/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {mockApiClient} from '@test/mock_api_client'; +import TestHelper from '@test/test_helper'; + +import Batcher from './performance_metrics_batcher'; +import {getBaseReportRequest} from './test_utils'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +jest.mock('@utils/log', () => ({ + logDebug: () => '', +})); + +describe('perfromance metrics batcher', () => { + const serverUrl = 'http://www.someserverurl.com'; + const expectedUrl = `${serverUrl}/api/v4/client_perf`; + + const measure1: PerformanceReportMeasure = { + metric: 'someMetric', + timestamp: 1234, + value: 1.5, + }; + let operator: ServerDataOperator; + + const measure2: PerformanceReportMeasure = { + metric: 'someOtherMetric', + timestamp: 1235, + value: 2.5, + }; + + const measure3: PerformanceReportMeasure = { + metric: 'yetAnother', + timestamp: 1236, + value: 0.5, + }; + + async function setMetricsConfig(value: string) { + await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value}], configsToDelete: [], prepareRecordsOnly: false}); + } + + beforeEach(async () => { + NetworkManager.createClient(serverUrl); + operator = (await TestHelper.setupServerDatabase(serverUrl)).operator; + await setMetricsConfig('true'); + jest.useFakeTimers({doNotFake: [ + 'Date', + 'cancelAnimationFrame', + 'cancelIdleCallback', + 'clearImmediate', + 'clearInterval', + 'clearTimeout', + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'requestAnimationFrame', + 'requestIdleCallback', + 'setImmediate', + 'setInterval', + ]}); + }); + afterEach(async () => { + jest.useRealTimers(); + NetworkManager.invalidateClient(serverUrl); + await TestHelper.tearDown(); + }); + + it('properly send batches only after timeout', async () => { + const batcher = new Batcher(serverUrl); + + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); + expectedRequest.body.histograms = [measure1, measure2]; + + batcher.addToBatch(measure1); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + + batcher.addToBatch(measure2); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + }); + + it('properly set end after start when only one element', async () => { + const batcher = new Batcher(serverUrl); + + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + batcher.addToBatch(measure1); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + }); + + it('send the batch directly after maximum batch size is reached', async () => { + const batcher = new Batcher(serverUrl); + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); + for (let i = 0; i < 99; i++) { + batcher.addToBatch(measure1); + expectedRequest.body.histograms.push(measure1); + } + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + + batcher.addToBatch(measure2); + expectedRequest.body.histograms.push(measure2); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledTimes(1); + }); + + it('do not send batches when the config is set to false', async () => { + await setMetricsConfig('false'); + const batcher = new Batcher(serverUrl); + batcher.addToBatch(measure2); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('do not send batches when the config is set to false even on force send', async () => { + await setMetricsConfig('false'); + const batcher = new Batcher(serverUrl); + batcher.addToBatch(measure2); + batcher.forceSend(); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('old elements do not drip into the next batch', async () => { + const batcher = new Batcher(serverUrl); + let expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + batcher.addToBatch(measure1); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest); + + expectedRequest = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1); + expectedRequest.body.histograms = []; + for (let i = 0; i < 100; i++) { + batcher.addToBatch(measure2); + expectedRequest.body.histograms.push(measure2); + } + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest); + + expectedRequest = getBaseReportRequest(measure3.timestamp, measure3.timestamp + 1); + expectedRequest.body.histograms = [measure3]; + + batcher.addToBatch(measure3); + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest); + }); + + it('force send sends the batch, and does not get resent after the timeout', async () => { + const batcher = new Batcher(serverUrl); + + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); + expectedRequest.body.histograms = [measure1, measure2]; + + batcher.addToBatch(measure1); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + + batcher.addToBatch(measure2); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + + mockApiClient.post.mockClear(); + + jest.advanceTimersByTime(61000); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); +}); diff --git a/app/managers/performance_metrics_manager/performance_metrics_batcher.ts b/app/managers/performance_metrics_manager/performance_metrics_batcher.ts new file mode 100644 index 00000000000..aad2210c2cc --- /dev/null +++ b/app/managers/performance_metrics_manager/performance_metrics_batcher.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Platform} from 'react-native'; + +import {sendPerformanceReport} from '@actions/remote/performance'; +import DatabaseManager from '@database/manager'; +import {getConfigValue} from '@queries/servers/system'; +import {toMilliseconds} from '@utils/datetime'; +import {logDebug} from '@utils/log'; + +const MAX_BATCH_SIZE = 100; +const INTERVAL_TIME = toMilliseconds({seconds: 60}); + +class Batcher { + private batch: PerformanceReportMeasure[] = []; + private serverUrl: string; + private sendTimeout: NodeJS.Timeout | undefined; + + constructor(serverUrl: string) { + this.serverUrl = serverUrl; + } + + private started() { + return Boolean(this.sendTimeout); + } + + private clearTimeout() { + clearTimeout(this.sendTimeout); + this.sendTimeout = undefined; + } + + private start() { + this.clearTimeout(); + this.sendTimeout = setTimeout(() => this.sendBatch(), INTERVAL_TIME); + } + + private async sendBatch() { + this.clearTimeout(); + if (this.batch.length === 0) { + return; + } + + const toSend = this.getReport(); + + // Empty the batch as soon as possible to avoid race conditions + this.batch = []; + + const database = DatabaseManager.serverDatabases[this.serverUrl]?.database; + if (!database) { + return; + } + + const clientPerformanceSetting = await getConfigValue(database, 'EnableClientMetrics'); + if (clientPerformanceSetting !== 'true') { + return; + } + + await sendPerformanceReport(this.serverUrl, toSend); + } + + private getReport(): PerformanceReport { + let start = this.batch[0].timestamp; + let end = this.batch[0].timestamp; + for (const measure of this.batch) { + start = Math.min(start, measure.timestamp); + end = Math.max(end, measure.timestamp); + } + if (start === end) { + end += 1; + } + + return { + version: '0.1.0', + labels: { + platform: Platform.select({ios: 'ios', default: 'android'}), + agent: 'rnapp', + }, + start, + end, + counters: [], + histograms: this.batch, + }; + } + + public addToBatch(measure: PerformanceReportMeasure) { + if (!this.started()) { + this.start(); + } + + logDebug('Performance metric:', measure); + this.batch.push(measure); + if (this.batch.length >= MAX_BATCH_SIZE) { + this.sendBatch(); + } + } + + public forceSend() { + this.sendBatch(); + } +} + +export default Batcher; diff --git a/app/managers/performance_metrics_manager/test_utils.ts b/app/managers/performance_metrics_manager/test_utils.ts new file mode 100644 index 00000000000..232261704c5 --- /dev/null +++ b/app/managers/performance_metrics_manager/test_utils.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function getBaseReportRequest(start: number, end: number): {body: PerformanceReport; headers: {}} { + return { + body: { + version: '0.1.0', + start, + end, + labels: {agent: 'rnapp', platform: 'ios'}, + histograms: [], + counters: [], + }, + headers: {}, + }; +} diff --git a/app/managers/performance_metrics_manager/types.d.ts b/app/managers/performance_metrics_manager/types.d.ts new file mode 100644 index 00000000000..fabff0a3b8e --- /dev/null +++ b/app/managers/performance_metrics_manager/types.d.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type PerformanceReportMeasure = { + metric: string; + value: number; + timestamp: number; +} + +type PerformanceReport = { + version: '0.1.0'; + + labels: { + platform: PlatformLabel; + agent: 'rnapp'; + }; + + start: number; + end: number; + + counters: PerformanceReportMeasure[]; + histograms: PerformanceReportMeasure[]; +} diff --git a/app/screens/home/channel_list/categories_list/categories/categories.test.tsx b/app/screens/home/channel_list/categories_list/categories/categories.test.tsx index 851539b13ef..bfacb314bc6 100644 --- a/app/screens/home/channel_list/categories_list/categories/categories.test.tsx +++ b/app/screens/home/channel_list/categories_list/categories/categories.test.tsx @@ -2,14 +2,19 @@ // See LICENSE.txt for license information. import React from 'react'; +import {DeviceEventEmitter} from 'react-native'; -import {renderWithEverything} from '@test/intl-test-helper'; +import {Events} from '@constants'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {renderWithEverything, act, waitFor, screen, waitForElementToBeRemoved} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; import Categories from '.'; import type Database from '@nozbe/watermelondb/Database'; +jest.mock('@managers/performance_metrics_manager'); + describe('components/channel_list/categories', () => { let database: Database; beforeAll(async () => { @@ -17,6 +22,10 @@ describe('components/channel_list/categories', () => { database = server.database; }); + afterAll(async () => { + await TestHelper.tearDown(); + }); + it('render without error', () => { const wrapper = renderWithEverything( , @@ -26,3 +35,37 @@ describe('components/channel_list/categories', () => { expect(wrapper.toJSON()).toBeTruthy(); }); }); + +describe('performance metrics', () => { + let database: Database; + const serverUrl = 'http://www.someserverurl.com'; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + }); + + afterAll(async () => { + await TestHelper.tearDown(); + }); + + it('properly send metric on load', () => { + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl); + }); + + it('properly call again after switching teams', async () => { + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1); + act(() => { + DeviceEventEmitter.emit(Events.TEAM_SWITCH, true); + }); + await waitFor(() => expect(screen.queryByTestId('categories.loading')).toBeVisible()); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1); + act(() => { + DeviceEventEmitter.emit(Events.TEAM_SWITCH, false); + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('categories.loading')); + expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(2); + expect(PerformanceMetricsManager.endMetric).toHaveBeenLastCalledWith('mobile_team_switch', serverUrl); + }); +}); diff --git a/app/screens/home/channel_list/categories_list/categories/categories.tsx b/app/screens/home/channel_list/categories_list/categories/categories.tsx index d7e42b9781c..6e2e19fe32b 100644 --- a/app/screens/home/channel_list/categories_list/categories/categories.tsx +++ b/app/screens/home/channel_list/categories_list/categories/categories.tsx @@ -10,6 +10,7 @@ import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; import {useTeamSwitch} from '@hooks/team_switch'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import CategoryBody from './body'; import LoadCategoriesError from './error'; @@ -68,6 +69,7 @@ const Categories = ({ const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length); const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => { + PerformanceMetricsManager.startMetric('mobile_channel_switch'); switchToChannelById(serverUrl, c.id); }, [serverUrl]); @@ -103,6 +105,14 @@ const Categories = ({ return () => clearTimeout(t); }, []); + useEffect(() => { + if (switchingTeam) { + return; + } + + PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl); + }, [switchingTeam]); + if (!categories.length) { return ; } @@ -139,6 +149,7 @@ const Categories = ({ )} diff --git a/app/screens/home/channel_list/channel_list.test.tsx b/app/screens/home/channel_list/channel_list.test.tsx new file mode 100644 index 00000000000..b6ca4a39f81 --- /dev/null +++ b/app/screens/home/channel_list/channel_list.test.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import ChannelListScreen from './channel_list'; + +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('@managers/performance_metrics_manager'); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => true, + useNavigation: () => ({isFocused: () => true}), + useRoute: () => ({}), +})); + +function getBaseProps(): ComponentProps { + return { + hasChannels: true, + hasCurrentUser: true, + hasMoreThanOneTeam: true, + hasTeams: true, + isCRTEnabled: true, + isLicensed: true, + launchType: 'normal', + showIncomingCalls: true, + showToS: false, + currentUserId: 'someId', + }; +} + +describe('performance metrics', () => { + let database: Database; + const serverUrl = 'http://www.someserverurl.com'; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + }); + + it('finish load on load', () => { + jest.useFakeTimers(); + const props = getBaseProps(); + renderWithEverything(, {database, serverUrl}); + expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('HOME', serverUrl); + jest.useRealTimers(); + }); +}); diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 9baec4ae11c..5617908af74 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -18,6 +18,7 @@ import {Navigation as NavigationConstants, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {resetToTeams, openToS} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {isMainActivity} from '@utils/helpers'; @@ -169,6 +170,10 @@ const ChannelListScreen = (props: ChannelProps) => { } }, []); + useEffect(() => { + PerformanceMetricsManager.finishLoad('HOME', serverUrl); + }, []); + return ( <> diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5f2a709a5aa..9ab4c7c5c22 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -952,6 +952,8 @@ PODS: - react-native-paste-input (0.7.1): - React-Core - Swime (= 3.0.6) + - react-native-performance (5.1.2): + - React-Core - react-native-safe-area-context (4.9.0): - React-Core - react-native-video (5.2.1): @@ -1267,6 +1269,7 @@ DEPENDENCIES: - "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)" - react-native-notifications (from `../node_modules/react-native-notifications`) - "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)" + - react-native-performance (from `../node_modules/react-native-performance`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-video (from `../node_modules/react-native-video`) - react-native-webrtc (from `../node_modules/react-native-webrtc`) @@ -1424,6 +1427,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-notifications" react-native-paste-input: :path: "../node_modules/@mattermost/react-native-paste-input" + react-native-performance: + :path: "../node_modules/react-native-performance" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" react-native-video: @@ -1581,6 +1586,7 @@ SPEC CHECKSUMS: react-native-network-client: a7e0e465f0de5ea75cef5c557df0d9dc0adbf6a9 react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4 react-native-paste-input: d2136a8269eb8ad57d81407ee2b8a646f738a694 + react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253 react-native-webrtc: 255a1172fd31525b952b36aef7b8e9a41de325e5 diff --git a/package-lock.json b/package-lock.json index 836fd936594..9737641df91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "react-native-math-view": "3.9.5", "react-native-navigation": "7.39.1", "react-native-notifications": "5.1.0", + "react-native-performance": "5.1.2", "react-native-permissions": "4.1.5", "react-native-reanimated": "3.8.1", "react-native-safe-area-context": "4.9.0", @@ -17258,6 +17259,14 @@ "react-native": "*" } }, + "node_modules/react-native-performance": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-5.1.2.tgz", + "integrity": "sha512-l5JOJphNzox9a9icL3T6O/gEqZuqWqcbejW04WPa10m0UanBdIYrNkPFl48B3ivWw3MabpjB6GiDYv7old9/fw==", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-permissions": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-4.1.5.tgz", diff --git a/package.json b/package.json index 0b5ce2676b9..fdfbaff6a9c 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "react-native-math-view": "3.9.5", "react-native-navigation": "7.39.1", "react-native-notifications": "5.1.0", + "react-native-performance": "5.1.2", "react-native-permissions": "4.1.5", "react-native-reanimated": "3.8.1", "react-native-safe-area-context": "4.9.0", diff --git a/test/intl-test-helper.tsx b/test/intl-test-helper.tsx index b424f7a1787..159295c14ca 100644 --- a/test/intl-test-helper.tsx +++ b/test/intl-test-helper.tsx @@ -2,11 +2,12 @@ // See LICENSE.txt for license information. import {DatabaseProvider} from '@nozbe/watermelondb/react'; -import {render} from '@testing-library/react-native'; +import {render, type RenderOptions} from '@testing-library/react-native'; import React, {type ReactElement} from 'react'; import {IntlProvider} from 'react-intl'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import ServerUrlProvider from '@context/server'; import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme'; import {getTranslations} from '@i18n'; @@ -48,13 +49,13 @@ export function renderWithIntlAndTheme(ui: ReactElement, {locale = 'en', ...rend return render(ui, {wrapper: Wrapper, ...renderOptions}); } -export function renderWithEverything(ui: ReactElement, {locale = 'en', database, ...renderOptions}: {locale?: string; database?: Database; renderOptions?: any} = {}) { +export function renderWithEverything(ui: ReactElement, {locale = 'en', database, serverUrl, ...renderOptions}: {locale?: string; database?: Database; serverUrl?: string; renderOptions?: RenderOptions} = {}) { function Wrapper({children}: {children: ReactElement}) { if (!database) { return null; } - return ( + const wrapper = ( ); + + if (serverUrl) { + return ( + + {wrapper} + + ); + } + + return wrapper; } return render(ui, {wrapper: Wrapper, ...renderOptions}); diff --git a/test/mock_api_client.ts b/test/mock_api_client.ts new file mode 100644 index 00000000000..4e470f42f32 --- /dev/null +++ b/test/mock_api_client.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {RequestOptions} from '@mattermost/react-native-network-client'; + +export const mockApiClient = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + post: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})), +}; diff --git a/test/setup.ts b/test/setup.ts index 89aad0e2f24..78abb38066f 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -8,6 +8,9 @@ import * as ReactNative from 'react-native'; import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import {v4 as uuidv4} from 'uuid'; +import {mockApiClient} from './mock_api_client'; + +import type {RequestOptions} from '@mattermost/react-native-network-client'; import type {ReadDirItem, StatResult} from 'react-native-fs'; import 'react-native-gesture-handler/jestSetup'; @@ -186,17 +189,11 @@ jest.doMock('react-native', () => { jest.mock('react-native-vector-icons', () => { const React = jest.requireActual('react'); - const PropTypes = jest.requireActual('prop-types'); class CompassIcon extends React.PureComponent { render() { return React.createElement('Icon', this.props); } } - CompassIcon.propTypes = { - name: PropTypes.string, - size: PropTypes.number, - style: PropTypes.oneOfType([PropTypes.array, PropTypes.number, PropTypes.object]), - }; CompassIcon.getImageSource = jest.fn().mockResolvedValue({}); return { createIconSet: () => CompassIcon, @@ -256,6 +253,8 @@ jest.mock('react-native-device-info', () => { hasNotch: jest.fn(() => true), isTablet: jest.fn(() => false), getApplicationName: jest.fn(() => 'Mattermost'), + getSystemName: jest.fn(() => 'ios'), + getSystemVersion: jest.fn(() => '0.0.0'), }; }); @@ -367,6 +366,8 @@ jest.mock('@screens/navigation', () => ({ popToRoot: jest.fn(() => Promise.resolve()), dismissModal: jest.fn(() => Promise.resolve()), dismissAllModals: jest.fn(() => Promise.resolve()), + dismissAllModalsAndPopToScreen: jest.fn(), + dismissAllModalsAndPopToRoot: jest.fn(), dismissOverlay: jest.fn(() => Promise.resolve()), })); @@ -386,6 +387,22 @@ jest.mock('@mattermost/react-native-emm', () => ({ useManagedConfig: () => ({}), })); +jest.mock('@react-native-clipboard/clipboard', () => ({})); + +jest.mock('react-native-document-picker', () => ({})); + +jest.mock('@mattermost/react-native-network-client', () => ({ + getOrCreateAPIClient: (serverUrl: string) => ({client: { + baseUrl: serverUrl, + get: (url: string, options?: RequestOptions) => mockApiClient.get(`${serverUrl}${url}`, options), + post: (url: string, options?: RequestOptions) => mockApiClient.post(`${serverUrl}${url}`, options), + invalidate: jest.fn(), + }}), + RetryTypes: { + EXPONENTIAL_RETRY: 'exponential', + }, +})); + jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock')); @@ -399,7 +416,15 @@ jest.mock('react-native-haptic-feedback', () => { }; }); -declare const global: {requestAnimationFrame: (callback: any) => void}; +declare const global: { + requestAnimationFrame: (callback: () => void) => void; + performance: { + now: () => number; + }; +}; + global.requestAnimationFrame = (callback) => { setTimeout(callback, 0); }; + +global.performance.now = () => Date.now(); diff --git a/test/test_helper.ts b/test/test_helper.ts index 0962b1e7dfd..0439a1177ab 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -10,6 +10,7 @@ import nock from 'nock'; import Config from '@assets/config.json'; import {Client} from '@client/rest'; +import {ActionType} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import DatabaseManager from '@database/manager'; @@ -18,7 +19,6 @@ import {generateId} from '@utils/general'; import type {APIClientInterface} from '@mattermost/react-native-network-client'; -const PASSWORD = 'password1'; const DEFAULT_LOCALE = 'en'; class TestHelper { @@ -51,8 +51,8 @@ class TestHelper { this.basicRoles = null; } - setupServerDatabase = async () => { - const serverUrl = 'https://appv1.mattermost.com'; + setupServerDatabase = async (url?: string) => { + const serverUrl = url || 'https://appv1.mattermost.com'; await DatabaseManager.init([serverUrl]); const {database, operator} = DatabaseManager.serverDatabases[serverUrl]!; @@ -112,6 +112,13 @@ class TestHelper { systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}], }); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [this.basicPost!.id], + posts: [this.basicPost!], + prepareRecordsOnly: false, + }); + return {database, operator}; }; @@ -291,7 +298,7 @@ class TestHelper { return 'success' + this.generateId() + '@simulator.amazonses.com'; }; - fakePost = (channelId: string) => { + fakePost = (channelId: string, userId?: string): Post => { const time = Date.now(); return { @@ -301,6 +308,17 @@ class TestHelper { update_at: time, message: `Unit Test ${this.generateId()}`, type: '', + delete_at: 0, + edit_at: 0, + hashtags: '', + is_pinned: false, + metadata: {}, + original_id: '', + pending_post_id: '', + props: {}, + reply_count: 0, + root_id: '', + user_id: userId || this.generateId(), }; }; @@ -314,7 +332,7 @@ class TestHelper { }; }; - fakeTeam = () => { + fakeTeam = (): Team => { const name = this.generateId(); let inviteId = this.generateId(); if (inviteId.length > 32) { @@ -322,6 +340,7 @@ class TestHelper { } return { + id: this.generateId(), name, display_name: `Unit Test ${name}`, type: 'O' as const, @@ -334,6 +353,9 @@ class TestHelper { allow_open_invite: true, group_constrained: false, last_team_icon_update: 0, + create_at: 0, + delete_at: 0, + update_at: 0, }; }; @@ -361,11 +383,9 @@ class TestHelper { }; }; - fakeUser = () => { + fakeUser = (): UserProfile => { return { email: this.fakeEmail(), - allow_marketing: true, - password: PASSWORD, locale: DEFAULT_LOCALE, username: this.generateId(), first_name: this.generateId(), @@ -373,6 +393,27 @@ class TestHelper { create_at: Date.now(), delete_at: 0, roles: 'system_user', + auth_service: '', + id: this.generateId(), + nickname: '', + notify_props: this.fakeNotifyProps(), + position: '', + update_at: 0, + }; + }; + + fakeNotifyProps = (): UserNotifyProps => { + return { + channel: 'false', + comments: 'root', + desktop: 'default', + desktop_sound: 'false', + email: 'false', + first_name: 'false', + highlight_keys: '', + mention_keys: '', + push: 'default', + push_status: 'away', }; }; @@ -665,6 +706,7 @@ class TestHelper { }; wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time)); + tick = () => new Promise((r) => setImmediate(r)); } export default new TestHelper(); diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 567c53d85e6..195415e52e0 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -46,6 +46,7 @@ interface ClientConfig { EnableBanner: string; EnableBotAccountCreation: string; EnableChannelViewedMessages: string; + EnableClientMetrics?: string; EnableCluster: string; EnableCommands: string; EnableCompliance: string;