/ src / utils / errorLogSink.ts
errorLogSink.ts
  1  /**
  2   * Error log sink implementation
  3   *
  4   * This module contains the heavy implementation for error logging and should be
  5   * initialized during app startup. It handles file-based error logging to disk.
  6   *
  7   * Usage: Call initializeErrorLogSink() during app startup to attach the sink.
  8   *
  9   * DESIGN: This module is separate from log.ts to avoid import cycles.
 10   * log.ts has NO heavy dependencies - events are queued until this sink is attached.
 11   */
 12  
 13  import axios from 'axios'
 14  import { dirname, join } from 'path'
 15  import { getSessionId } from '../bootstrap/state.js'
 16  import { createBufferedWriter } from './bufferedWriter.js'
 17  import { CACHE_PATHS } from './cachePaths.js'
 18  import { registerCleanup } from './cleanupRegistry.js'
 19  import { logForDebugging } from './debug.js'
 20  import { getFsImplementation } from './fsOperations.js'
 21  import { attachErrorLogSink, dateToFilename } from './log.js'
 22  import { jsonStringify } from './slowOperations.js'
 23  
 24  const DATE = dateToFilename(new Date())
 25  
 26  /**
 27   * Gets the path to the errors log file.
 28   */
 29  export function getErrorsPath(): string {
 30    return join(CACHE_PATHS.errors(), DATE + '.jsonl')
 31  }
 32  
 33  /**
 34   * Gets the path to MCP logs for a server.
 35   */
 36  export function getMCPLogsPath(serverName: string): string {
 37    return join(CACHE_PATHS.mcpLogs(serverName), DATE + '.jsonl')
 38  }
 39  
 40  type JsonlWriter = {
 41    write: (obj: object) => void
 42    flush: () => void
 43    dispose: () => void
 44  }
 45  
 46  function createJsonlWriter(options: {
 47    writeFn: (content: string) => void
 48    flushIntervalMs?: number
 49    maxBufferSize?: number
 50  }): JsonlWriter {
 51    const writer = createBufferedWriter(options)
 52    return {
 53      write(obj: object): void {
 54        writer.write(jsonStringify(obj) + '\n')
 55      },
 56      flush: writer.flush,
 57      dispose: writer.dispose,
 58    }
 59  }
 60  
 61  // Buffered writers for JSONL log files, keyed by path
 62  const logWriters = new Map<string, JsonlWriter>()
 63  
 64  /**
 65   * Flush all buffered log writers. Used for testing.
 66   * @internal
 67   */
 68  export function _flushLogWritersForTesting(): void {
 69    for (const writer of logWriters.values()) {
 70      writer.flush()
 71    }
 72  }
 73  
 74  /**
 75   * Clear all buffered log writers. Used for testing.
 76   * @internal
 77   */
 78  export function _clearLogWritersForTesting(): void {
 79    for (const writer of logWriters.values()) {
 80      writer.dispose()
 81    }
 82    logWriters.clear()
 83  }
 84  
 85  function getLogWriter(path: string): JsonlWriter {
 86    let writer = logWriters.get(path)
 87    if (!writer) {
 88      const dir = dirname(path)
 89      writer = createJsonlWriter({
 90        // sync IO: called from sync context
 91        writeFn: (content: string) => {
 92          try {
 93            // Happy-path: directory already exists
 94            getFsImplementation().appendFileSync(path, content)
 95          } catch {
 96            // If any error occurs, assume it was due to missing directory
 97            getFsImplementation().mkdirSync(dir)
 98            // Retry appending
 99            getFsImplementation().appendFileSync(path, content)
100          }
101        },
102        flushIntervalMs: 1000,
103        maxBufferSize: 50,
104      })
105      logWriters.set(path, writer)
106      registerCleanup(async () => writer?.dispose())
107    }
108    return writer
109  }
110  
111  function appendToLog(path: string, message: object): void {
112    if (process.env.USER_TYPE !== 'ant') {
113      return
114    }
115  
116    const messageWithTimestamp = {
117      timestamp: new Date().toISOString(),
118      ...message,
119      cwd: getFsImplementation().cwd(),
120      userType: process.env.USER_TYPE,
121      sessionId: getSessionId(),
122      version: MACRO.VERSION,
123    }
124  
125    getLogWriter(path).write(messageWithTimestamp)
126  }
127  
128  function extractServerMessage(data: unknown): string | undefined {
129    if (typeof data === 'string') {
130      return data
131    }
132    if (data && typeof data === 'object') {
133      const obj = data as Record<string, unknown>
134      if (typeof obj.message === 'string') {
135        return obj.message
136      }
137      if (
138        typeof obj.error === 'object' &&
139        obj.error &&
140        'message' in obj.error &&
141        typeof (obj.error as Record<string, unknown>).message === 'string'
142      ) {
143        return (obj.error as Record<string, unknown>).message as string
144      }
145    }
146    return undefined
147  }
148  
149  /**
150   * Implementation for logError - writes error to debug log and file.
151   */
152  function logErrorImpl(error: Error): void {
153    const errorStr = error.stack || error.message
154  
155    // Enrich axios errors with request URL, status, and server message for debugging
156    let context = ''
157    if (axios.isAxiosError(error) && error.config?.url) {
158      const parts = [`url=${error.config.url}`]
159      if (error.response?.status !== undefined) {
160        parts.push(`status=${error.response.status}`)
161      }
162      const serverMessage = extractServerMessage(error.response?.data)
163      if (serverMessage) {
164        parts.push(`body=${serverMessage}`)
165      }
166      context = `[${parts.join(',')}] `
167    }
168  
169    logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
170  
171    appendToLog(getErrorsPath(), {
172      error: `${context}${errorStr}`,
173    })
174  }
175  
176  /**
177   * Implementation for logMCPError - writes MCP error to debug log and file.
178   */
179  function logMCPErrorImpl(serverName: string, error: unknown): void {
180    // Not themed, to avoid having to pipe theme all the way down
181    logForDebugging(`MCP server "${serverName}" ${error}`, { level: 'error' })
182  
183    const logFile = getMCPLogsPath(serverName)
184    const errorStr =
185      error instanceof Error ? error.stack || error.message : String(error)
186  
187    const errorInfo = {
188      error: errorStr,
189      timestamp: new Date().toISOString(),
190      sessionId: getSessionId(),
191      cwd: getFsImplementation().cwd(),
192    }
193  
194    getLogWriter(logFile).write(errorInfo)
195  }
196  
197  /**
198   * Implementation for logMCPDebug - writes MCP debug message to log file.
199   */
200  function logMCPDebugImpl(serverName: string, message: string): void {
201    logForDebugging(`MCP server "${serverName}": ${message}`)
202  
203    const logFile = getMCPLogsPath(serverName)
204  
205    const debugInfo = {
206      debug: message,
207      timestamp: new Date().toISOString(),
208      sessionId: getSessionId(),
209      cwd: getFsImplementation().cwd(),
210    }
211  
212    getLogWriter(logFile).write(debugInfo)
213  }
214  
215  /**
216   * Initialize the error log sink.
217   *
218   * Call this during app startup to attach the error logging backend.
219   * Any errors logged before this is called will be queued and drained.
220   *
221   * Should be called BEFORE initializeAnalyticsSink() in the startup sequence.
222   *
223   * Idempotent: safe to call multiple times (subsequent calls are no-ops).
224   */
225  export function initializeErrorLogSink(): void {
226    attachErrorLogSink({
227      logError: logErrorImpl,
228      logMCPError: logMCPErrorImpl,
229      logMCPDebug: logMCPDebugImpl,
230      getErrorsPath,
231      getMCPLogsPath,
232    })
233  
234    logForDebugging('Error log sink initialized')
235  }