diff --git a/src/shared/performance/types.tsx b/src/shared/performance/types.tsx new file mode 100644 index 00000000000..18d04e4251a --- /dev/null +++ b/src/shared/performance/types.tsx @@ -0,0 +1,32 @@ +export interface Metric { + metricName: string + value: number +} + +export interface RouteParams { + navigationStartTimeRef?: number +} + +export interface Route { + params?: RouteParams +} + +export interface UsePerformanceProfilerOptions { + route?: Route + shouldProfile?: boolean +} + +export interface PerformanceService { + startTrace: (traceName: string) => Promise + sanitizeMetricName: (traceName: string) => string + sanitizeTraceName: (traceName: string) => string +} + +export interface Trace { + putMetric: (metricName: string, value: number) => void + stop: () => void +} + +export interface InteractionService { + runAfterInteractions: (callback: () => void) => { cancel: () => void } +} diff --git a/src/shared/performance/useFirebasePerformanceProfiler.tsx b/src/shared/performance/useFirebasePerformanceProfiler.tsx new file mode 100644 index 00000000000..d142dc78f46 --- /dev/null +++ b/src/shared/performance/useFirebasePerformanceProfiler.tsx @@ -0,0 +1,58 @@ +import perf from '@react-native-firebase/perf' +import { InteractionManager } from 'react-native' + +import { useAppStartTimeStore } from 'shared/performance/appStartTimeStore' +import { usePerformanceProfiler } from 'shared/performance/usePerformanceProfiler' + +import { Route } from './types' + +function sanitizeName(name: string, length: number): string { + // Supprimer les espaces en début et fin + let sanitized = name.trim() + // Supprimer les underscores au début et à la fin + sanitized = sanitized.replace(/(^_+)|(_+$)/g, '') + // Tronquer à 32 caractères maximum + if (sanitized.length > length) { + sanitized = sanitized.substring(0, length) + } + return sanitized +} +export function sanitizeMetricName(name: string): string { + return sanitizeName(name, 32) +} + +export function sanitizeTraceName(name: string): string { + return sanitizeName(name, 100) +} + +type useFirebasePerformanceProfilerProps = { + route?: Route + shouldProfile?: boolean +} + +export const useFirebasePerformanceProfiler = ( + traceName: string, + props?: useFirebasePerformanceProfilerProps +) => { + const { route = undefined, shouldProfile = true } = props || {} + const performanceService = { + startTrace: async (identifier: string) => perf().startTrace(identifier), + sanitizeMetricName: sanitizeMetricName, + sanitizeTraceName: sanitizeTraceName, + } + + const interactionService = { + runAfterInteractions: InteractionManager.runAfterInteractions, + } + + const { appStartTime } = useAppStartTimeStore() + + // Utilisation du hook de performance pour mesurer les délais + return usePerformanceProfiler(traceName, { + performanceService, + interactionService, + appStartTime, + route, + shouldProfile, + }) +} diff --git a/src/shared/performance/usePerformanceProfiler.test.tsx b/src/shared/performance/usePerformanceProfiler.test.tsx new file mode 100644 index 00000000000..f45ee18b090 --- /dev/null +++ b/src/shared/performance/usePerformanceProfiler.test.tsx @@ -0,0 +1,324 @@ +/* eslint-disable no-restricted-imports */ +import { act, renderHook } from '@testing-library/react-native' + +import { PerformanceService, InteractionService } from './types' +import { usePerformanceProfiler } from './usePerformanceProfiler' + +test('Ne fait rien si on ne veut pas profiler', async () => { + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn(), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + shouldProfile: false, + }) + ) + + await act(async () => {}) + + expect(mockStartTrace).not.toHaveBeenCalled() +}) + +test('Démarre correctement la trace', async () => { + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn(), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + + await act(async () => {}) + + expect(mockPerformanceService.sanitizeTraceName).toHaveBeenCalledWith('TestTrace') + expect(mockPerformanceService.sanitizeMetricName).toHaveBeenCalledWith('mountDuration(in ms)') + expect(mockStartTrace).toHaveBeenCalledWith('TestTrace') +}) + +test('Arrête correctement la trace', async () => { + const mockStopTrace = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn(), + stop: jest.fn(() => mockStopTrace()), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + + await act(async () => {}) + + expect(mockStopTrace).toHaveBeenCalledWith() +}) + +test('Mesure le temps de montage', async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t, 1)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + + await act(async () => {}) + + expect(mockPutMetric).toHaveBeenCalledWith('mountDuration(in ms)', 1) + expect(mockPutMetric).toHaveBeenCalledWith('renderCount', 1) + expect(mockPutMetric).toHaveBeenCalledWith('startupTime(in ms)', 1) + expect(mockPutMetric).not.toHaveBeenCalledWith('navigationTime(in ms)') +}) + +test('Mesure le temps de navigation', async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + const route = { params: { navigationStartTimeRef: Date.now() - 3 } } + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + route, + }) + ) + + await act(async () => {}) + + expect(mockPutMetric).toHaveBeenNthCalledWith(3, 'navigationTime(in ms)') + expect(mockPutMetric).not.toHaveBeenCalledWith('startupTime(in ms)') +}) + +test("Mesure le temps de démarrage de l'app", async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn((callback) => { + callback() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + + await act(async () => {}) + + expect(mockPutMetric).not.toHaveBeenCalledWith('navigationTime(in ms)') + expect(mockPutMetric).toHaveBeenCalledWith('startupTime(in ms)') +}) + +test('Mesure le démarrage en attendant la fin des interactions', async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const callbackAfterInteractions = jest.fn() + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn(() => { + callbackAfterInteractions() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + + await act(async () => {}) + + expect(callbackAfterInteractions).toHaveBeenCalledWith() +}) + +test('Mesure la navigation en attendant la fin des interactions', async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const callbackAfterInteractions = jest.fn() + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn(() => { + callbackAfterInteractions() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + const route = { params: { navigationStartTimeRef: Date.now() - 3 } } + renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + route, + }) + ) + + await act(async () => {}) + + expect(callbackAfterInteractions).toHaveBeenCalledWith() +}) + +test("Mesure le temps d'éxécution d'une fonction", async () => { + const mockPutMetric = jest.fn() + const mockStartTrace = jest.fn(() => + Promise.resolve({ + putMetric: jest.fn((t) => mockPutMetric(t)), + stop: jest.fn(), + }) + ) + const mockPerformanceService: PerformanceService = { + startTrace: mockStartTrace, + sanitizeMetricName: jest.fn((t) => t), + sanitizeTraceName: jest.fn((t) => t), + } + const callbackAfterInteractions = jest.fn() + const mockInteractionService: InteractionService = { + runAfterInteractions: jest.fn(() => { + callbackAfterInteractions() + return { cancel: jest.fn() } + }), + } + const appStartTime = Date.now() + const { result } = renderHook(() => + usePerformanceProfiler('TestTrace', { + performanceService: mockPerformanceService, + interactionService: mockInteractionService, + appStartTime, + }) + ) + await act(async () => {}) + + const toTest = jest.fn() + await result.current.measureExecutionTime({ fn: toTest, metricName: 'performance' }) + + await act(async () => {}) + + expect(toTest).toHaveBeenCalledWith() + expect(mockPutMetric).toHaveBeenCalledWith('performance') +}) diff --git a/src/shared/performance/usePerformanceProfiler.tsx b/src/shared/performance/usePerformanceProfiler.tsx new file mode 100644 index 00000000000..95c329f436a --- /dev/null +++ b/src/shared/performance/usePerformanceProfiler.tsx @@ -0,0 +1,191 @@ +import { useRef, useCallback, useState, useEffect } from 'react' + +import { + PerformanceService, + InteractionService, + Route, + Trace, + Metric, +} from 'shared/performance/types' + +interface UsePerformanceProfilerProps { + performanceService: PerformanceService + interactionService: InteractionService + appStartTime: number + route?: Route + shouldProfile?: boolean +} + +export const usePerformanceProfiler = ( + traceNameEx: string, + { + performanceService, + interactionService, + appStartTime, + route, + shouldProfile = true, + }: UsePerformanceProfilerProps +) => { + const mountStartTime = useRef(Date.now()) + const isFirstMount = useRef(true) + const trace = useRef(null) + const renderCount = useRef(0) + const metricsToBeAddedOnTraceBeingAvailable = useRef([]) + const [traceAvailable, setTraceAvailable] = useState(false) + const traceName = performanceService.sanitizeTraceName(traceNameEx) + + const startTracing = useCallback(async () => { + if (shouldProfile) { + try { + trace.current = await performanceService.startTrace(traceName) + setTraceAvailable(true) + } catch (error) { + console.error('Erreur lors du démarrage de la trace:', error) + } + } + }, [traceName, shouldProfile, performanceService]) + + const stopTracing = useCallback(() => { + if (shouldProfile && trace.current) { + try { + trace.current.stop() + } catch (error) { + console.error("Erreur lors de l'arrêt de la trace:", error) + } + } + }, [shouldProfile]) + + const addMetric = useCallback( + (metricName: string, value: number) => { + if (shouldProfile) { + const sanitizedMetricName = performanceService.sanitizeMetricName(metricName) + + if (trace.current) { + try { + trace.current.putMetric(sanitizedMetricName, value) + } catch (error) { + console.error("Erreur lors de l'ajout de la métrique:", error) + } + } else { + metricsToBeAddedOnTraceBeingAvailable.current.push({ + metricName: sanitizedMetricName, + value, + }) + } + } + }, + [performanceService, shouldProfile] + ) + + const addOrIncrementRenderCountMetric = useCallback(() => { + renderCount.current += 1 + addMetric('renderCount', renderCount.current) + }, [addMetric]) + + const addMetricsOnTraceAvailability = useCallback(() => { + if (shouldProfile && trace.current) { + metricsToBeAddedOnTraceBeingAvailable.current.forEach((item) => { + try { + trace.current?.putMetric(item.metricName, item.value) + } catch (error) { + console.error("Erreur lors de l'ajout de la métrique en attente:", error) + } + }) + metricsToBeAddedOnTraceBeingAvailable.current = [] + } + }, [shouldProfile]) + + // Mesure du temps de navigation + const measureNavigationTime = useCallback(() => { + if (shouldProfile && route?.params?.navigationStartTimeRef) { + const navigationStartTime = route.params.navigationStartTimeRef + const navigationTime = mountStartTime.current - navigationStartTime + addMetric('navigationTime(in ms)', navigationTime) + } + }, [route, addMetric, shouldProfile]) + + const measureTabLoadTime = useCallback(() => { + if (shouldProfile && !route) { + const task = interactionService.runAfterInteractions(() => { + const renderEndTime = Date.now() + const startupTime = renderEndTime - appStartTime + addMetric('startupTime(in ms)', startupTime) + }) + return () => task.cancel() + } + return undefined + }, [shouldProfile, route, interactionService, appStartTime, addMetric]) + + useEffect(() => { + if (isFirstMount.current) { + isFirstMount.current = false + startTracing() + } + + return () => { + stopTracing() + } + }, [startTracing, stopTracing]) + + useEffect(() => { + if (shouldProfile) { + if (mountStartTime.current > 0) { + const totalMountDuration = Date.now() - mountStartTime.current + addMetric('mountDuration(in ms)', totalMountDuration) + } + addOrIncrementRenderCountMetric() + measureNavigationTime() + const cleanupMeasure = measureTabLoadTime() + return () => { + cleanupMeasure?.() + } + } + return undefined + }, [ + shouldProfile, + addMetric, + addOrIncrementRenderCountMetric, + measureNavigationTime, + measureTabLoadTime, + ]) + + // Effet pour ajouter les métriques mises en mémoire tampon lorsque la trace est disponible + useEffect(() => { + if (traceAvailable) { + addMetricsOnTraceAvailability() + } + }, [traceAvailable, addMetricsOnTraceAvailability]) + + // Effet pour arrêter la trace après les interactions et la disponibilité de la trace + useEffect(() => { + if (traceAvailable) { + interactionService.runAfterInteractions(() => { + stopTracing() + }) + } + }, [traceAvailable, stopTracing, interactionService]) + + // Fonction pour mesurer le temps d'exécution d'une fonction asynchrone + const measureExecutionTime = useCallback( + async ({ fn, metricName }: { fn: () => Promise; metricName: string }) => { + const start = Date.now() + try { + const result = await fn() + const end = Date.now() + addMetric(metricName, end - start) + return result + } catch (error) { + console.error("Erreur lors de l'exécution de la fonction:", error) + throw error + } + }, + [addMetric] + ) + + return { + mountStartTime: mountStartTime.current, + addMetric, + stopTrace: stopTracing, + measureExecutionTime, + } +}