feat(logging): Implement structured logging across the application
- Introduced a new Logger utility to standardize logging with correlation IDs and structured context. - Replaced console.error and console.log statements with logger methods in various modules including save.ts, summary.ts, parser.ts, HooksDatabase.ts, and worker-service.ts. - Enhanced error handling and logging for better traceability of observations and summaries. - Made observations.text nullable in the database schema to support structured fields. - Added correlation IDs for tracking observations through the processing pipeline.
This commit is contained in:
+19
-5
@@ -1,5 +1,6 @@
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { createHookResponse } from './hook-response.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface PostToolUseInput {
|
||||
session_id: string;
|
||||
@@ -42,7 +43,7 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
|
||||
|
||||
if (!session.worker_port) {
|
||||
db.close();
|
||||
console.error('[save-hook] No worker port for session', session.id);
|
||||
logger.error('HOOK', 'No worker port for session', { sessionId: session.id });
|
||||
console.log(createHookResponse('PostToolUse', true));
|
||||
return;
|
||||
}
|
||||
@@ -51,24 +52,37 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
|
||||
const promptNumber = db.getPromptCounter(session.id);
|
||||
db.close();
|
||||
|
||||
const toolStr = logger.formatTool(tool_name, tool_input);
|
||||
|
||||
try {
|
||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
||||
sessionId: session.id,
|
||||
workerPort: session.worker_port
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tool_name,
|
||||
tool_input: JSON.stringify(tool_input),
|
||||
tool_output: JSON.stringify(tool_output),
|
||||
tool_input: tool_input !== undefined ? JSON.stringify(tool_input) : '{}',
|
||||
tool_output: tool_output !== undefined ? JSON.stringify(tool_output) : '{}',
|
||||
prompt_number: promptNumber
|
||||
}),
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[save-hook] Failed to send observation:', await response.text());
|
||||
const errorText = await response.text();
|
||||
logger.failure('HOOK', 'Failed to send observation', {
|
||||
sessionId: session.id,
|
||||
status: response.status
|
||||
}, errorText);
|
||||
} else {
|
||||
logger.debug('HOOK', 'Observation sent successfully', { sessionId: session.id, toolName: tool_name });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[save-hook] Error:', error.message);
|
||||
logger.failure('HOOK', 'Error sending observation', { sessionId: session.id }, error);
|
||||
} finally {
|
||||
console.log(createHookResponse('PostToolUse', true));
|
||||
}
|
||||
|
||||
+16
-3
@@ -1,5 +1,6 @@
|
||||
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
|
||||
import { createHookResponse } from './hook-response.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface StopInput {
|
||||
session_id: string;
|
||||
@@ -28,7 +29,7 @@ export async function summaryHook(input?: StopInput): Promise<void> {
|
||||
|
||||
if (!session.worker_port) {
|
||||
db.close();
|
||||
console.error('[summary-hook] No worker port for session', session.id);
|
||||
logger.error('HOOK', 'No worker port for session', { sessionId: session.id });
|
||||
console.log(createHookResponse('Stop', true));
|
||||
return;
|
||||
}
|
||||
@@ -38,6 +39,12 @@ export async function summaryHook(input?: StopInput): Promise<void> {
|
||||
db.close();
|
||||
|
||||
try {
|
||||
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
||||
sessionId: session.id,
|
||||
workerPort: session.worker_port,
|
||||
promptNumber
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/summarize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -46,10 +53,16 @@ export async function summaryHook(input?: StopInput): Promise<void> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[summary-hook] Failed to generate summary:', await response.text());
|
||||
const errorText = await response.text();
|
||||
logger.failure('HOOK', 'Failed to generate summary', {
|
||||
sessionId: session.id,
|
||||
status: response.status
|
||||
}, errorText);
|
||||
} else {
|
||||
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: session.id });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[summary-hook] Error:', error.message);
|
||||
logger.failure('HOOK', 'Error requesting summary', { sessionId: session.id }, error);
|
||||
} finally {
|
||||
console.log(createHookResponse('Stop', true));
|
||||
}
|
||||
|
||||
+20
-5
@@ -3,6 +3,8 @@
|
||||
* Parses observation and summary XML blocks from SDK responses
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface ParsedObservation {
|
||||
type: string;
|
||||
title: string;
|
||||
@@ -27,7 +29,7 @@ export interface ParsedSummary {
|
||||
* Parse observation XML blocks from SDK response
|
||||
* Returns all observations found in the response
|
||||
*/
|
||||
export function parseObservations(text: string): ParsedObservation[] {
|
||||
export function parseObservations(text: string, correlationId?: string): ParsedObservation[] {
|
||||
const observations: ParsedObservation[] = [];
|
||||
|
||||
// Match <observation>...</observation> blocks (non-greedy)
|
||||
@@ -49,14 +51,20 @@ export function parseObservations(text: string): ParsedObservation[] {
|
||||
|
||||
// Validate required fields
|
||||
if (!type || !title || !subtitle || !narrative) {
|
||||
console.warn('[SDK Parser] Observation missing required fields, skipping');
|
||||
logger.warn('PARSER', 'Observation missing required fields, skipping', {
|
||||
correlationId,
|
||||
hasType: !!type,
|
||||
hasTitle: !!title,
|
||||
hasSubtitle: !!subtitle,
|
||||
hasNarrative: !!narrative
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate type
|
||||
const validTypes = ['change', 'discovery', 'decision'];
|
||||
if (!validTypes.includes(type.trim())) {
|
||||
console.warn(`[SDK Parser] Invalid observation type: ${type}, skipping`);
|
||||
logger.warn('PARSER', `Invalid observation type: ${type}, skipping`, { correlationId });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -79,7 +87,7 @@ export function parseObservations(text: string): ParsedObservation[] {
|
||||
* Parse summary XML block from SDK response
|
||||
* Returns null if no valid summary found
|
||||
*/
|
||||
export function parseSummary(text: string): ParsedSummary | null {
|
||||
export function parseSummary(text: string, sessionId?: number): ParsedSummary | null {
|
||||
// Match <summary>...</summary> block (non-greedy)
|
||||
const summaryRegex = /<summary>([\s\S]*?)<\/summary>/;
|
||||
const summaryMatch = summaryRegex.exec(text);
|
||||
@@ -100,7 +108,14 @@ export function parseSummary(text: string): ParsedSummary | null {
|
||||
|
||||
// Validate required fields are present (notes is optional)
|
||||
if (!request || !investigated || !learned || !completed || !next_steps) {
|
||||
console.warn('[SDK Parser] Summary missing required fields');
|
||||
logger.warn('PARSER', 'Summary missing required fields', {
|
||||
sessionId,
|
||||
hasRequest: !!request,
|
||||
hasInvestigated: !!investigated,
|
||||
hasLearned: !!learned,
|
||||
hasCompleted: !!completed,
|
||||
hasNextSteps: !!next_steps
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Lightweight database interface for hooks
|
||||
@@ -23,6 +24,7 @@ export class HooksDatabase {
|
||||
this.ensurePromptTrackingColumns();
|
||||
this.removeSessionSummariesUniqueConstraint();
|
||||
this.addObservationHierarchicalFields();
|
||||
this.makeObservationsTextNullable();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +197,86 @@ export class HooksDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make observations.text nullable (migration 009)
|
||||
* The text field is deprecated in favor of structured fields (title, subtitle, narrative, etc.)
|
||||
*/
|
||||
private makeObservationsTextNullable(): void {
|
||||
try {
|
||||
// Check if text column is already nullable
|
||||
const tableInfo = this.db.pragma('table_info(observations)');
|
||||
const textColumn = (tableInfo as any[]).find((col: any) => col.name === 'text');
|
||||
|
||||
if (!textColumn || textColumn.notnull === 0) {
|
||||
// Already migrated or text column doesn't exist
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[HooksDatabase] Making observations.text nullable...');
|
||||
|
||||
// Begin transaction
|
||||
this.db.exec('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// Create new table with text as nullable
|
||||
this.db.exec(`
|
||||
CREATE TABLE observations_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sdk_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
narrative TEXT,
|
||||
concepts TEXT,
|
||||
files_read TEXT,
|
||||
files_modified TEXT,
|
||||
prompt_number INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Copy data from old table (all existing columns)
|
||||
this.db.exec(`
|
||||
INSERT INTO observations_new
|
||||
SELECT id, sdk_session_id, project, text, type, title, subtitle, facts,
|
||||
narrative, concepts, files_read, files_modified, prompt_number,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
`);
|
||||
|
||||
// Drop old table
|
||||
this.db.exec('DROP TABLE observations');
|
||||
|
||||
// Rename new table
|
||||
this.db.exec('ALTER TABLE observations_new RENAME TO observations');
|
||||
|
||||
// Recreate indexes
|
||||
this.db.exec(`
|
||||
CREATE INDEX idx_observations_sdk_session ON observations(sdk_session_id);
|
||||
CREATE INDEX idx_observations_project ON observations(project);
|
||||
CREATE INDEX idx_observations_type ON observations(type);
|
||||
CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC);
|
||||
`);
|
||||
|
||||
// Commit transaction
|
||||
this.db.exec('COMMIT');
|
||||
|
||||
console.error('[HooksDatabase] Successfully made observations.text nullable');
|
||||
} catch (error: any) {
|
||||
// Rollback on error
|
||||
this.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[HooksDatabase] Migration error (make text nullable):', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent session summaries for a project
|
||||
*/
|
||||
@@ -370,7 +452,12 @@ export class HooksDatabase {
|
||||
const result = stmt.run(sdkSessionId, id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
console.error(`[HooksDatabase] Skipped updating sdk_session_id for session ${id} - already set (prevents FOREIGN KEY constraint violation)`);
|
||||
// This is expected behavior - sdk_session_id is already set
|
||||
// Only log at debug level to avoid noise
|
||||
logger.debug('DB', 'sdk_session_id already set, skipping update', {
|
||||
sessionId: id,
|
||||
sdkSessionId
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { buildInitPrompt, buildObservationPrompt, buildFinalizePrompt } from '..
|
||||
import { parseObservations, parseSummary } from '../sdk/parser.js';
|
||||
import type { SDKSession } from '../sdk/prompts.js';
|
||||
import { findAvailablePort } from '../utils/port-allocator.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const MODEL = 'claude-sonnet-4-5';
|
||||
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
|
||||
@@ -42,6 +43,8 @@ interface ActiveSession {
|
||||
abortController: AbortController;
|
||||
generatorPromise: Promise<void> | null;
|
||||
lastPromptNumber: number; // Track which prompt_number we last sent to SDK
|
||||
observationCounter: number; // Counter for correlation IDs
|
||||
startTime: number; // Session start timestamp
|
||||
}
|
||||
|
||||
class WorkerService {
|
||||
@@ -79,14 +82,12 @@ class WorkerService {
|
||||
db.close();
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[WorkerService] Cleaned up ${cleanedCount} orphaned sessions`);
|
||||
logger.info('SYSTEM', `Cleaned up ${cleanedCount} orphaned sessions`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.app.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[WorkerService] Started on http://127.0.0.1:${port}`);
|
||||
console.log(`[WorkerService] PID: ${process.pid}`);
|
||||
console.log(`[WorkerService] Active sessions: ${this.sessions.size}`);
|
||||
logger.info('SYSTEM', `Worker started`, { port, pid: process.pid, activeSessions: this.sessions.size });
|
||||
|
||||
// Write port to file for hooks to discover
|
||||
const { writeFileSync } = require('fs');
|
||||
@@ -122,7 +123,8 @@ class WorkerService {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
const { project, userPrompt } = req.body;
|
||||
|
||||
console.log(`[WorkerService] Initializing session ${sessionDbId}`, { project });
|
||||
const correlationId = logger.sessionId(sessionDbId);
|
||||
logger.info('WORKER', 'Session init', { correlationId, project });
|
||||
|
||||
if (this.sessions.has(sessionDbId)) {
|
||||
res.status(409).json({ error: 'Session already exists' });
|
||||
@@ -138,7 +140,9 @@ class WorkerService {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 0
|
||||
lastPromptNumber: 0,
|
||||
observationCounter: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
this.sessions.set(sessionDbId, session);
|
||||
@@ -150,13 +154,14 @@ class WorkerService {
|
||||
|
||||
// Start SDK agent in background
|
||||
session.generatorPromise = this.runSDKAgent(session).catch(err => {
|
||||
console.error(`[WorkerService] SDK agent error for session ${sessionDbId}:`, err);
|
||||
logger.failure('WORKER', 'SDK agent error', { sessionId: sessionDbId }, err);
|
||||
const db = new HooksDatabase();
|
||||
db.markSessionFailed(sessionDbId);
|
||||
db.close();
|
||||
this.sessions.delete(sessionDbId);
|
||||
});
|
||||
|
||||
logger.success('WORKER', 'Session initialized', { sessionId: sessionDbId, port: this.port });
|
||||
res.json({
|
||||
status: 'initialized',
|
||||
sessionDbId,
|
||||
@@ -178,7 +183,15 @@ class WorkerService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WorkerService] Queueing observation for session ${sessionDbId}:`, tool_name);
|
||||
// Create correlation ID for tracking this observation
|
||||
session.observationCounter++;
|
||||
const correlationId = logger.correlationId(sessionDbId, session.observationCounter);
|
||||
const toolStr = logger.formatTool(tool_name, tool_input);
|
||||
|
||||
logger.dataIn('WORKER', `Observation queued: ${toolStr}`, {
|
||||
correlationId,
|
||||
queue: session.pendingMessages.length + 1
|
||||
});
|
||||
|
||||
session.pendingMessages.push({
|
||||
type: 'observation',
|
||||
@@ -205,7 +218,11 @@ class WorkerService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WorkerService] Requesting summary for session ${sessionDbId}, prompt #${prompt_number}`);
|
||||
logger.dataIn('WORKER', 'Summary requested', {
|
||||
sessionId: sessionDbId,
|
||||
promptNumber: prompt_number,
|
||||
queue: session.pendingMessages.length + 1
|
||||
});
|
||||
|
||||
session.pendingMessages.push({
|
||||
type: 'summarize',
|
||||
@@ -247,7 +264,7 @@ class WorkerService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[WorkerService] Deleting session ${sessionDbId}`);
|
||||
logger.warn('WORKER', 'Session delete requested', { sessionId: sessionDbId });
|
||||
|
||||
// Abort SDK agent
|
||||
session.abortController.abort();
|
||||
@@ -267,6 +284,7 @@ class WorkerService {
|
||||
|
||||
this.sessions.delete(sessionDbId);
|
||||
|
||||
logger.info('WORKER', 'Session deleted', { sessionId: sessionDbId });
|
||||
res.json({ status: 'deleted' });
|
||||
}
|
||||
|
||||
@@ -274,7 +292,7 @@ class WorkerService {
|
||||
* Run SDK agent for a session
|
||||
*/
|
||||
private async runSDKAgent(session: ActiveSession): Promise<void> {
|
||||
console.log(`[WorkerService] Starting SDK agent for session ${session.sessionDbId}`);
|
||||
logger.info('SDK', 'Agent starting', { sessionId: session.sessionDbId });
|
||||
|
||||
const claudePath = process.env.CLAUDE_CODE_PATH || '/Users/alexnewman/.nvm/versions/node/v24.5.0/bin/claude';
|
||||
|
||||
@@ -300,7 +318,10 @@ class WorkerService {
|
||||
db.close();
|
||||
|
||||
if (updated) {
|
||||
console.log(`[WorkerService] SDK session initialized:`, systemMsg.session_id);
|
||||
logger.success('SDK', 'Session initialized', {
|
||||
sessionId: session.sessionDbId,
|
||||
sdkSessionId: systemMsg.session_id
|
||||
});
|
||||
session.sdkSessionId = systemMsg.session_id;
|
||||
}
|
||||
}
|
||||
@@ -312,7 +333,14 @@ class WorkerService {
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
console.log(`[WorkerService] SDK response for prompt #${session.lastPromptNumber}:\n${textContent}`);
|
||||
const responseSize = textContent.length;
|
||||
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber: session.lastPromptNumber
|
||||
});
|
||||
|
||||
// In debug mode, log the full response
|
||||
logger.debug('SDK', 'Full response', { sessionId: session.sessionDbId }, textContent);
|
||||
|
||||
// Parse and store with prompt number
|
||||
this.handleAgentMessage(session, textContent, session.lastPromptNumber);
|
||||
@@ -320,7 +348,12 @@ class WorkerService {
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
console.log(`[WorkerService] SDK agent completed for session ${session.sessionDbId}`);
|
||||
const sessionDuration = Date.now() - session.startTime;
|
||||
logger.success('SDK', 'Agent completed', {
|
||||
sessionId: session.sessionDbId,
|
||||
duration: `${(sessionDuration / 1000).toFixed(1)}s`
|
||||
});
|
||||
|
||||
const db = new HooksDatabase();
|
||||
db.markSessionCompleted(session.sessionDbId);
|
||||
db.close();
|
||||
@@ -329,9 +362,9 @@ class WorkerService {
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error(`[WorkerService] SDK agent aborted for session ${session.sessionDbId}`);
|
||||
logger.warn('SDK', 'Agent aborted', { sessionId: session.sessionDbId });
|
||||
} else {
|
||||
console.error(`[WorkerService] SDK agent error for session ${session.sessionDbId}:`, error);
|
||||
logger.failure('SDK', 'Agent error', { sessionId: session.sessionDbId }, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -345,7 +378,11 @@ class WorkerService {
|
||||
const claudeSessionId = `session-${session.sessionDbId}`;
|
||||
const initPrompt = buildInitPrompt(session.project, claudeSessionId, session.userPrompt);
|
||||
|
||||
console.log(`[WorkerService] Yielding init prompt:\n${initPrompt}`);
|
||||
logger.dataIn('SDK', `Init prompt sent (${initPrompt.length} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
logger.debug('SDK', 'Full init prompt', { sessionId: session.sessionDbId }, initPrompt);
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
@@ -372,7 +409,6 @@ class WorkerService {
|
||||
const message = session.pendingMessages.shift()!;
|
||||
|
||||
if (message.type === 'summarize') {
|
||||
console.log(`[WorkerService] Processing SUMMARIZE for session ${session.sessionDbId}, prompt #${message.prompt_number}`);
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
|
||||
const db = new HooksDatabase();
|
||||
@@ -382,7 +418,11 @@ class WorkerService {
|
||||
if (dbSession) {
|
||||
const summarizePrompt = buildFinalizePrompt(dbSession);
|
||||
|
||||
console.log(`[WorkerService] Yielding summarize prompt:\n${summarizePrompt}`);
|
||||
logger.dataIn('SDK', `Summary prompt sent (${summarizePrompt.length} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber: message.prompt_number
|
||||
});
|
||||
logger.debug('SDK', 'Full summary prompt', { sessionId: session.sessionDbId }, summarizePrompt);
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
@@ -405,7 +445,15 @@ class WorkerService {
|
||||
created_at_epoch: Date.now()
|
||||
});
|
||||
|
||||
console.log(`[WorkerService] Yielding observation (prompt #${message.prompt_number}):\n${observationPrompt}`);
|
||||
const toolStr = logger.formatTool(message.tool_name, message.tool_input);
|
||||
const correlationId = logger.correlationId(session.sessionDbId, session.observationCounter);
|
||||
|
||||
logger.dataIn('SDK', `Observation prompt: ${toolStr}`, {
|
||||
correlationId,
|
||||
promptNumber: message.prompt_number,
|
||||
size: `${observationPrompt.length} chars`
|
||||
});
|
||||
logger.debug('SDK', 'Full observation prompt', { correlationId }, observationPrompt);
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
@@ -426,23 +474,41 @@ class WorkerService {
|
||||
* Gets prompt_number from the message that triggered this response
|
||||
*/
|
||||
private handleAgentMessage(session: ActiveSession, content: string, promptNumber: number): void {
|
||||
const correlationId = logger.correlationId(session.sessionDbId, session.observationCounter);
|
||||
|
||||
// Parse observations
|
||||
const observations = parseObservations(content);
|
||||
console.log(`[WorkerService] Parsed ${observations.length} observations for prompt #${promptNumber}`);
|
||||
const observations = parseObservations(content, correlationId);
|
||||
|
||||
if (observations.length > 0) {
|
||||
logger.info('PARSER', `Parsed ${observations.length} observation(s)`, {
|
||||
correlationId,
|
||||
promptNumber,
|
||||
types: observations.map(o => o.type).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
const db = new HooksDatabase();
|
||||
for (const obs of observations) {
|
||||
if (session.sdkSessionId) {
|
||||
db.storeObservation(session.sdkSessionId, session.project, obs, promptNumber);
|
||||
logger.success('DB', 'Observation stored', {
|
||||
correlationId,
|
||||
type: obs.type,
|
||||
title: obs.title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse summary
|
||||
const summary = parseSummary(content);
|
||||
const summary = parseSummary(content, session.sessionDbId);
|
||||
if (summary && session.sdkSessionId) {
|
||||
console.log(`[WorkerService] Parsed summary for session ${session.sessionDbId}, prompt #${promptNumber}`);
|
||||
logger.info('PARSER', 'Summary parsed', {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber
|
||||
});
|
||||
|
||||
db.storeSummary(session.sdkSessionId, session.project, summary, promptNumber);
|
||||
logger.success('DB', 'Summary stored', { sessionId: session.sessionDbId });
|
||||
}
|
||||
|
||||
db.close();
|
||||
@@ -456,19 +522,19 @@ async function main() {
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.error('[WorkerService] Shutting down gracefully...');
|
||||
logger.warn('SYSTEM', 'Shutting down (SIGINT)');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.error('[WorkerService] Shutting down gracefully...');
|
||||
logger.warn('SYSTEM', 'Shutting down (SIGTERM)');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-start when run directly (not when imported)
|
||||
main().catch(err => {
|
||||
console.error('[WorkerService] Fatal error:', err);
|
||||
logger.failure('SYSTEM', 'Fatal startup error', {}, err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Structured Logger for claude-mem Worker Service
|
||||
* Provides readable, traceable logging with correlation IDs and data flow tracking
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
SILENT = 4
|
||||
}
|
||||
|
||||
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM';
|
||||
|
||||
interface LogContext {
|
||||
sessionId?: number;
|
||||
sdkSessionId?: string;
|
||||
correlationId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private level: LogLevel;
|
||||
private useColor: boolean;
|
||||
|
||||
constructor() {
|
||||
// Parse log level from environment
|
||||
const envLevel = process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase() || 'INFO';
|
||||
this.level = LogLevel[envLevel as keyof typeof LogLevel] ?? LogLevel.INFO;
|
||||
|
||||
// Disable colors when output is not a TTY (e.g., PM2 logs)
|
||||
this.useColor = process.stdout.isTTY ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create correlation ID for tracking an observation through the pipeline
|
||||
*/
|
||||
static correlationId(sessionId: number, observationNum: number): string {
|
||||
return `obs-${sessionId}-${observationNum}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session correlation ID
|
||||
*/
|
||||
static 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.level === 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;
|
||||
|
||||
try {
|
||||
const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
|
||||
|
||||
// Special formatting for common tools
|
||||
if (toolName === 'Bash' && input.command) {
|
||||
const cmd = input.command.length > 50
|
||||
? input.command.substring(0, 50) + '...'
|
||||
: input.command;
|
||||
return `${toolName}(${cmd})`;
|
||||
}
|
||||
|
||||
if (toolName === 'Read' && input.file_path) {
|
||||
const path = input.file_path.split('/').pop() || input.file_path;
|
||||
return `${toolName}(${path})`;
|
||||
}
|
||||
|
||||
if (toolName === 'Edit' && input.file_path) {
|
||||
const path = input.file_path.split('/').pop() || input.file_path;
|
||||
return `${toolName}(${path})`;
|
||||
}
|
||||
|
||||
if (toolName === 'Write' && input.file_path) {
|
||||
const path = input.file_path.split('/').pop() || input.file_path;
|
||||
return `${toolName}(${path})`;
|
||||
}
|
||||
|
||||
// Default: just show tool name
|
||||
return toolName;
|
||||
} catch {
|
||||
return toolName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging method
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
component: Component,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
data?: any
|
||||
): void {
|
||||
if (level < this.level) return;
|
||||
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
||||
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) {
|
||||
if (this.level === 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 appropriate stream
|
||||
if (level === LogLevel.ERROR) {
|
||||
console.error(logLine);
|
||||
} else {
|
||||
console.log(logLine);
|
||||
}
|
||||
}
|
||||
|
||||
// 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` });
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const logger = new Logger();
|
||||
Reference in New Issue
Block a user