diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 305aa55..bd3597b 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"; @@ -94,6 +95,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), @@ -146,6 +151,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 e2f289d..5ba8458 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -96,3 +96,17 @@ export enum PerformanceMetric { FCP = 'fcp', TTFB = 'ttfb', } + +/** + * 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', +}