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 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
32
CLAUDE.md
32
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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
192
src/frontend/src/lib/logger.js
Normal file
192
src/frontend/src/lib/logger.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user