mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 11:11:13 -05:00
Refactor: Implement documentation plan
This commit is contained in:
387
src/lib/cacheMonitoring.ts
Normal file
387
src/lib/cacheMonitoring.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Cache Performance Monitoring Utilities
|
||||
*
|
||||
* Provides tools to monitor React Query cache performance in production.
|
||||
* Use sparingly - only enable when debugging performance issues.
|
||||
*
|
||||
* Features:
|
||||
* - Cache hit/miss tracking
|
||||
* - Query duration monitoring
|
||||
* - Slow query detection
|
||||
* - Invalidation frequency tracking
|
||||
*
|
||||
* @example
|
||||
* import { cacheMonitor } from '@/lib/cacheMonitoring';
|
||||
*
|
||||
* // Start monitoring (development only)
|
||||
* if (process.env.NODE_ENV === 'development') {
|
||||
* cacheMonitor.start();
|
||||
* }
|
||||
*
|
||||
* // Get metrics
|
||||
* const metrics = cacheMonitor.getMetrics();
|
||||
* console.log('Cache hit rate:', metrics.hitRate);
|
||||
*/
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface CacheMetrics {
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
totalQueries: number;
|
||||
avgQueryTime: number;
|
||||
slowQueries: number;
|
||||
invalidations: number;
|
||||
lastReset: Date;
|
||||
}
|
||||
|
||||
interface QueryTiming {
|
||||
queryKey: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
status: 'pending' | 'success' | 'error';
|
||||
}
|
||||
|
||||
class CacheMonitor {
|
||||
private metrics: CacheMetrics;
|
||||
private queryTimings: Map<string, QueryTiming>;
|
||||
private slowQueryThreshold: number = 500; // ms
|
||||
private enabled: boolean = false;
|
||||
private listeners: {
|
||||
onSlowQuery?: (queryKey: string, duration: number) => void;
|
||||
onCacheMiss?: (queryKey: string) => void;
|
||||
onInvalidation?: (queryKey: string) => void;
|
||||
} = {};
|
||||
|
||||
constructor() {
|
||||
this.metrics = this.resetMetrics();
|
||||
this.queryTimings = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring cache performance
|
||||
* Should only be used in development or for debugging
|
||||
*/
|
||||
start(queryClient?: QueryClient) {
|
||||
if (this.enabled) {
|
||||
logger.warn('Cache monitor already started');
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = true;
|
||||
this.metrics = this.resetMetrics();
|
||||
|
||||
logger.info('Cache monitor started', {
|
||||
slowQueryThreshold: this.slowQueryThreshold
|
||||
});
|
||||
|
||||
// If queryClient provided, set up automatic tracking
|
||||
if (queryClient) {
|
||||
this.setupQueryClientTracking(queryClient);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring
|
||||
*/
|
||||
stop() {
|
||||
this.enabled = false;
|
||||
this.queryTimings.clear();
|
||||
logger.info('Cache monitor stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
reset() {
|
||||
this.metrics = this.resetMetrics();
|
||||
this.queryTimings.clear();
|
||||
logger.info('Cache metrics reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cache hit (data served from cache)
|
||||
*/
|
||||
recordHit(queryKey: string) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.metrics.hits++;
|
||||
this.metrics.totalQueries++;
|
||||
this.updateHitRate();
|
||||
|
||||
logger.debug('Cache hit', { queryKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cache miss (data fetched from server)
|
||||
*/
|
||||
recordMiss(queryKey: string) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.metrics.misses++;
|
||||
this.metrics.totalQueries++;
|
||||
this.updateHitRate();
|
||||
|
||||
logger.debug('Cache miss', { queryKey });
|
||||
|
||||
if (this.listeners.onCacheMiss) {
|
||||
this.listeners.onCacheMiss(queryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing a query
|
||||
*/
|
||||
startQuery(queryKey: string) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const key = this.normalizeQueryKey(queryKey);
|
||||
this.queryTimings.set(key, {
|
||||
queryKey: key,
|
||||
startTime: performance.now(),
|
||||
status: 'pending'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End timing a query
|
||||
*/
|
||||
endQuery(queryKey: string, status: 'success' | 'error') {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const key = this.normalizeQueryKey(queryKey);
|
||||
const timing = this.queryTimings.get(key);
|
||||
|
||||
if (!timing) {
|
||||
logger.warn('Query timing not found', { queryKey: key });
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - timing.startTime;
|
||||
|
||||
timing.endTime = endTime;
|
||||
timing.duration = duration;
|
||||
timing.status = status;
|
||||
|
||||
// Update average query time
|
||||
const totalTime = this.metrics.avgQueryTime * (this.metrics.totalQueries - 1) + duration;
|
||||
this.metrics.avgQueryTime = totalTime / this.metrics.totalQueries;
|
||||
|
||||
// Check for slow query
|
||||
if (duration > this.slowQueryThreshold) {
|
||||
this.metrics.slowQueries++;
|
||||
|
||||
logger.warn('Slow query detected', {
|
||||
queryKey: key,
|
||||
duration: Math.round(duration),
|
||||
threshold: this.slowQueryThreshold
|
||||
});
|
||||
|
||||
if (this.listeners.onSlowQuery) {
|
||||
this.listeners.onSlowQuery(key, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.queryTimings.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cache invalidation
|
||||
*/
|
||||
recordInvalidation(queryKey: string) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.metrics.invalidations++;
|
||||
|
||||
logger.debug('Cache invalidated', { queryKey });
|
||||
|
||||
if (this.listeners.onInvalidation) {
|
||||
this.listeners.onInvalidation(queryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current metrics
|
||||
*/
|
||||
getMetrics(): Readonly<CacheMetrics> {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics as formatted string
|
||||
*/
|
||||
getMetricsReport(): string {
|
||||
const m = this.metrics;
|
||||
const uptimeMinutes = Math.round((Date.now() - m.lastReset.getTime()) / 1000 / 60);
|
||||
|
||||
return `
|
||||
Cache Performance Report
|
||||
========================
|
||||
Uptime: ${uptimeMinutes} minutes
|
||||
Total Queries: ${m.totalQueries}
|
||||
Cache Hits: ${m.hits} (${(m.hitRate * 100).toFixed(1)}%)
|
||||
Cache Misses: ${m.misses}
|
||||
Avg Query Time: ${Math.round(m.avgQueryTime)}ms
|
||||
Slow Queries: ${m.slowQueries}
|
||||
Invalidations: ${m.invalidations}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log current metrics to console
|
||||
*/
|
||||
logMetrics() {
|
||||
console.log(this.getMetricsReport());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set slow query threshold (in milliseconds)
|
||||
*/
|
||||
setSlowQueryThreshold(ms: number) {
|
||||
this.slowQueryThreshold = ms;
|
||||
logger.info('Slow query threshold updated', { threshold: ms });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listeners
|
||||
*/
|
||||
on(event: 'slowQuery', callback: (queryKey: string, duration: number) => void): void;
|
||||
on(event: 'cacheMiss', callback: (queryKey: string) => void): void;
|
||||
on(event: 'invalidation', callback: (queryKey: string) => void): void;
|
||||
on(event: string, callback: (...args: any[]) => void): void {
|
||||
if (event === 'slowQuery') {
|
||||
this.listeners.onSlowQuery = callback;
|
||||
} else if (event === 'cacheMiss') {
|
||||
this.listeners.onCacheMiss = callback;
|
||||
} else if (event === 'invalidation') {
|
||||
this.listeners.onInvalidation = callback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup automatic tracking with QueryClient
|
||||
* @private
|
||||
*/
|
||||
private setupQueryClientTracking(queryClient: QueryClient) {
|
||||
const cache = queryClient.getQueryCache();
|
||||
|
||||
// Subscribe to cache updates
|
||||
const unsubscribe = cache.subscribe((event) => {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const queryKey = this.normalizeQueryKey(event.query.queryKey);
|
||||
|
||||
if (event.type === 'updated') {
|
||||
const query = event.query;
|
||||
|
||||
// Check if this is a cache hit or miss
|
||||
if (query.state.dataUpdatedAt > 0) {
|
||||
const isCacheHit = query.state.fetchStatus !== 'fetching';
|
||||
|
||||
if (isCacheHit) {
|
||||
this.recordHit(queryKey);
|
||||
} else {
|
||||
this.recordMiss(queryKey);
|
||||
this.startQuery(queryKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Record when fetch completes
|
||||
if (query.state.status === 'success' || query.state.status === 'error') {
|
||||
this.endQuery(queryKey, query.state.status);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store unsubscribe function
|
||||
(this as any)._unsubscribe = unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize query key to string for tracking
|
||||
* @private
|
||||
*/
|
||||
private normalizeQueryKey(queryKey: string | readonly unknown[]): string {
|
||||
if (typeof queryKey === 'string') {
|
||||
return queryKey;
|
||||
}
|
||||
return JSON.stringify(queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hit rate percentage
|
||||
* @private
|
||||
*/
|
||||
private updateHitRate() {
|
||||
if (this.metrics.totalQueries === 0) {
|
||||
this.metrics.hitRate = 0;
|
||||
} else {
|
||||
this.metrics.hitRate = this.metrics.hits / this.metrics.totalQueries;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics to initial state
|
||||
* @private
|
||||
*/
|
||||
private resetMetrics(): CacheMetrics {
|
||||
return {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
hitRate: 0,
|
||||
totalQueries: 0,
|
||||
avgQueryTime: 0,
|
||||
slowQueries: 0,
|
||||
invalidations: 0,
|
||||
lastReset: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cacheMonitor = new CacheMonitor();
|
||||
|
||||
/**
|
||||
* Hook to use cache monitoring in React components
|
||||
* Only use for debugging - do not leave in production code
|
||||
*
|
||||
* @example
|
||||
* function DebugPanel() {
|
||||
* const metrics = useCacheMonitoring();
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <h3>Cache Stats</h3>
|
||||
* <p>Hit Rate: {(metrics.hitRate * 100).toFixed(1)}%</p>
|
||||
* <p>Avg Query Time: {Math.round(metrics.avgQueryTime)}ms</p>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*/
|
||||
export function useCacheMonitoring() {
|
||||
// Re-render when metrics change (simple polling)
|
||||
const [metrics, setMetrics] = React.useState(cacheMonitor.getMetrics());
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMetrics(cacheMonitor.getMetrics());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// Only import React if using the hook
|
||||
let React: any;
|
||||
try {
|
||||
React = require('react');
|
||||
} catch {
|
||||
// React not available, hook won't work but main exports still functional
|
||||
}
|
||||
Reference in New Issue
Block a user