949b845992
- Modified the logger to check if the data is an instance of Error. - If it is an Error, the logger now formats the output to include the message and stack trace in debug mode, or just the message otherwise. - Retained the existing behavior for other object types in debug mode.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
/**
|
|
* Structured Logger for claude-mem Worker Service
|
|
* Provides readable, traceable logging with correlation IDs and data flow tracking
|
|
*/
|
|
|
|
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
|
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
export enum LogLevel {
|
|
DEBUG = 0,
|
|
INFO = 1,
|
|
WARN = 2,
|
|
ERROR = 3,
|
|
SILENT = 4
|
|
}
|
|
|
|
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM' | 'HTTP' | 'SESSION' | 'CHROMA';
|
|
|
|
interface LogContext {
|
|
sessionId?: number;
|
|
sdkSessionId?: string;
|
|
correlationId?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
class Logger {
|
|
private level: LogLevel | null = null;
|
|
private useColor: boolean;
|
|
private logFilePath: string | null = null;
|
|
|
|
constructor() {
|
|
// Disable colors when output is not a TTY (e.g., PM2 logs)
|
|
this.useColor = process.stdout.isTTY ?? false;
|
|
this.initializeLogFile();
|
|
}
|
|
|
|
/**
|
|
* Initialize log file path and ensure directory exists
|
|
*/
|
|
private initializeLogFile(): void {
|
|
try {
|
|
// Get data directory from settings
|
|
const dataDir = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
|
const logsDir = join(dataDir, 'logs');
|
|
|
|
// Ensure logs directory exists
|
|
if (!existsSync(logsDir)) {
|
|
mkdirSync(logsDir, { recursive: true });
|
|
}
|
|
|
|
// Create log file path with date
|
|
const date = new Date().toISOString().split('T')[0];
|
|
this.logFilePath = join(logsDir, `claude-mem-${date}.log`);
|
|
} catch (error) {
|
|
// If log file initialization fails, just log to console
|
|
console.error('[LOGGER] Failed to initialize log file:', error);
|
|
this.logFilePath = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lazy-load log level from settings file (not hardcoded defaults!)
|
|
*/
|
|
private getLevel(): LogLevel {
|
|
if (this.level === null) {
|
|
try {
|
|
// Load settings from file to get user's actual log level
|
|
const dataDir = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
|
const settingsPath = join(dataDir, 'settings.json');
|
|
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
|
const envLevel = settings.CLAUDE_MEM_LOG_LEVEL.toUpperCase();
|
|
this.level = LogLevel[envLevel as keyof typeof LogLevel] ?? LogLevel.INFO;
|
|
} catch (error) {
|
|
// Fallback to INFO if settings can't be loaded
|
|
console.error('[LOGGER] Failed to load settings, using INFO level:', error);
|
|
this.level = LogLevel.INFO;
|
|
}
|
|
}
|
|
return this.level;
|
|
}
|
|
|
|
/**
|
|
* Create correlation ID for tracking an observation through the pipeline
|
|
*/
|
|
correlationId(sessionId: number, observationNum: number): string {
|
|
return `obs-${sessionId}-${observationNum}`;
|
|
}
|
|
|
|
/**
|
|
* Create session correlation ID
|
|
*/
|
|
sessionId(sessionId: number): string {
|
|
return `session-${sessionId}`;
|
|
}
|
|
|
|
/**
|
|
* Format data for logging - create compact summaries instead of full dumps
|
|
*/
|
|
private formatData(data: any): string {
|
|
if (data === null || data === undefined) return '';
|
|
if (typeof data === 'string') return data;
|
|
if (typeof data === 'number') return data.toString();
|
|
if (typeof data === 'boolean') return data.toString();
|
|
|
|
// For objects, create compact summaries
|
|
if (typeof data === 'object') {
|
|
// If it's an error, show message and stack in debug mode
|
|
if (data instanceof Error) {
|
|
return this.getLevel() === LogLevel.DEBUG
|
|
? `${data.message}\n${data.stack}`
|
|
: data.message;
|
|
}
|
|
|
|
// For arrays, show count
|
|
if (Array.isArray(data)) {
|
|
return `[${data.length} items]`;
|
|
}
|
|
|
|
// For objects, show key count
|
|
const keys = Object.keys(data);
|
|
if (keys.length === 0) return '{}';
|
|
if (keys.length <= 3) {
|
|
// Show small objects inline
|
|
return JSON.stringify(data);
|
|
}
|
|
return `{${keys.length} keys: ${keys.slice(0, 3).join(', ')}...}`;
|
|
}
|
|
|
|
return String(data);
|
|
}
|
|
|
|
/**
|
|
* Format a tool name and input for compact display
|
|
*/
|
|
formatTool(toolName: string, toolInput?: any): string {
|
|
if (!toolInput) return toolName;
|
|
|
|
const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
|
|
|
|
// Bash: show full command
|
|
if (toolName === 'Bash' && input.command) {
|
|
return `${toolName}(${input.command})`;
|
|
}
|
|
|
|
// File operations: show full path
|
|
if (input.file_path) {
|
|
return `${toolName}(${input.file_path})`;
|
|
}
|
|
|
|
// NotebookEdit: show full notebook path
|
|
if (input.notebook_path) {
|
|
return `${toolName}(${input.notebook_path})`;
|
|
}
|
|
|
|
// Glob: show full pattern
|
|
if (toolName === 'Glob' && input.pattern) {
|
|
return `${toolName}(${input.pattern})`;
|
|
}
|
|
|
|
// Grep: show full pattern
|
|
if (toolName === 'Grep' && input.pattern) {
|
|
return `${toolName}(${input.pattern})`;
|
|
}
|
|
|
|
// WebFetch/WebSearch: show full URL or query
|
|
if (input.url) {
|
|
return `${toolName}(${input.url})`;
|
|
}
|
|
|
|
if (input.query) {
|
|
return `${toolName}(${input.query})`;
|
|
}
|
|
|
|
// Task: show subagent_type or full description
|
|
if (toolName === 'Task') {
|
|
if (input.subagent_type) {
|
|
return `${toolName}(${input.subagent_type})`;
|
|
}
|
|
if (input.description) {
|
|
return `${toolName}(${input.description})`;
|
|
}
|
|
}
|
|
|
|
// Skill: show skill name
|
|
if (toolName === 'Skill' && input.skill) {
|
|
return `${toolName}(${input.skill})`;
|
|
}
|
|
|
|
// LSP: show operation type
|
|
if (toolName === 'LSP' && input.operation) {
|
|
return `${toolName}(${input.operation})`;
|
|
}
|
|
|
|
// Default: just show tool name
|
|
return toolName;
|
|
}
|
|
|
|
/**
|
|
* Format timestamp in local timezone (YYYY-MM-DD HH:MM:SS.mmm)
|
|
*/
|
|
private formatTimestamp(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
|
|
}
|
|
|
|
/**
|
|
* Core logging method
|
|
*/
|
|
private log(
|
|
level: LogLevel,
|
|
component: Component,
|
|
message: string,
|
|
context?: LogContext,
|
|
data?: any
|
|
): void {
|
|
if (level < this.getLevel()) return;
|
|
|
|
const timestamp = this.formatTimestamp(new Date());
|
|
const levelStr = LogLevel[level].padEnd(5);
|
|
const componentStr = component.padEnd(6);
|
|
|
|
// Build correlation ID part
|
|
let correlationStr = '';
|
|
if (context?.correlationId) {
|
|
correlationStr = `[${context.correlationId}] `;
|
|
} else if (context?.sessionId) {
|
|
correlationStr = `[session-${context.sessionId}] `;
|
|
}
|
|
|
|
// Build data part
|
|
let dataStr = '';
|
|
if (data !== undefined && data !== null) {
|
|
// Handle Error objects specially - they don't JSON.stringify properly
|
|
if (data instanceof Error) {
|
|
dataStr = this.getLevel() === LogLevel.DEBUG
|
|
? `\n${data.message}\n${data.stack}`
|
|
: ` ${data.message}`;
|
|
} else if (this.getLevel() === LogLevel.DEBUG && typeof data === 'object') {
|
|
// In debug mode, show full JSON for objects
|
|
dataStr = '\n' + JSON.stringify(data, null, 2);
|
|
} else {
|
|
dataStr = ' ' + this.formatData(data);
|
|
}
|
|
}
|
|
|
|
// Build additional context
|
|
let contextStr = '';
|
|
if (context) {
|
|
const { sessionId, sdkSessionId, correlationId, ...rest } = context;
|
|
if (Object.keys(rest).length > 0) {
|
|
const pairs = Object.entries(rest).map(([k, v]) => `${k}=${v}`);
|
|
contextStr = ` {${pairs.join(', ')}}`;
|
|
}
|
|
}
|
|
|
|
const logLine = `[${timestamp}] [${levelStr}] [${componentStr}] ${correlationStr}${message}${contextStr}${dataStr}`;
|
|
|
|
// Output to log file ONLY (worker runs in background, console is useless)
|
|
if (this.logFilePath) {
|
|
try {
|
|
appendFileSync(this.logFilePath, logLine + '\n', 'utf8');
|
|
} catch (error) {
|
|
// If file write fails, write to stderr as last resort
|
|
process.stderr.write(`[LOGGER] Failed to write to log file: ${error}\n`);
|
|
}
|
|
} else {
|
|
// If no log file available, write to stderr as fallback
|
|
process.stderr.write(logLine + '\n');
|
|
}
|
|
}
|
|
|
|
// Public logging methods
|
|
debug(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.log(LogLevel.DEBUG, component, message, context, data);
|
|
}
|
|
|
|
info(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.log(LogLevel.INFO, component, message, context, data);
|
|
}
|
|
|
|
warn(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.log(LogLevel.WARN, component, message, context, data);
|
|
}
|
|
|
|
error(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.log(LogLevel.ERROR, component, message, context, data);
|
|
}
|
|
|
|
/**
|
|
* Log data flow: input → processing
|
|
*/
|
|
dataIn(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.info(component, `→ ${message}`, context, data);
|
|
}
|
|
|
|
/**
|
|
* Log data flow: processing → output
|
|
*/
|
|
dataOut(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.info(component, `← ${message}`, context, data);
|
|
}
|
|
|
|
/**
|
|
* Log successful completion
|
|
*/
|
|
success(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.info(component, `✓ ${message}`, context, data);
|
|
}
|
|
|
|
/**
|
|
* Log failure
|
|
*/
|
|
failure(component: Component, message: string, context?: LogContext, data?: any): void {
|
|
this.error(component, `✗ ${message}`, context, data);
|
|
}
|
|
|
|
/**
|
|
* Log timing information
|
|
*/
|
|
timing(component: Component, message: string, durationMs: number, context?: LogContext): void {
|
|
this.info(component, `⏱ ${message}`, context, { duration: `${durationMs}ms` });
|
|
}
|
|
|
|
/**
|
|
* Happy Path Error - logs when the expected "happy path" fails but we have a fallback
|
|
*
|
|
* Semantic meaning: "When the happy path fails, this is an error, but we have a fallback."
|
|
*
|
|
* Use for:
|
|
* ✅ Unexpected null/undefined values that should theoretically never happen
|
|
* ✅ Defensive coding where silent fallback is acceptable
|
|
* ✅ Situations where you want to track unexpected nulls without breaking execution
|
|
*
|
|
* DO NOT use for:
|
|
* ❌ Nullable fields with valid default behavior (use direct || defaults)
|
|
* ❌ Critical validation failures (use logger.warn or throw Error)
|
|
* ❌ Try-catch blocks where error is already logged (redundant)
|
|
*
|
|
* @param component - Component where error occurred
|
|
* @param message - Error message describing what went wrong
|
|
* @param context - Optional context (sessionId, correlationId, etc)
|
|
* @param data - Optional data to include
|
|
* @param fallback - Value to return (defaults to empty string)
|
|
* @returns The fallback value
|
|
*/
|
|
happyPathError<T = string>(
|
|
component: Component,
|
|
message: string,
|
|
context?: LogContext,
|
|
data?: any,
|
|
fallback: T = '' as T
|
|
): T {
|
|
// Capture stack trace to get caller location
|
|
const stack = new Error().stack || '';
|
|
const stackLines = stack.split('\n');
|
|
// Line 0: "Error"
|
|
// Line 1: "at happyPathError ..."
|
|
// Line 2: "at <CALLER> ..." <- We want this one
|
|
const callerLine = stackLines[2] || '';
|
|
const callerMatch = callerLine.match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/);
|
|
const location = callerMatch
|
|
? `${callerMatch[1].split('/').pop()}:${callerMatch[2]}`
|
|
: 'unknown';
|
|
|
|
// Log as a warning with location info
|
|
const enhancedContext = {
|
|
...context,
|
|
location
|
|
};
|
|
|
|
this.warn(component, `[HAPPY-PATH] ${message}`, enhancedContext, data);
|
|
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const logger = new Logger();
|