veza/apps/web/src/utils/stateMiddleware.ts

431 lines
11 KiB
TypeScript

/**
* State Middleware
* FE-STATE-010: Add middleware for logging, analytics, error handling
*
* Provides a comprehensive middleware for Zustand stores that handles:
* - State change logging
* - Analytics tracking
* - Error handling and reporting
*/
import { StateCreator } from 'zustand';
import { logger } from './logger';
/**
* Analytics event types
*/
export type AnalyticsEventType =
| 'state_change'
| 'action_called'
| 'error_occurred'
| 'performance_metric';
/**
* Analytics event
*/
export interface AnalyticsEvent {
type: AnalyticsEventType;
store: string;
action?: string;
timestamp: number;
metadata?: Record<string, unknown>;
}
/**
* Analytics handler function
*/
export type AnalyticsHandler = (event: AnalyticsEvent) => void;
/**
* Error handler function
*/
export type ErrorHandler = (
error: Error,
context: {
store: string;
action?: string;
state?: unknown;
},
) => void;
/**
* Options for state middleware
*/
export interface StateMiddlewareOptions {
/** Store name for logging and analytics */
storeName: string;
/** Enable logging (default: true in dev, false in prod) */
enableLogging?: boolean;
/** Enable analytics tracking (default: true) */
enableAnalytics?: boolean;
/** Enable error handling (default: true) */
enableErrorHandling?: boolean;
/** Custom analytics handler */
analyticsHandler?: AnalyticsHandler;
/** Custom error handler */
errorHandler?: ErrorHandler;
/** Filter state changes to log (return false to skip) */
shouldLog?: (state: unknown, prevState: unknown) => boolean;
/** Filter actions to track (return false to skip) */
shouldTrack?: (action: string, args: unknown[]) => boolean;
/** Sanitize state for logging (remove sensitive data) */
sanitizeState?: (state: unknown) => unknown;
}
/**
* Default analytics handler (can be overridden)
*/
let defaultAnalyticsHandler: AnalyticsHandler | null = null;
/**
* Set default analytics handler
*/
export function setAnalyticsHandler(handler: AnalyticsHandler): void {
defaultAnalyticsHandler = handler;
}
/**
* Default error handler (can be overridden)
*/
let defaultErrorHandler: ErrorHandler | null = null;
/**
* Set default error handler
*/
export function setErrorHandler(handler: ErrorHandler): void {
defaultErrorHandler = handler;
}
/**
* Track analytics event
*/
function trackAnalytics(
event: Omit<AnalyticsEvent, 'timestamp'>,
handler?: AnalyticsHandler,
): void {
const analyticsHandler = handler || defaultAnalyticsHandler;
if (!analyticsHandler) {
return;
}
try {
analyticsHandler({
...event,
timestamp: Date.now(),
});
} catch (error) {
logger.error('[StateMiddleware] Analytics tracking failed', {
error: String(error),
});
}
}
/**
* Handle error
*/
function handleError(
error: Error,
context: {
store: string;
action?: string;
state?: unknown;
},
handler?: ErrorHandler,
): void {
const errorHandler = handler || defaultErrorHandler;
// Always log errors
logger.error(`[StateMiddleware] Error in ${context.store}:`, {
error: error.message,
stack: error.stack,
action: context.action,
context,
});
// Call custom error handler if provided
if (errorHandler) {
try {
errorHandler(error, context);
} catch (handlerError) {
logger.error('[StateMiddleware] Error handler failed', {
error: String(handlerError),
});
}
}
// Track error analytics (use default handler, not error handler)
trackAnalytics({
type: 'error_occurred',
store: context.store,
action: context.action,
metadata: {
error: error.message,
errorName: error.name,
},
});
}
/**
* Sanitize state for logging (remove sensitive data)
*/
function sanitizeState(
state: unknown,
customSanitize?: (state: unknown) => unknown,
): unknown {
if (customSanitize) {
return customSanitize(state);
}
// Default sanitization: remove common sensitive fields
if (typeof state === 'object' && state !== null) {
const sanitized = { ...(state as Record<string, unknown>) };
const sensitiveKeys = [
'password',
'token',
'secret',
'apiKey',
'accessToken',
'refreshToken',
];
for (const key of sensitiveKeys) {
if (key in sanitized) {
sanitized[key] = '[REDACTED]';
}
}
return sanitized;
}
return state;
}
/**
* Zustand middleware for logging, analytics, and error handling
*
* @example
* ```typescript
* export const useMyStore = create<MyState>()(
* stateMiddleware(
* (set, get) => ({
* // store implementation
* }),
* { storeName: 'MyStore' }
* )
* );
* ```
*/
export function stateMiddleware<T extends object>(
config: StateCreator<T>,
options: StateMiddlewareOptions,
): StateCreator<T> {
const {
storeName,
enableLogging = import.meta.env.DEV,
enableAnalytics = true,
enableErrorHandling = true,
analyticsHandler,
errorHandler,
shouldLog = () => true,
shouldTrack = () => true,
sanitizeState: customSanitize,
} = options;
return (set, get, api) => {
let previousState: T | null = null;
let actionCallCount = 0;
// Wrap set function to intercept state changes
const wrappedSet: typeof set = (...args) => {
const currentState = get();
// Log state changes
if (enableLogging && shouldLog(currentState, previousState)) {
const sanitizedCurrent = sanitizeState(currentState, customSanitize);
const sanitizedPrevious = sanitizeState(previousState, customSanitize);
logger.debug(`[StateMiddleware:${storeName}] State change:`, {
previous: sanitizedPrevious,
current: sanitizedCurrent,
});
}
// Track analytics
if (enableAnalytics) {
trackAnalytics(
{
type: 'state_change',
store: storeName,
metadata: {
hasPreviousState: previousState !== null,
},
},
analyticsHandler,
);
}
// Call original set
try {
set(...args);
previousState = get();
} catch (error) {
if (enableErrorHandling) {
handleError(
error instanceof Error ? error : new Error(String(error)),
{
store: storeName,
state: currentState,
},
errorHandler,
);
}
throw error;
}
};
// Create store with wrapped set
const store = config(wrappedSet, get, api);
// Wrap store actions to track calls
if (enableAnalytics || enableLogging) {
const wrappedStore = { ...store } as Record<string, unknown>;
// Wrap all functions in the store
for (const [key, value] of Object.entries(wrappedStore)) {
if (typeof value === 'function' && key !== 'setState') {
const originalAction = value as (...args: unknown[]) => unknown;
wrappedStore[key] = ((...args: unknown[]) => {
actionCallCount++;
const startTime = performance.now();
// Track action call
if (enableAnalytics && shouldTrack(key, args)) {
trackAnalytics(
{
type: 'action_called',
store: storeName,
action: key,
metadata: {
callCount: actionCallCount,
argsCount: args.length,
},
},
analyticsHandler,
);
}
// Log action call
if (enableLogging) {
logger.debug(
`[StateMiddleware:${storeName}] Action called: ${key}`,
{
args: args.length > 0 ? args : undefined,
},
);
}
try {
const result = originalAction(...args);
// Track performance if it's a promise
if (result instanceof Promise) {
const endTime = performance.now();
const duration = endTime - startTime;
if (enableAnalytics) {
trackAnalytics(
{
type: 'performance_metric',
store: storeName,
action: key,
metadata: {
duration,
isAsync: true,
},
},
analyticsHandler,
);
}
// Handle promise errors
if (enableErrorHandling) {
result.catch((error) => {
handleError(
error instanceof Error ? error : new Error(String(error)),
{
store: storeName,
action: key,
state: get(),
},
errorHandler,
);
});
}
} else {
// Track synchronous action performance
const endTime = performance.now();
const duration = endTime - startTime;
if (enableAnalytics && duration > 10) {
// Only track if it takes more than 10ms
trackAnalytics(
{
type: 'performance_metric',
store: storeName,
action: key,
metadata: {
duration,
isAsync: false,
},
},
analyticsHandler,
);
}
}
return result;
} catch (error) {
if (enableErrorHandling) {
handleError(
error instanceof Error ? error : new Error(String(error)),
{
store: storeName,
action: key,
state: get(),
},
errorHandler,
);
}
throw error;
}
}) as typeof originalAction;
}
}
return wrappedStore as T;
}
return store;
};
}
/**
* Helper to create a store with state middleware
*
* @example
* ```typescript
* export const useMyStore = createWithMiddleware<MyState>(
* (set, get) => ({
* // store implementation
* }),
* { storeName: 'MyStore' }
* );
* ```
*/
export function createWithMiddleware<T extends object>(
config: StateCreator<T>,
options: StateMiddlewareOptions,
): StateCreator<T> {
return stateMiddleware(config, options);
}