/** * webSearchLogger.ts * Centralized structured logger for the web search pipeline on the client side. * Outputs consistently formatted, emoji-tagged logs to the React Native / Metro console. */ type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'; const ICONS: Record = { INFO: 'ℹ️ ', WARN: '⚠️ ', ERROR: '❌', DEBUG: '🔍', }; function formatMeta(meta?: Record): string { if (!meta) return ''; try { return '\n ' + JSON.stringify(meta, null, 2).replace(/\n/g, '\n '); } catch { return ''; } } function log(level: LogLevel, phase: string, message: string, meta?: Record) { const ts = new Date().toISOString(); const metaStr = formatMeta(meta); const prefix = `[${ts}] ${ICONS[level]} [WebSearch/${phase}]`; const fullMsg = `${prefix} ${message}${metaStr}`; switch (level) { case 'ERROR': console.error(fullMsg); break; case 'WARN': console.warn(fullMsg); break; default: console.log(fullMsg); } } function logSection(title: string) { const border = '─'.repeat(55); console.log(`\n${border}`); console.log(` ${title}`); console.log(`${border}`); } /** Rough tokens estimate (~4 chars per token) */ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } // ─── Public API ──────────────────────────────────────────────────────────────── export const WebSearchLogger = { pipelineStart(query: string, searchEndpoint: string) { logSection(`🚀 Web Search Pipeline Started`); log('INFO', 'Init', `New search initiated`, { query, searchEndpoint, timestamp: new Date().toISOString(), }); }, endpointResolved(host: string, port: number, searchEndpoint: string) { log('INFO', 'Endpoint', `Search service URL resolved`, { lmStudioHost: host, lmStudioPort: port, searchServiceEndpoint: searchEndpoint, }); }, requestSent(query: string, maxContextLength: number, timeoutMs = 30000) { log('INFO', 'Request', `Sending search request to local service`, { query, maxContextLength, estimatedTokenBudget: estimateTokens('x'.repeat(maxContextLength)), timeoutMs, }); }, requestFailed(status: number, statusText: string) { log('ERROR', 'Request', `Search service returned error`, { httpStatus: status, statusText, }); }, serviceUnreachable(error: unknown, isTimeout = false) { const err = error instanceof Error ? error : new Error(String(error)); const isConnectionRefused = err.message.includes('Network request failed') || err.message.includes('ECONNREFUSED') || err.message.includes('Connection refused'); const message = isTimeout ? `Search service timeout (30s) — service may be overloaded or unreachable` : isConnectionRefused ? `Search service is NOT running — connection refused` : `Cannot reach local search service`; log('ERROR', 'Request', message, { error: err.message, isTimeout, isConnectionRefused, diagnosis: isConnectionRefused ? 'The local-search-service Node.js process is not running on this machine' : isTimeout ? 'Service is running but not responding within 30s (SearXNG may be slow)' : 'Unknown network error', hint: 'Run: cd local-search-service && npx ts-node server.ts', }); }, contextReceived( context: string, sources: Array<{ title: string; url: string }>, meta?: { requestId?: string; totalLatencyMs?: number; finalContextChars?: number; finalContextEstimatedTokens?: number; sourcesKept?: number; sourcesDropped?: number; pruned?: boolean; }, ) { logSection(`✅ Context Received from Search Service`); log('INFO', 'Context', `Web search context ready`, { contextChars: context.length, estimatedTokens: estimateTokens(context), sourcesReceived: sources.length, sourceTitles: sources.map((s) => s.title.substring(0, 60)), sourceUrls: sources.map((s) => s.url), ...(meta ?? {}), }); }, contextAnalysis(systemPromptChars: number, historyChars: number, searchContextChars: number) { logSection(`🧠 Context Window Analysis`); const totalChars = systemPromptChars + historyChars + searchContextChars; const totalTokens = estimateTokens('x'.repeat(totalChars)); log('INFO', 'Analysis', `Total context before sending to LLM`, { systemPromptChars, systemPromptTokens: estimateTokens('x'.repeat(systemPromptChars)), chatHistoryChars: historyChars, chatHistoryTokens: estimateTokens('x'.repeat(historyChars)), searchContextChars, searchContextTokens: estimateTokens('x'.repeat(searchContextChars)), totalChars, estimatedTotalTokens: totalTokens, note: 'Token estimates are approximate (~4 chars/token). Actual tokenization varies by model.', }); }, llmRequestStart(model: string, messageCount: number) { logSection(`🤖 LLM Request`); log('INFO', 'LLM', `Sending messages to LM Studio`, { model, totalMessages: messageCount, streamingEnabled: true, }); }, llmStreamComplete(assistantMessageId: string, chunkCount?: number) { log('INFO', 'LLM', `Stream complete — response generated`, { assistantMessageId, ...(chunkCount !== undefined ? { chunkCount } : {}), }); }, llmError(error: Error) { log('ERROR', 'LLM', `LLM stream error`, { error: error.message, stack: error.stack?.split('\n').slice(0, 3).join(' | '), }); }, pipelineComplete(query: string, totalLatencyMs: number, withSearch: boolean) { logSection(`🏁 Pipeline Complete`); log('INFO', 'Done', `Full request cycle finished`, { query, totalLatencyMs, webSearchUsed: withSearch, }); console.log('─'.repeat(55) + '\n'); }, };