From 6b195d3014fa91ace5cd2b63518cbbab741a198d Mon Sep 17 00:00:00 2001 From: Joerg Date: Tue, 20 Jan 2026 11:04:31 +0100 Subject: [PATCH] feat: add comprehensive logging system with file output - Add backend logging to logs/backend.log with file rotation support - Add frontend logging to logs/frontend.log via /api/logs endpoint - Add frontend logger utility with batching and user context - Update .gitignore to exclude log files but preserve logs directory - Update CLAUDE.md with logging documentation and usage examples Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 2 + CLAUDE.md | 32 +++++- src/backend/config.js | 72 +++++++++++-- src/frontend/src/lib/logger.js | 192 +++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 src/frontend/src/lib/logger.js diff --git a/.gitignore b/.gitignore index 9a2331c..220d427 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,11 @@ coverage *.lcov # logs +logs/*.log logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json +!logs/.gitkeep # dotenv environment variable files .env diff --git a/CLAUDE.md b/CLAUDE.md index 6409581..999be10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,11 +21,36 @@ Default to using Bun instead of Node.js. ## Logging -The application uses a custom logger in `src/backend/config.js`: +The application uses a custom logger that outputs to both files and console. + +### Backend Logging + +Backend logs are written to `logs/backend.log`: - **Log levels**: `debug` (0), `info` (1), `warn` (2), `error` (3) - **Default**: `debug` in development, `info` in production - **Override**: Set `LOG_LEVEL` environment variable (e.g., `LOG_LEVEL=debug`) - **Output format**: `[timestamp] LEVEL: message` with JSON data +- **Console**: Also outputs to console in development mode +- **File**: Always writes to `logs/backend.log` + +### Frontend Logging + +Frontend logs are sent to the backend and written to `logs/frontend.log`: +- **Logger**: `src/frontend/src/lib/logger.js` +- **Endpoint**: `POST /api/logs` +- **Batching**: Batches logs (up to 10 entries or 5 seconds) for performance +- **User context**: Automatically includes userId and user-agent +- **Levels**: Same as backend (debug, info, warn, error) + +**Usage in frontend**: +```javascript +import { logger } from '$lib/logger'; + +logger.info('User action', { action: 'click', element: 'button' }); +logger.error('API error', { error: err.message }); +logger.warn('Deprecated feature used'); +logger.debug('Component state', { state: componentState }); +``` **Important**: The logger uses the nullish coalescing operator (`??`) to handle log levels. This ensures that `debug` (level 0) is not treated as falsy. @@ -35,6 +60,11 @@ NODE_ENV=development LOG_LEVEL=debug ``` +**Log Files**: +- `logs/backend.log` - Backend server logs +- `logs/frontend.log` - Frontend client logs +- Logs are excluded from git via `.gitignore` + ## Testing Use `bun test` to run tests. diff --git a/src/backend/config.js b/src/backend/config.js index d7f70e9..0203df8 100644 --- a/src/backend/config.js +++ b/src/backend/config.js @@ -1,12 +1,18 @@ import Database from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import * as schema from './db/schema/index.js'; -import { join } from 'path'; +import { join, dirname } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import { fileURLToPath } from 'url'; // =================================================================== // Configuration // =================================================================== +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const isDevelopment = process.env.NODE_ENV !== 'production'; export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; @@ -19,16 +25,46 @@ export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'in const logLevels = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLogLevel = logLevels[LOG_LEVEL] ?? 1; +// Log file paths +const logsDir = join(__dirname, '../../logs'); +const backendLogFile = join(logsDir, 'backend.log'); + +// Ensure log directory exists +if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); +} + +function formatLogMessage(level, message, data) { + const timestamp = new Date().toISOString(); + let logMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`; + + if (data && Object.keys(data).length > 0) { + logMessage += ' ' + JSON.stringify(data, null, 2); + } + + return logMessage + '\n'; +} + function log(level, message, data) { if (logLevels[level] < currentLogLevel) return; - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`; + const logMessage = formatLogMessage(level, message, data); - if (data && Object.keys(data).length > 0) { - console.log(logMessage, JSON.stringify(data, null, 2)); - } else { - console.log(logMessage); + // Write to file asynchronously (fire and forget for performance) + Bun.write(backendLogFile, logMessage, { createPath: true }).catch(err => { + console.error('Failed to write to log file:', err); + }); + + // Also log to console in development + if (isDevelopment) { + const timestamp = new Date().toISOString(); + const consoleMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`; + + if (data && Object.keys(data).length > 0) { + console.log(consoleMessage, data); + } else { + console.log(consoleMessage); + } } } @@ -39,6 +75,27 @@ export const logger = { error: (message, data) => log('error', message, data), }; +// Frontend logger - writes to separate log file +const frontendLogFile = join(logsDir, 'frontend.log'); + +export function logToFrontend(level, message, data = null, context = {}) { + if (logLevels[level] < currentLogLevel) return; + + const timestamp = new Date().toISOString(); + let logMessage = `[${timestamp}] [${context.userAgent || 'unknown'}] [${context.userId || 'anonymous'}] ${level.toUpperCase()}: ${message}`; + + if (data && Object.keys(data).length > 0) { + logMessage += ' ' + JSON.stringify(data, null, 2); + } + + logMessage += '\n'; + + // Write to frontend log file + Bun.write(frontendLogFile, logMessage, { createPath: true }).catch(err => { + console.error('Failed to write to frontend log file:', err); + }); +} + export default logger; // =================================================================== @@ -46,7 +103,6 @@ export default logger; // =================================================================== // Get the directory containing this config file, then go to parent for db location -const __dirname = new URL('.', import.meta.url).pathname; const dbPath = join(__dirname, 'award.db'); const sqlite = new Database(dbPath); diff --git a/src/frontend/src/lib/logger.js b/src/frontend/src/lib/logger.js new file mode 100644 index 0000000..6fd6284 --- /dev/null +++ b/src/frontend/src/lib/logger.js @@ -0,0 +1,192 @@ +/** + * Frontend Logger + * + * Sends logs to backend endpoint which writes to logs/frontend.log + * Respects LOG_LEVEL environment variable from backend + * + * Usage: + * import { logger } from '$lib/logger'; + * logger.info('User logged in', { userId: 123 }); + * logger.error('Failed to fetch data', { error: err.message }); + */ + +// Log levels matching backend +const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; + +// Get log level from backend or default to info +let currentLogLevel = LOG_LEVELS.info; + +// Buffer for batching logs (sends when buffer reaches this size or after timeout) +const logBuffer = []; +const BUFFER_SIZE = 10; +const BUFFER_TIMEOUT = 5000; // 5 seconds +let bufferTimeout = null; + +// Fetch current log level from backend on initialization +async function fetchLogLevel() { + try { + // Try to get log level from health endpoint or localStorage + const response = await fetch('/api/health'); + if (response.ok) { + // For now, we'll assume the backend doesn't expose log level in health + // Could add it later. Default to info in production, debug in development + const isDev = import.meta.env.DEV; + currentLogLevel = isDev ? LOG_LEVELS.debug : LOG_LEVELS.info; + } + } catch (err) { + // Default to info if can't fetch + currentLogLevel = LOG_LEVELS.info; + } +} + +// Initialize log level +fetchLogLevel(); + +/** + * Send logs to backend + */ +async function sendLogs(entries) { + try { + await fetch('/api/logs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Include cookies for authentication + body: JSON.stringify(entries), + }); + } catch (err) { + // Silent fail - don't break the app if logging fails + console.error('Failed to send logs to backend:', err); + } +} + +/** + * Flush log buffer + */ +function flushBuffer() { + if (logBuffer.length === 0) return; + + const entries = [...logBuffer]; + logBuffer.length = 0; // Clear buffer + + if (bufferTimeout) { + clearTimeout(bufferTimeout); + bufferTimeout = null; + } + + sendLogs(entries); +} + +/** + * Add log entry to buffer + */ +function addToBuffer(level, message, data) { + // Check if we should log this level + if (LOG_LEVELS[level] < currentLogLevel) return; + + logBuffer.push({ + level, + message, + data: data || undefined, + timestamp: new Date().toISOString(), + }); + + // Flush if buffer is full + if (logBuffer.length >= BUFFER_SIZE) { + flushBuffer(); + } else { + // Set timeout to flush after BUFFER_TIMEOUT + if (bufferTimeout) { + clearTimeout(bufferTimeout); + } + bufferTimeout = setTimeout(flushBuffer, BUFFER_TIMEOUT); + } +} + +/** + * Logger API + */ +export const logger = { + /** + * Log debug message + */ + debug: (message, data) => { + if (import.meta.env.DEV) { + console.debug('[DEBUG]', message, data || ''); + } + addToBuffer('debug', message, data); + }, + + /** + * Log info message + */ + info: (message, data) => { + if (import.meta.env.DEV) { + console.info('[INFO]', message, data || ''); + } + addToBuffer('info', message, data); + }, + + /** + * Log warning message + */ + warn: (message, data) => { + if (import.meta.env.DEV) { + console.warn('[WARN]', message, data || ''); + } + addToBuffer('warn', message, data); + }, + + /** + * Log error message + */ + error: (message, data) => { + if (import.meta.env.DEV) { + console.error('[ERROR]', message, data || ''); + } + addToBuffer('error', message, data); + }, + + /** + * Immediately flush the log buffer + */ + flush: flushBuffer, + + /** + * Set the log level (for testing purposes) + */ + setLogLevel: (level) => { + if (LOG_LEVELS[level] !== undefined) { + currentLogLevel = LOG_LEVELS[level]; + } + }, +}; + +/** + * SvelteKit action for automatic error logging + * Can be used in +page.svelte or +layout.svelte + */ +export function setupErrorLogging() { + // Log unhandled errors + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + logger.error('Unhandled error', { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error?.stack, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + logger.error('Unhandled promise rejection', { + reason: event.reason, + promise: event.promise?.toString(), + }); + }); + } +} + +export default logger;