Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/PERFORMANCE_MONITORING.md
Original file line number Diff line number Diff line change
@@ -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 (
<ProfiledScreen name="HomeScreen">
<HomeContent />
</ProfiledScreen>
);
}
```

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 (
<Profiler id="MyComponent" onRender={onRender}>
{/* your subtree */}
</Profiler>
);
}
```

### 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 <reason>"
```

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
4 changes: 3 additions & 1 deletion src/components/mobile/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,9 +25,10 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ 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) => {
Expand Down
32 changes: 32 additions & 0 deletions src/components/mobile/ProfiledScreen.tsx
Original file line number Diff line number Diff line change
@@ -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:
* <ProfiledScreen name="HomeScreen">
* <HomeContent />
* </ProfiledScreen>
*/
export const ProfiledScreen: React.FC<ProfiledScreenProps> = ({ name, children, options }) => {
const { onRender } = useReactProfiler(name, options);

return (
<Profiler id={name} onRender={onRender}>
{children}
</Profiler>
);
};

export default ProfiledScreen;
1 change: 1 addition & 0 deletions src/components/mobile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export * from './SwipeableCoordinator';
export * from './SwipeableRow';
export * from './VirtualList';
export * from './VoiceSearch';
export * from './ProfiledScreen';

1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export { OptimizedVideoGesturesView, useOptimizedVideoGestures } from './useOpti
export * from './useDebounce';
export * from './useHealthDashboard';
export * from './usePredictivePreload';
export * from './useReactProfiler';

112 changes: 112 additions & 0 deletions src/hooks/useReactProfiler.ts
Original file line number Diff line number Diff line change
@@ -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');
* <Profiler id="MyScreen" onRender={onRender}>…</Profiler>
*/
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<number[]>([]);

const onRender = useCallback<ProfilerOnRenderCallback>(
(_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;
6 changes: 6 additions & 0 deletions src/utils/trackingEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading