From c34e73413e1cf438aad68f7ee8a11f049714641c Mon Sep 17 00:00:00 2001 From: DeePrincipal-dev-lang Date: Fri, 29 May 2026 19:16:57 +0000 Subject: [PATCH 1/2] feat: add resource timing metrics for API calls and image loads - Add performanceTiming.ts: rolling window tracker with p50/p95/avg/min/max/errorRate - Add API_TIMING, IMAGE_TIMING, RESOURCE_TIMING_SUMMARY events and ResourceTimingMetric enum - Instrument axios interceptors to auto-time every API request Closes #31 #32 #33 #34 --- src/services/api/axios.config.ts | 25 ++++- src/utils/performanceTiming.ts | 172 +++++++++++++++++++++++++++++++ src/utils/trackingEvents.ts | 19 ++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/utils/performanceTiming.ts diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index c4d29eb..c80c358 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -29,6 +29,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import { getEnv } from "../../config"; import { appLogger } from "../../utils/logger"; +import { startTiming, notifyEntry } from "../../utils/performanceTiming"; import { getAccessToken, getRefreshToken, saveTokens } from "../secureStorage"; import { requestQueue } from "./requestQueue"; @@ -79,7 +80,7 @@ function processRefreshQueue(token: string | null, error: unknown) { // ─── Request interceptor ─────────────────────────────────────────────────── apiClient.interceptors.request.use( - async (config: InternalAxiosRequestConfig) => { + async (config: InternalAxiosRequestConfig & { _timingFinish?: (success: boolean, status?: number) => ReturnType }) => { // Skip adding token for refresh requests if (config.url?.includes("/auth/refresh")) { return config; @@ -91,6 +92,10 @@ apiClient.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; } + // Attach timing finish function to config for use in response interceptor + (config as InternalAxiosRequestConfig & { _timingFinish?: ReturnType })._timingFinish = + startTiming('api', config.url ?? 'unknown', config.method?.toUpperCase()); + return config; }, (error) => Promise.reject(error), @@ -99,11 +104,20 @@ apiClient.interceptors.request.use( // ─── Response interceptor ─────────────────────────────────────────────────── apiClient.interceptors.response.use( - (response) => response, + (response) => { + // Record successful timing + const cfg = response.config as InternalAxiosRequestConfig & { _timingFinish?: ReturnType }; + if (cfg._timingFinish) { + const entry = cfg._timingFinish(true, response.status); + notifyEntry(entry); + } + return response; + }, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; _retryCount?: number; + _timingFinish?: ReturnType; }; // ── Log non-network errors ──────────────────────────────────────────── @@ -118,6 +132,13 @@ apiClient.interceptors.response.use( }); } + // Record failed timing (only once, on first error — not on retries) + if (originalRequest._timingFinish && !originalRequest._retryCount) { + const entry = originalRequest._timingFinish(false, error.response?.status); + notifyEntry(entry); + originalRequest._timingFinish = undefined; + } + // ── Queue network errors for retry ─────────────────────────────────── if (error.code === "ERR_NETWORK" || error.message === "Network Error") { if (originalRequest) { diff --git a/src/utils/performanceTiming.ts b/src/utils/performanceTiming.ts new file mode 100644 index 0000000..671d897 --- /dev/null +++ b/src/utils/performanceTiming.ts @@ -0,0 +1,172 @@ +/** + * Resource Timing Tracker + * + * Captures API call durations and image loading times, maintains a rolling + * window of recent entries, and exposes aggregated metrics (p50/p95/avg). + */ + +export type ResourceType = 'api' | 'image'; + +export interface TimingEntry { + id: string; + type: ResourceType; + /** URL or endpoint path */ + resource: string; + /** HTTP method for API calls */ + method?: string; + /** HTTP status code for API calls */ + status?: number; + startTime: number; + duration: number; + /** Whether the call succeeded */ + success: boolean; + timestamp: number; +} + +export interface AggregatedMetrics { + count: number; + avg: number; + p50: number; + p95: number; + min: number; + max: number; + errorRate: number; +} + +export interface PerformanceSummary { + api: AggregatedMetrics; + image: AggregatedMetrics; + all: AggregatedMetrics; +} + +const MAX_ENTRIES = 200; + +let entries: TimingEntry[] = []; +let idCounter = 0; + +function nextId(): string { + return `pt_${Date.now()}_${++idCounter}`; +} + +/** Start timing a resource. Returns a function to call when done. */ +export function startTiming( + type: ResourceType, + resource: string, + method?: string, +): (success: boolean, status?: number) => TimingEntry { + const id = nextId(); + const startTime = Date.now(); + + return (success: boolean, status?: number): TimingEntry => { + const duration = Date.now() - startTime; + const entry: TimingEntry = { + id, + type, + resource, + method, + status, + startTime, + duration, + success, + timestamp: Date.now(), + }; + + entries.push(entry); + // Keep rolling window + if (entries.length > MAX_ENTRIES) { + entries = entries.slice(entries.length - MAX_ENTRIES); + } + + return entry; + }; +} + +function computeMetrics(subset: TimingEntry[]): AggregatedMetrics { + if (subset.length === 0) { + return { count: 0, avg: 0, p50: 0, p95: 0, min: 0, max: 0, errorRate: 0 }; + } + + const durations = subset.map((e) => e.duration).sort((a, b) => a - b); + const sum = durations.reduce((acc, d) => acc + d, 0); + const errors = subset.filter((e) => !e.success).length; + + const percentile = (p: number) => { + const idx = Math.ceil((p / 100) * durations.length) - 1; + return durations[Math.max(0, idx)]; + }; + + return { + count: subset.length, + avg: Math.round(sum / subset.length), + p50: percentile(50), + p95: percentile(95), + min: durations[0], + max: durations[durations.length - 1], + errorRate: errors / subset.length, + }; +} + +/** Get aggregated metrics for all recorded entries. */ +export function getMetrics(): PerformanceSummary { + const apiEntries = entries.filter((e) => e.type === 'api'); + const imageEntries = entries.filter((e) => e.type === 'image'); + + return { + api: computeMetrics(apiEntries), + image: computeMetrics(imageEntries), + all: computeMetrics(entries), + }; +} + +/** Get the raw timing entries (most recent first). */ +export function getEntries(type?: ResourceType): TimingEntry[] { + const source = type ? entries.filter((e) => e.type === type) : entries; + return [...source].reverse(); +} + +/** Clear all recorded entries (useful for testing). */ +export function clearEntries(): void { + entries = []; +} + +/** Subscribe to new timing entries. Returns an unsubscribe function. */ +type Listener = (entry: TimingEntry) => void; +const listeners: Set = new Set(); + +export function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Internal: notify listeners (called by instrumented code). */ +export function notifyEntry(entry: TimingEntry): void { + listeners.forEach((l) => { + try { + l(entry); + } catch { + // ignore listener errors + } + }); +} + +/** + * Convenience wrapper: time an async operation and record the result. + */ +export async function timeAsync( + type: ResourceType, + resource: string, + fn: () => Promise, + method?: string, +): Promise { + const finish = startTiming(type, resource, method); + try { + const result = await fn(); + const entry = finish(true); + notifyEntry(entry); + return result; + } catch (err) { + const entry = finish(false); + notifyEntry(entry); + throw err; + } +} diff --git a/src/utils/trackingEvents.ts b/src/utils/trackingEvents.ts index 1d7ddac..5b81ce8 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -38,6 +38,11 @@ export enum AnalyticsEvent { PERFORMANCE_METRIC = 'performance_metric', API_ERROR = 'api_error', CRASH_REPORT = 'crash_report', + + // Resource Timing (Issue #34) + API_TIMING = 'api_timing', + IMAGE_TIMING = 'image_timing', + RESOURCE_TIMING_SUMMARY = 'resource_timing_summary', } /** @@ -73,3 +78,17 @@ export enum PerformanceMetric { SCREEN_TRANSITION_TIME = 'screen_transition_time', API_RESPONSE_TIME = 'api_response_time', } + +/** + * Resource timing metric names for structured reporting. + */ +export enum ResourceTimingMetric { + API_DURATION = 'api_duration', + IMAGE_LOAD_DURATION = 'image_load_duration', + API_P50 = 'api_p50', + API_P95 = 'api_p95', + IMAGE_P50 = 'image_p50', + IMAGE_P95 = 'image_p95', + API_ERROR_RATE = 'api_error_rate', + IMAGE_ERROR_RATE = 'image_error_rate', +} From 9a4e51612c12efa07259b380c35a8aff11c625d7 Mon Sep 17 00:00:00 2001 From: DeePrincipal-dev-lang Date: Fri, 29 May 2026 19:20:32 +0000 Subject: [PATCH 2/2] feat: timeout countdown with progress bar and retry button - useRequestTimeout: tick-based countdown hook (100ms interval), exposes progress (0-1), remaining ms, isTimedOut, start, reset - RequestTimeoutOverlay: progress bar + remaining seconds + Retry button, auto-starts when loading=true, shows retry on timeout - Exported from hooks/index.ts and components/mobile/index.ts Closes #11 #20 --- .../mobile/RequestTimeoutOverlay.tsx | 130 ++++++++++++++++++ src/components/mobile/index.ts | 1 + src/hooks/index.ts | 1 + src/hooks/useRequestTimeout.ts | 76 ++++++++++ 4 files changed, 208 insertions(+) create mode 100644 src/components/mobile/RequestTimeoutOverlay.tsx create mode 100644 src/hooks/useRequestTimeout.ts diff --git a/src/components/mobile/RequestTimeoutOverlay.tsx b/src/components/mobile/RequestTimeoutOverlay.tsx new file mode 100644 index 0000000..a6fadd4 --- /dev/null +++ b/src/components/mobile/RequestTimeoutOverlay.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Animated, +} from 'react-native'; +import { useRequestTimeout, REQUEST_TIMEOUT_MS } from '../../hooks/useRequestTimeout'; + +export interface RequestTimeoutOverlayProps { + /** Whether a request is currently in-flight */ + loading: boolean; + /** Called when the user taps Retry */ + onRetry: () => void; + /** Timeout duration in ms (default: REQUEST_TIMEOUT_MS) */ + timeoutMs?: number; + /** Label shown above the progress bar */ + message?: string; +} + +/** + * Shows a countdown progress bar while a request is in-flight. + * When the timeout is reached, displays a Retry button. + */ +export function RequestTimeoutOverlay({ + loading, + onRetry, + timeoutMs = REQUEST_TIMEOUT_MS, + message = 'Waiting for response…', +}: RequestTimeoutOverlayProps) { + const { progress, remaining, isTimedOut, start, reset } = + useRequestTimeout(timeoutMs); + + // Start countdown when loading begins, reset when it ends + React.useEffect(() => { + if (loading) { + start(); + } else { + reset(); + } + }, [loading]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!loading && !isTimedOut) return null; + + const secondsLeft = Math.ceil(remaining / 1000); + + const handleRetry = () => { + reset(); + onRetry(); + }; + + return ( + + {/* Message + countdown */} + + {isTimedOut ? 'Request timed out' : message} + + {!isTimedOut && ( + + {secondsLeft}s + + )} + + {/* Progress bar */} + + + + + {/* Retry button — shown once timed out */} + {isTimedOut && ( + + Retry + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#1a1a2e', + borderRadius: 8, + marginHorizontal: 16, + marginVertical: 8, + alignItems: 'center', + gap: 8, + }, + message: { + color: '#e0e0e0', + fontSize: 14, + textAlign: 'center', + }, + countdown: { + color: '#f0a500', + fontSize: 20, + fontWeight: '700', + }, + trackContainer: { + width: '100%', + height: 6, + backgroundColor: '#333', + borderRadius: 3, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: '#f0a500', + borderRadius: 3, + }, + retryButton: { + marginTop: 4, + paddingHorizontal: 24, + paddingVertical: 10, + backgroundColor: '#f0a500', + borderRadius: 6, + }, + retryText: { + color: '#1a1a2e', + fontWeight: '700', + fontSize: 14, + }, +}); diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts index 2dd09c6..18673e2 100644 --- a/src/components/mobile/index.ts +++ b/src/components/mobile/index.ts @@ -22,3 +22,4 @@ export * from './SettingsSkeleton'; export * from './StatisticsDisplay'; export * from './SubscriptionSkeleton'; export * from './VoiceSearch'; +export * from './RequestTimeoutOverlay'; \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a5c522a..48a729c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -23,3 +23,4 @@ export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; export * from './useDebounce'; +export * from './useRequestTimeout'; \ No newline at end of file diff --git a/src/hooks/useRequestTimeout.ts b/src/hooks/useRequestTimeout.ts new file mode 100644 index 0000000..dd791a3 --- /dev/null +++ b/src/hooks/useRequestTimeout.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +/** Axios default timeout in ms — keep in sync with axios.config.ts */ +export const REQUEST_TIMEOUT_MS = 10_000; + +const TICK_MS = 100; + +export interface UseRequestTimeoutReturn { + /** 0–1 progress through the timeout window */ + progress: number; + /** Remaining milliseconds */ + remaining: number; + /** True once the countdown reaches zero */ + isTimedOut: boolean; + /** Start (or restart) the countdown */ + start: () => void; + /** Stop and reset the countdown */ + reset: () => void; +} + +/** + * Tracks a request timeout countdown. + * + * @param timeoutMs Total timeout duration (default: REQUEST_TIMEOUT_MS) + * @param onTimeout Optional callback fired when the countdown reaches zero + */ +export function useRequestTimeout( + timeoutMs: number = REQUEST_TIMEOUT_MS, + onTimeout?: () => void, +): UseRequestTimeoutReturn { + const [elapsed, setElapsed] = useState(0); + const [active, setActive] = useState(false); + const intervalRef = useRef | null>(null); + const onTimeoutRef = useRef(onTimeout); + onTimeoutRef.current = onTimeout; + + const clear = () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + const start = useCallback(() => { + clear(); + setElapsed(0); + setActive(true); + intervalRef.current = setInterval(() => { + setElapsed((prev) => prev + TICK_MS); + }, TICK_MS); + }, []); + + const reset = useCallback(() => { + clear(); + setElapsed(0); + setActive(false); + }, []); + + // Stop ticking and fire callback when timeout is reached + useEffect(() => { + if (active && elapsed >= timeoutMs) { + clear(); + setActive(false); + onTimeoutRef.current?.(); + } + }, [active, elapsed, timeoutMs]); + + // Cleanup on unmount + useEffect(() => () => clear(), []); + + const remaining = Math.max(0, timeoutMs - elapsed); + const progress = Math.min(1, elapsed / timeoutMs); + const isTimedOut = elapsed >= timeoutMs; + + return { progress, remaining, isTimedOut, start, reset }; +}