From 95acf4542b0c564ce6f6cb03c0136ce48d15f7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 23 Nov 2025 20:57:24 +0100 Subject: [PATCH] feat(logging): add comprehensive client-side logging (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created client-side logging infrastructure for React Query and hooks: New Client Logger (lib/utils/client-logger.ts): - Environment-aware logging (debug/info in dev, warn/error in prod) - Structured logging with context objects - Performance timing helpers (time/timeEnd) - Group logging for related operations - Factory functions for hook/query/mutation-specific loggers React Query Configuration (components/providers/Providers.tsx): - Added custom logger to QueryClient - Integrated with client-side logger for consistent formatting - Configured mutation retry defaults SSE Hook Logging (lib/hooks/useEventSource.ts): - Connection lifecycle logging (connect/disconnect/reconnect) - Heartbeat and process update event logging - Error tracking with reconnection attempt details - Exponential backoff logging for reconnections Supervisor Hooks Logging (lib/hooks/useSupervisor.ts): - Added logging to all critical mutation hooks: - Process control (start/stop/restart) - Batch operations (start-all/stop-all/restart-all) - Configuration reload - Signal operations - Logs mutation start, success, and error states - Includes contextual metadata (process names, signals, etc.) All client-side logs: - Use structured format with timestamps - Include relevant context for debugging - Respect environment (verbose in dev, minimal in prod) - Compatible with browser devtools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/providers/Providers.tsx | 9 ++ lib/hooks/useEventSource.ts | 29 +++++- lib/hooks/useSupervisor.ts | 83 ++++++++++++++--- lib/utils/client-logger.ts | 137 +++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 lib/utils/client-logger.ts diff --git a/components/providers/Providers.tsx b/components/providers/Providers.tsx index dc1147a..ee56398 100644 --- a/components/providers/Providers.tsx +++ b/components/providers/Providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState, ReactNode } from 'react'; import { Toaster } from 'sonner'; import { ThemeProvider } from './ThemeProvider'; +import { clientLogger } from '@/lib/utils/client-logger'; export function Providers({ children }: { children: ReactNode }) { const [queryClient] = useState( @@ -15,6 +16,14 @@ export function Providers({ children }: { children: ReactNode }) { refetchOnWindowFocus: false, retry: 2, }, + mutations: { + retry: 1, + }, + }, + logger: { + log: (message) => clientLogger.debug(message), + warn: (message) => clientLogger.warn(message), + error: (error) => clientLogger.error('React Query error', error), }, }) ); diff --git a/lib/hooks/useEventSource.ts b/lib/hooks/useEventSource.ts index 6069fcd..1549754 100644 --- a/lib/hooks/useEventSource.ts +++ b/lib/hooks/useEventSource.ts @@ -1,4 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +import { createHookLogger } from '@/lib/utils/client-logger'; + +const logger = createHookLogger('useEventSource'); export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -36,6 +39,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) const connect = useCallback(() => { if (!enabled || eventSourceRef.current) return; + logger.info('Connecting to SSE', { url }); setStatus('connecting'); try { @@ -43,6 +47,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) eventSourceRef.current = eventSource; eventSource.addEventListener('connected', () => { + logger.info('SSE connected successfully', { url }); setStatus('connected'); setReconnectAttempts(0); onConnect?.(); @@ -51,6 +56,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) eventSource.addEventListener('heartbeat', (event) => { // Keep connection alive if (status !== 'connected') { + logger.debug('SSE heartbeat received, updating status to connected'); setStatus('connected'); } }); @@ -58,15 +64,20 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) eventSource.addEventListener('process-update', (event) => { try { const data = JSON.parse(event.data); + logger.debug('Process update received', { + processCount: data.processes?.length, + timestamp: data.timestamp, + }); onMessage?.({ event: 'process-update', data }); } catch (error) { - console.error('Failed to parse SSE message:', error); + logger.error('Failed to parse SSE message', error, { event: event.data }); } }); eventSource.addEventListener('error', (event) => { try { const data = JSON.parse((event as MessageEvent).data); + logger.warn('SSE error event received', { error: data }); onMessage?.({ event: 'error', data }); } catch (error) { // Not a message error, connection error @@ -74,7 +85,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) }); eventSource.onerror = (event) => { - console.error('EventSource error:', event); + logger.error('EventSource connection error', event); setStatus('error'); onError?.(event); @@ -85,24 +96,33 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) // Attempt reconnection with exponential backoff if (reconnectAttempts < maxReconnectAttempts) { const delay = Math.min(reconnectInterval * Math.pow(2, reconnectAttempts), 30000); - console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`); + logger.info('Scheduling reconnection', { + delay, + attempt: reconnectAttempts + 1, + maxAttempts: maxReconnectAttempts, + }); reconnectTimeoutRef.current = setTimeout(() => { setReconnectAttempts((prev) => prev + 1); connect(); }, delay); } else { + logger.warn('Max reconnection attempts reached, disconnecting', { + maxAttempts: maxReconnectAttempts, + }); setStatus('disconnected'); onDisconnect?.(); } }; } catch (error) { - console.error('Failed to create EventSource:', error); + logger.error('Failed to create EventSource', error, { url }); setStatus('error'); } }, [url, enabled, status, reconnectAttempts, maxReconnectAttempts, reconnectInterval, onMessage, onError, onConnect, onDisconnect]); const disconnect = useCallback(() => { + logger.info('Disconnecting from SSE'); + if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; @@ -118,6 +138,7 @@ export function useEventSource(url: string, options: UseEventSourceOptions = {}) }, []); const reconnect = useCallback(() => { + logger.info('Manual reconnection requested'); disconnect(); setReconnectAttempts(0); connect(); diff --git a/lib/hooks/useSupervisor.ts b/lib/hooks/useSupervisor.ts index 9ac79c7..695ccd3 100644 --- a/lib/hooks/useSupervisor.ts +++ b/lib/hooks/useSupervisor.ts @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { ProcessInfo, SystemInfo, LogTailResult, ConfigInfo } from '@/lib/supervisor/types'; +import { createMutationLogger } from '@/lib/utils/client-logger'; // Query Keys export const supervisorKeys = { @@ -143,16 +144,21 @@ export function useProcessLogs( export function useStartProcess() { const queryClient = useQueryClient(); + const logger = createMutationLogger('startProcess'); return useMutation({ - mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => startProcess(name, wait), + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => { + logger.info('Starting process', { name, wait }); + return startProcess(name, wait); + }, onSuccess: (data, variables) => { + logger.info('Process started successfully', { name: variables.name }); toast.success(data.message); - // Invalidate and refetch queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) }); }, - onError: (error: Error) => { + onError: (error: Error, variables) => { + logger.error('Failed to start process', error, { name: variables.name }); toast.error(`Failed to start process: ${error.message}`); }, }); @@ -160,15 +166,21 @@ export function useStartProcess() { export function useStopProcess() { const queryClient = useQueryClient(); + const logger = createMutationLogger('stopProcess'); return useMutation({ - mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => stopProcess(name, wait), + mutationFn: ({ name, wait }: { name: string; wait?: boolean }) => { + logger.info('Stopping process', { name, wait }); + return stopProcess(name, wait); + }, onSuccess: (data, variables) => { + logger.info('Process stopped successfully', { name: variables.name }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) }); }, - onError: (error: Error) => { + onError: (error: Error, variables) => { + logger.error('Failed to stop process', error, { name: variables.name }); toast.error(`Failed to stop process: ${error.message}`); }, }); @@ -176,15 +188,21 @@ export function useStopProcess() { export function useRestartProcess() { const queryClient = useQueryClient(); + const logger = createMutationLogger('restartProcess'); return useMutation({ - mutationFn: (name: string) => restartProcess(name), + mutationFn: (name: string) => { + logger.info('Restarting process', { name }); + return restartProcess(name); + }, onSuccess: (data, name) => { + logger.info('Process restarted successfully', { name }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); queryClient.invalidateQueries({ queryKey: supervisorKeys.process(name) }); }, - onError: (error: Error) => { + onError: (error: Error, name) => { + logger.error('Failed to restart process', error, { name }); toast.error(`Failed to restart process: ${error.message}`); }, }); @@ -415,14 +433,22 @@ async function restartAllProcesses(wait: boolean = true): Promise<{ success: boo export function useStartAllProcesses() { const queryClient = useQueryClient(); + const logger = createMutationLogger('startAllProcesses'); return useMutation({ - mutationFn: (wait: boolean = true) => startAllProcesses(wait), + mutationFn: (wait: boolean = true) => { + logger.info('Starting all processes', { wait }); + return startAllProcesses(wait); + }, onSuccess: (data) => { + logger.info('All processes started successfully', { + resultCount: data.results?.length, + }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); }, onError: (error: Error) => { + logger.error('Failed to start all processes', error); toast.error(`Failed to start all processes: ${error.message}`); }, }); @@ -430,14 +456,22 @@ export function useStartAllProcesses() { export function useStopAllProcesses() { const queryClient = useQueryClient(); + const logger = createMutationLogger('stopAllProcesses'); return useMutation({ - mutationFn: (wait: boolean = true) => stopAllProcesses(wait), + mutationFn: (wait: boolean = true) => { + logger.info('Stopping all processes', { wait }); + return stopAllProcesses(wait); + }, onSuccess: (data) => { + logger.info('All processes stopped successfully', { + resultCount: data.results?.length, + }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); }, onError: (error: Error) => { + logger.error('Failed to stop all processes', error); toast.error(`Failed to stop all processes: ${error.message}`); }, }); @@ -445,14 +479,22 @@ export function useStopAllProcesses() { export function useRestartAllProcesses() { const queryClient = useQueryClient(); + const logger = createMutationLogger('restartAllProcesses'); return useMutation({ - mutationFn: (wait: boolean = true) => restartAllProcesses(wait), + mutationFn: (wait: boolean = true) => { + logger.info('Restarting all processes', { wait }); + return restartAllProcesses(wait); + }, onSuccess: (data) => { + logger.info('All processes restarted successfully', { + resultCount: data.results?.length, + }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); }, onError: (error: Error) => { + logger.error('Failed to restart all processes', error); toast.error(`Failed to restart all processes: ${error.message}`); }, }); @@ -516,14 +558,20 @@ export function useConfig() { export function useReloadConfig() { const queryClient = useQueryClient(); + const logger = createMutationLogger('reloadConfig'); return useMutation({ - mutationFn: reloadConfig, + mutationFn: () => { + logger.info('Reloading supervisor configuration'); + return reloadConfig(); + }, onSuccess: (data) => { + logger.info('Configuration reloaded successfully'); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.all }); }, onError: (error: Error) => { + logger.error('Failed to reload configuration', error); toast.error(`Failed to reload configuration: ${error.message}`); }, }); @@ -602,15 +650,24 @@ async function signalAllProcesses(signal: string): Promise<{ success: boolean; m export function useSignalProcess() { const queryClient = useQueryClient(); + const logger = createMutationLogger('signalProcess'); return useMutation({ - mutationFn: ({ name, signal }: { name: string; signal: string }) => signalProcess(name, signal), + mutationFn: ({ name, signal }: { name: string; signal: string }) => { + logger.info('Sending signal to process', { name, signal }); + return signalProcess(name, signal); + }, onSuccess: (data, variables) => { + logger.info('Signal sent successfully', { name: variables.name, signal: variables.signal }); toast.success(data.message); queryClient.invalidateQueries({ queryKey: supervisorKeys.process(variables.name) }); queryClient.invalidateQueries({ queryKey: supervisorKeys.processes() }); }, - onError: (error: Error) => { + onError: (error: Error, variables) => { + logger.error('Failed to send signal', error, { + name: variables.name, + signal: variables.signal, + }); toast.error(`Failed to send signal: ${error.message}`); }, }); diff --git a/lib/utils/client-logger.ts b/lib/utils/client-logger.ts new file mode 100644 index 0000000..790db45 --- /dev/null +++ b/lib/utils/client-logger.ts @@ -0,0 +1,137 @@ +'use client'; + +/** + * Client-side logging utility + * Logs to console in development, can be extended to send to server in production + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogContext { + [key: string]: any; +} + +class ClientLogger { + private isDevelopment: boolean; + + constructor() { + this.isDevelopment = process.env.NODE_ENV === 'development'; + } + + private formatMessage(level: LogLevel, message: string, context?: LogContext): string { + const timestamp = new Date().toISOString(); + const contextStr = context ? ` ${JSON.stringify(context)}` : ''; + return `[${timestamp}] [${level.toUpperCase()}] ${message}${contextStr}`; + } + + private shouldLog(level: LogLevel): boolean { + // In production, only log warnings and errors + if (!this.isDevelopment && (level === 'debug' || level === 'info')) { + return false; + } + return true; + } + + debug(message: string, context?: LogContext): void { + if (this.shouldLog('debug')) { + console.debug(this.formatMessage('debug', message, context)); + } + } + + info(message: string, context?: LogContext): void { + if (this.shouldLog('info')) { + console.info(this.formatMessage('info', message, context)); + } + } + + warn(message: string, context?: LogContext): void { + if (this.shouldLog('warn')) { + console.warn(this.formatMessage('warn', message, context)); + } + } + + error(message: string, error?: Error | unknown, context?: LogContext): void { + if (this.shouldLog('error')) { + const errorContext = { + ...context, + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name, + } : error, + }; + console.error(this.formatMessage('error', message, errorContext)); + } + } + + // Performance timing helper + time(label: string): void { + if (this.isDevelopment) { + console.time(label); + } + } + + timeEnd(label: string): void { + if (this.isDevelopment) { + console.timeEnd(label); + } + } + + // Group logging for related operations + group(label: string): void { + if (this.isDevelopment) { + console.group(label); + } + } + + groupEnd(): void { + if (this.isDevelopment) { + console.groupEnd(); + } + } +} + +// Export singleton instance +export const clientLogger = new ClientLogger(); + +// Hook-specific logger factory +export function createHookLogger(hookName: string) { + return { + debug: (message: string, context?: LogContext) => + clientLogger.debug(`[${hookName}] ${message}`, context), + info: (message: string, context?: LogContext) => + clientLogger.info(`[${hookName}] ${message}`, context), + warn: (message: string, context?: LogContext) => + clientLogger.warn(`[${hookName}] ${message}`, context), + error: (message: string, error?: Error | unknown, context?: LogContext) => + clientLogger.error(`[${hookName}] ${message}`, error, context), + }; +} + +// Query/Mutation logger factory +export function createQueryLogger(queryKey: readonly unknown[]) { + const key = JSON.stringify(queryKey); + return { + debug: (message: string, context?: LogContext) => + clientLogger.debug(`[Query: ${key}] ${message}`, context), + info: (message: string, context?: LogContext) => + clientLogger.info(`[Query: ${key}] ${message}`, context), + warn: (message: string, context?: LogContext) => + clientLogger.warn(`[Query: ${key}] ${message}`, context), + error: (message: string, error?: Error | unknown, context?: LogContext) => + clientLogger.error(`[Query: ${key}] ${message}`, error, context), + }; +} + +export function createMutationLogger(mutationKey: string) { + return { + debug: (message: string, context?: LogContext) => + clientLogger.debug(`[Mutation: ${mutationKey}] ${message}`, context), + info: (message: string, context?: LogContext) => + clientLogger.info(`[Mutation: ${mutationKey}] ${message}`, context), + warn: (message: string, context?: LogContext) => + clientLogger.warn(`[Mutation: ${mutationKey}] ${message}`, context), + error: (message: string, error?: Error | unknown, context?: LogContext) => + clientLogger.error(`[Mutation: ${mutationKey}] ${message}`, error, context), + }; +}