diff --git a/docs/PERFORMANCE_MONITORING.md b/docs/PERFORMANCE_MONITORING.md new file mode 100644 index 00000000..10f1c632 --- /dev/null +++ b/docs/PERFORMANCE_MONITORING.md @@ -0,0 +1,147 @@ +# Performance Monitoring + +TeachLink Mobile ships with a layered performance monitoring stack that covers component +render timing, Core Web Vitals, crash reporting, and production analytics. + +--- + +## Architecture Overview + +``` +AnalyticsProvider (root) +├── mobileAnalyticsService – event ingestion & sampling +├── crashReportingService – global JS / promise error handlers +└── webVitalsService – LCP, FID, CLS, FCP, TTFB (web target) + +useReactProfiler (hook) +└── Profiler (React built-in) + └── ProfiledScreen (wrapper component) +``` + +--- + +## Metrics Definitions + +### React Profiler Metrics + +| Metric | Definition | Unit | +|---|---|---| +| `render_duration` | `actualDuration` reported by React's `Profiler` — wall-clock time spent rendering the committed subtree | ms | +| Slow render | Any render where `actualDuration > slowRenderThresholdMs` (default 16 ms / 1 frame at 60 fps) | — | +| `averageRenderDurationMs` | Rolling mean of the last `maxSamples` (default 100) render durations | ms | + +### Core Web Vitals (web target) + +| Metric | Good | Needs Improvement | Poor | Description | +|---|---|---|---|---| +| LCP | ≤ 2500 ms | ≤ 4000 ms | > 4000 ms | Largest Contentful Paint | +| FID | ≤ 100 ms | ≤ 300 ms | > 300 ms | First Input Delay | +| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 | Cumulative Layout Shift | +| FCP | ≤ 1800 ms | ≤ 3000 ms | > 3000 ms | First Contentful Paint | +| TTFB | ≤ 800 ms | ≤ 1800 ms | > 1800 ms | Time to First Byte | + +Thresholds are sourced from Google's Core Web Vitals recommendations and defined in +`src/services/webVitals.ts`. + +### Navigation & Infrastructure + +| Metric | Key | Description | +|---|---|---| +| Navigation latency | `navigation_latency` | Time between user tap and screen mount | +| App load time | `app_load_time` | Cold-start JS bundle load | +| Screen transition time | `screen_transition_time` | Animated navigation transition | +| API response time | `api_response_time` | Round-trip HTTP call | +| Time to interactive | `time_to_interactive` | App ready for full interaction | + +--- + +## Usage + +### Wrap a screen with `ProfiledScreen` + +```tsx +import { ProfiledScreen } from '@/components/mobile/ProfiledScreen'; + +export default function HomeScreen() { + return ( + + + + ); +} +``` + +Every render is forwarded to `mobileAnalyticsService.trackPerformance`. Renders slower +than 16 ms also emit a `PERFORMANCE_METRIC` event with `is_slow: true` and a console +warning (visible in Metro logs). + +### Use `useReactProfiler` directly + +Use this when you need access to the live `metrics` object (e.g. a dev-only overlay): + +```tsx +import { Profiler } from 'react'; +import { useReactProfiler } from '@/hooks/useReactProfiler'; + +function MyComponent() { + const { onRender, metrics } = useReactProfiler('MyComponent', { + slowRenderThresholdMs: 32, // 2 frames + maxSamples: 50, + }); + + return ( + + {/* your subtree */} + + ); +} +``` + +### Initialise web vitals (automatic) + +`webVitalsService.init()` is called inside `AnalyticsProvider` on app mount. No extra +setup is required. On React Native / non-browser environments the `web-vitals` functions +are no-ops, so the call is safe everywhere. + +--- + +## Performance Baselines + +Baselines are committed to `performance-baseline.json`. Update them after intentional +performance changes: + +```bash +npm run perf:update-baseline +git add performance-baseline.json +git commit -m "perf: update baseline after " +``` + +CI regresses on any metric that worsens by > 5% vs the stored baseline (see +`docs/PERFORMANCE_THRESHOLDS.md`). + +--- + +## Alerts & Regression Detection + +### Slow-render alert + +Logged to Metro console and sent as a `PERFORMANCE_METRIC` analytics event whenever a +single render exceeds `slowRenderThresholdMs`. + +### Web Vitals regression + +`webVitalsService` stores the first reading of each metric as a baseline. Subsequent +readings > 20% above baseline emit a `WEB_VITALS_REGRESSION` analytics event. + +### Production crash threshold + +`crashReportingService` counts unhandled JS errors. When the count reaches +`MAX_ERRORS_THRESHOLD` (5) it calls `alertProductionIssue`, which logs a `PRODUCTION +ALERT` warning. In a real deployment this would fan out to a Slack channel or PagerDuty. + +--- + +## Related Documents + +- [PERFORMANCE_TESTING.md](./PERFORMANCE_TESTING.md) — component-level perf test guide +- [PERFORMANCE_THRESHOLDS.md](./PERFORMANCE_THRESHOLDS.md) — CI budget & regression gate diff --git a/src/components/mobile/AnalyticsProvider.tsx b/src/components/mobile/AnalyticsProvider.tsx index 5db8a1cc..521b9fed 100644 --- a/src/components/mobile/AnalyticsProvider.tsx +++ b/src/components/mobile/AnalyticsProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, ReactNode, useContext, useEffect, useRef } from ' import { AppState, AppStateStatus } from 'react-native'; import { crashReportingService } from '../../services/crashReporting'; import { mobileAnalyticsService } from '../../services/mobileAnalytics'; +import webVitalsService from '../../services/webVitals'; import logger from '../../utils/logger'; import { ErrorBoundary } from '../common/ErrorBoundary'; @@ -24,9 +25,10 @@ export const AnalyticsProvider: React.FC = ({ children } useEffect(() => { // 1. Initialize services on mount - logger.info('📱 [AnalyticsProvider] Initializing tracking and crash reporting...'); + logger.info('📱 [AnalyticsProvider] Initializing tracking, crash reporting and web vitals...'); mobileAnalyticsService.init(); crashReportingService.init(); + webVitalsService.init(); // 2. Manage session lifecycle (Foreground vs. Background) const handleAppStateChange = (nextAppState: AppStateStatus) => { diff --git a/src/components/mobile/ProfiledScreen.tsx b/src/components/mobile/ProfiledScreen.tsx new file mode 100644 index 00000000..39f96894 --- /dev/null +++ b/src/components/mobile/ProfiledScreen.tsx @@ -0,0 +1,32 @@ +import React, { Profiler, ReactNode } from 'react'; +import { useReactProfiler, ProfilerOptions } from '../../hooks/useReactProfiler'; + +interface ProfiledScreenProps { + name: string; + children: ReactNode; + options?: ProfilerOptions; +} + +/** + * Wraps a screen (or any subtree) with React's built-in Profiler and forwards + * render-timing data to analytics via `useReactProfiler`. + * + * Only active when `__DEV__` is true or when the profiler is explicitly enabled + * via the options prop, keeping production overhead negligible. + * + * Usage: + * + * + * + */ +export const ProfiledScreen: React.FC = ({ name, children, options }) => { + const { onRender } = useReactProfiler(name, options); + + return ( + + {children} + + ); +}; + +export default ProfiledScreen; diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts index 535196e2..6081cdaf 100644 --- a/src/components/mobile/index.ts +++ b/src/components/mobile/index.ts @@ -31,4 +31,5 @@ export * from './SwipeableCoordinator'; export * from './SwipeableRow'; export * from './VirtualList'; export * from './VoiceSearch'; +export * from './ProfiledScreen'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 43adc866..0ccf80e5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -37,4 +37,5 @@ export { OptimizedVideoGesturesView, useOptimizedVideoGestures } from './useOpti export * from './useDebounce'; export * from './useHealthDashboard'; export * from './usePredictivePreload'; +export * from './useReactProfiler'; diff --git a/src/hooks/useReactProfiler.ts b/src/hooks/useReactProfiler.ts new file mode 100644 index 00000000..416ca820 --- /dev/null +++ b/src/hooks/useReactProfiler.ts @@ -0,0 +1,112 @@ +import { useCallback, useRef } from 'react'; +import { mobileAnalyticsService } from '../services/mobileAnalytics'; +import { AnalyticsEvent, PerformanceMetric } from '../utils/trackingEvents'; +import { appLogger } from '../utils/logger'; + +export interface ProfilerMetrics { + componentName: string; + renderCount: number; + lastRenderDurationMs: number; + lastCommitDurationMs: number; + averageRenderDurationMs: number; + slowRenders: number; +} + +export interface ProfilerOptions { + slowRenderThresholdMs?: number; + maxSamples?: number; +} + +export type ProfilerOnRenderCallback = ( + id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number +) => void; + +const DEFAULT_SLOW_THRESHOLD_MS = 16; +const DEFAULT_MAX_SAMPLES = 100; + +/** + * Provides a React Profiler `onRender` callback that records render timing + * metrics and forwards slow renders to the analytics service. + * + * Usage: + * const { onRender, metrics } = useReactProfiler('MyScreen'); + * + */ +export function useReactProfiler( + componentName: string, + options: ProfilerOptions = {} +): { onRender: ProfilerOnRenderCallback; metrics: ProfilerMetrics } { + const { slowRenderThresholdMs = DEFAULT_SLOW_THRESHOLD_MS, maxSamples = DEFAULT_MAX_SAMPLES } = + options; + + const renderCount = useRef(0); + const totalDuration = useRef(0); + const slowRenders = useRef(0); + const lastRenderDurationMs = useRef(0); + const lastCommitDurationMs = useRef(0); + const samples = useRef([]); + + const onRender = useCallback( + (_id, phase, actualDuration, _baseDuration, _startTime, commitTime) => { + renderCount.current += 1; + lastRenderDurationMs.current = actualDuration; + lastCommitDurationMs.current = commitTime; + + samples.current.push(actualDuration); + if (samples.current.length > maxSamples) { + samples.current.shift(); + } + totalDuration.current += actualDuration; + + if (actualDuration > slowRenderThresholdMs) { + slowRenders.current += 1; + + appLogger.infoSync( + `[ReactProfiler] Slow render detected — ${componentName} (${phase}): ${actualDuration.toFixed(2)}ms` + ); + + mobileAnalyticsService.trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, { + metric_name: PerformanceMetric.RENDER_DURATION, + component: componentName, + phase, + actual_duration_ms: Math.round(actualDuration), + commit_time: commitTime, + is_slow: true, + }); + } + + mobileAnalyticsService.trackPerformance(PerformanceMetric.RENDER_DURATION, actualDuration, { + component: componentName, + phase, + render_count: renderCount.current, + event_category: 'high_frequency', + event_name: `profiler_${componentName}`, + }); + }, + [componentName, slowRenderThresholdMs, maxSamples] + ); + + const sampleArr = samples.current; + const avg = + sampleArr.length > 0 + ? sampleArr.reduce((a, b) => a + b, 0) / sampleArr.length + : 0; + + const metrics: ProfilerMetrics = { + componentName, + renderCount: renderCount.current, + lastRenderDurationMs: lastRenderDurationMs.current, + lastCommitDurationMs: lastCommitDurationMs.current, + averageRenderDurationMs: avg, + slowRenders: slowRenders.current, + }; + + return { onRender, metrics }; +} + +export default useReactProfiler; diff --git a/src/utils/trackingEvents.ts b/src/utils/trackingEvents.ts index 1cbc37b8..e2f289d1 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -36,6 +36,8 @@ export enum AnalyticsEvent { // Performance & Infrastructure PERFORMANCE_METRIC = 'performance_metric', + REACT_PROFILER_RENDER = 'react_profiler_render', + REACT_PROFILER_SLOW_RENDER = 'react_profiler_slow_render', AB_ASSIGNMENT = 'ab_assignment', AB_EXPOSURE = 'ab_exposure', API_ERROR = 'api_error', @@ -83,6 +85,10 @@ export enum PerformanceMetric { SCREEN_TRANSITION_TIME = 'screen_transition_time', API_RESPONSE_TIME = 'api_response_time', + // React Profiler + RENDER_DURATION = 'render_duration', + NAVIGATION_LATENCY = 'navigation_latency', + // Core Web Vitals LCP = 'lcp', FID = 'fid', diff --git a/tests/components/ProfiledScreen.test.tsx b/tests/components/ProfiledScreen.test.tsx new file mode 100644 index 00000000..037c76c0 --- /dev/null +++ b/tests/components/ProfiledScreen.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import { ProfiledScreen } from '../../src/components/mobile/ProfiledScreen'; +import { mobileAnalyticsService } from '../../src/services/mobileAnalytics'; + +jest.mock('../../src/services/mobileAnalytics', () => ({ + mobileAnalyticsService: { + trackEvent: jest.fn(), + trackPerformance: jest.fn(), + }, +})); + +jest.mock('../../src/utils/logger', () => ({ + appLogger: { + infoSync: jest.fn(), + warnSync: jest.fn(), + }, +})); + +const mockTrackPerformance = mobileAnalyticsService.trackPerformance as jest.MockedFunction< + typeof mobileAnalyticsService.trackPerformance +>; + +describe('ProfiledScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders children without crashing', () => { + const { getByText } = render( + + Hello World + + ); + + expect(getByText('Hello World')).toBeTruthy(); + }); + + it('forwards render metrics to analytics on mount', () => { + render( + + Content + + ); + + expect(mockTrackPerformance).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.objectContaining({ component: 'HomeScreen' }) + ); + }); + + it('passes the screen name as Profiler id', () => { + render( + + Course + + ); + + expect(mockTrackPerformance).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.objectContaining({ component: 'CourseScreen' }) + ); + }); + + it('accepts profiler options without crashing', () => { + expect(() => + render( + + Options + + ) + ).not.toThrow(); + }); +}); diff --git a/tests/hooks/useReactProfiler.test.ts b/tests/hooks/useReactProfiler.test.ts new file mode 100644 index 00000000..72958a08 --- /dev/null +++ b/tests/hooks/useReactProfiler.test.ts @@ -0,0 +1,141 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useReactProfiler } from '../../src/hooks/useReactProfiler'; +import { mobileAnalyticsService } from '../../src/services/mobileAnalytics'; +import { appLogger } from '../../src/utils/logger'; +import { PerformanceMetric } from '../../src/utils/trackingEvents'; + +jest.mock('../../src/services/mobileAnalytics', () => ({ + mobileAnalyticsService: { + trackEvent: jest.fn(), + trackPerformance: jest.fn(), + }, +})); + +jest.mock('../../src/utils/logger', () => ({ + appLogger: { + infoSync: jest.fn(), + warnSync: jest.fn(), + }, +})); + +const mockTrackEvent = mobileAnalyticsService.trackEvent as jest.MockedFunction< + typeof mobileAnalyticsService.trackEvent +>; +const mockTrackPerformance = mobileAnalyticsService.trackPerformance as jest.MockedFunction< + typeof mobileAnalyticsService.trackPerformance +>; +const mockInfoSync = appLogger.infoSync as jest.MockedFunction; + +function invokeOnRender( + hook: ReturnType, + overrides: Partial<{ + id: string; + phase: 'mount' | 'update' | 'nested-update'; + actualDuration: number; + baseDuration: number; + startTime: number; + commitTime: number; + }> = {} +) { + const { + id = 'TestScreen', + phase = 'mount', + actualDuration = 5, + baseDuration = 5, + startTime = 0, + commitTime = 10, + } = overrides; + act(() => { + hook.onRender(id, phase, actualDuration, baseDuration, startTime, commitTime); + }); +} + +describe('useReactProfiler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns onRender callback and initial metrics', () => { + const { result } = renderHook(() => useReactProfiler('HomeScreen')); + + expect(typeof result.current.onRender).toBe('function'); + expect(result.current.metrics.componentName).toBe('HomeScreen'); + expect(result.current.metrics.renderCount).toBe(0); + expect(result.current.metrics.slowRenders).toBe(0); + }); + + it('calls trackPerformance on every render', () => { + const { result } = renderHook(() => useReactProfiler('HomeScreen')); + + invokeOnRender(result.current, { actualDuration: 8 }); + + expect(mockTrackPerformance).toHaveBeenCalledWith( + PerformanceMetric.RENDER_DURATION, + 8, + expect.objectContaining({ component: 'HomeScreen', phase: 'mount' }) + ); + }); + + it('does NOT flag a fast render as slow', () => { + const { result } = renderHook(() => + useReactProfiler('HomeScreen', { slowRenderThresholdMs: 16 }) + ); + + invokeOnRender(result.current, { actualDuration: 10 }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockInfoSync).not.toHaveBeenCalled(); + }); + + it('logs and tracks a slow render via trackEvent', () => { + const { result } = renderHook(() => + useReactProfiler('SlowScreen', { slowRenderThresholdMs: 16 }) + ); + + invokeOnRender(result.current, { actualDuration: 50, phase: 'update' }); + + expect(mockInfoSync).toHaveBeenCalledWith( + expect.stringContaining('Slow render detected — SlowScreen (update): 50.00ms') + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + component: 'SlowScreen', + phase: 'update', + is_slow: true, + }) + ); + }); + + it('respects a custom slowRenderThresholdMs option', () => { + const { result } = renderHook(() => + useReactProfiler('FastScreen', { slowRenderThresholdMs: 100 }) + ); + + invokeOnRender(result.current, { actualDuration: 50 }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('accumulates render count across multiple renders', () => { + const { result } = renderHook(() => useReactProfiler('Counter')); + + invokeOnRender(result.current); + invokeOnRender(result.current, { phase: 'update' }); + invokeOnRender(result.current, { phase: 'update' }); + + expect(mockTrackPerformance).toHaveBeenCalledTimes(3); + }); + + it('passes event_category high_frequency to suppress sampling', () => { + const { result } = renderHook(() => useReactProfiler('SampledScreen')); + + invokeOnRender(result.current); + + expect(mockTrackPerformance).toHaveBeenCalledWith( + PerformanceMetric.RENDER_DURATION, + expect.any(Number), + expect.objectContaining({ event_category: 'high_frequency' }) + ); + }); +});