236 lines
6.4 KiB
TypeScript
236 lines
6.4 KiB
TypeScript
/**
|
|
* Error log sink implementation
|
|
*
|
|
* This module contains the heavy implementation for error logging and should be
|
|
* initialized during app startup. It handles file-based error logging to disk.
|
|
*
|
|
* Usage: Call initializeErrorLogSink() during app startup to attach the sink.
|
|
*
|
|
* DESIGN: This module is separate from log.ts to avoid import cycles.
|
|
* log.ts has NO heavy dependencies - events are queued until this sink is attached.
|
|
*/
|
|
|
|
import axios from 'axios'
|
|
import { dirname, join } from 'path'
|
|
import { getSessionId } from '../bootstrap/state.js'
|
|
import { createBufferedWriter } from './bufferedWriter.js'
|
|
import { CACHE_PATHS } from './cachePaths.js'
|
|
import { registerCleanup } from './cleanupRegistry.js'
|
|
import { logForDebugging } from './debug.js'
|
|
import { getFsImplementation } from './fsOperations.js'
|
|
import { attachErrorLogSink, dateToFilename } from './log.js'
|
|
import { jsonStringify } from './slowOperations.js'
|
|
|
|
const DATE = dateToFilename(new Date())
|
|
|
|
/**
|
|
* Gets the path to the errors log file.
|
|
*/
|
|
export function getErrorsPath(): string {
|
|
return join(CACHE_PATHS.errors(), DATE + '.jsonl')
|
|
}
|
|
|
|
/**
|
|
* Gets the path to MCP logs for a server.
|
|
*/
|
|
export function getMCPLogsPath(serverName: string): string {
|
|
return join(CACHE_PATHS.mcpLogs(serverName), DATE + '.jsonl')
|
|
}
|
|
|
|
type JsonlWriter = {
|
|
write: (obj: object) => void
|
|
flush: () => void
|
|
dispose: () => void
|
|
}
|
|
|
|
function createJsonlWriter(options: {
|
|
writeFn: (content: string) => void
|
|
flushIntervalMs?: number
|
|
maxBufferSize?: number
|
|
}): JsonlWriter {
|
|
const writer = createBufferedWriter(options)
|
|
return {
|
|
write(obj: object): void {
|
|
writer.write(jsonStringify(obj) + '\n')
|
|
},
|
|
flush: writer.flush,
|
|
dispose: writer.dispose,
|
|
}
|
|
}
|
|
|
|
// Buffered writers for JSONL log files, keyed by path
|
|
const logWriters = new Map<string, JsonlWriter>()
|
|
|
|
/**
|
|
* Flush all buffered log writers. Used for testing.
|
|
* @internal
|
|
*/
|
|
export function _flushLogWritersForTesting(): void {
|
|
for (const writer of logWriters.values()) {
|
|
writer.flush()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all buffered log writers. Used for testing.
|
|
* @internal
|
|
*/
|
|
export function _clearLogWritersForTesting(): void {
|
|
for (const writer of logWriters.values()) {
|
|
writer.dispose()
|
|
}
|
|
logWriters.clear()
|
|
}
|
|
|
|
function getLogWriter(path: string): JsonlWriter {
|
|
let writer = logWriters.get(path)
|
|
if (!writer) {
|
|
const dir = dirname(path)
|
|
writer = createJsonlWriter({
|
|
// sync IO: called from sync context
|
|
writeFn: (content: string) => {
|
|
try {
|
|
// Happy-path: directory already exists
|
|
getFsImplementation().appendFileSync(path, content)
|
|
} catch {
|
|
// If any error occurs, assume it was due to missing directory
|
|
getFsImplementation().mkdirSync(dir)
|
|
// Retry appending
|
|
getFsImplementation().appendFileSync(path, content)
|
|
}
|
|
},
|
|
flushIntervalMs: 1000,
|
|
maxBufferSize: 50,
|
|
})
|
|
logWriters.set(path, writer)
|
|
registerCleanup(async () => writer?.dispose())
|
|
}
|
|
return writer
|
|
}
|
|
|
|
function appendToLog(path: string, message: object): void {
|
|
if (process.env.USER_TYPE !== 'ant') {
|
|
return
|
|
}
|
|
|
|
const messageWithTimestamp = {
|
|
timestamp: new Date().toISOString(),
|
|
...message,
|
|
cwd: getFsImplementation().cwd(),
|
|
userType: process.env.USER_TYPE,
|
|
sessionId: getSessionId(),
|
|
version: MACRO.VERSION,
|
|
}
|
|
|
|
getLogWriter(path).write(messageWithTimestamp)
|
|
}
|
|
|
|
function extractServerMessage(data: unknown): string | undefined {
|
|
if (typeof data === 'string') {
|
|
return data
|
|
}
|
|
if (data && typeof data === 'object') {
|
|
const obj = data as Record<string, unknown>
|
|
if (typeof obj.message === 'string') {
|
|
return obj.message
|
|
}
|
|
if (
|
|
typeof obj.error === 'object' &&
|
|
obj.error &&
|
|
'message' in obj.error &&
|
|
typeof (obj.error as Record<string, unknown>).message === 'string'
|
|
) {
|
|
return (obj.error as Record<string, unknown>).message as string
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Implementation for logError - writes error to debug log and file.
|
|
*/
|
|
function logErrorImpl(error: Error): void {
|
|
const errorStr = error.stack || error.message
|
|
|
|
// Enrich axios errors with request URL, status, and server message for debugging
|
|
let context = ''
|
|
if (axios.isAxiosError(error) && error.config?.url) {
|
|
const parts = [`url=${error.config.url}`]
|
|
if (error.response?.status !== undefined) {
|
|
parts.push(`status=${error.response.status}`)
|
|
}
|
|
const serverMessage = extractServerMessage(error.response?.data)
|
|
if (serverMessage) {
|
|
parts.push(`body=${serverMessage}`)
|
|
}
|
|
context = `[${parts.join(',')}] `
|
|
}
|
|
|
|
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
|
|
|
|
appendToLog(getErrorsPath(), {
|
|
error: `${context}${errorStr}`,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Implementation for logMCPError - writes MCP error to debug log and file.
|
|
*/
|
|
function logMCPErrorImpl(serverName: string, error: unknown): void {
|
|
// Not themed, to avoid having to pipe theme all the way down
|
|
logForDebugging(`MCP server "${serverName}" ${error}`, { level: 'error' })
|
|
|
|
const logFile = getMCPLogsPath(serverName)
|
|
const errorStr =
|
|
error instanceof Error ? error.stack || error.message : String(error)
|
|
|
|
const errorInfo = {
|
|
error: errorStr,
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: getSessionId(),
|
|
cwd: getFsImplementation().cwd(),
|
|
}
|
|
|
|
getLogWriter(logFile).write(errorInfo)
|
|
}
|
|
|
|
/**
|
|
* Implementation for logMCPDebug - writes MCP debug message to log file.
|
|
*/
|
|
function logMCPDebugImpl(serverName: string, message: string): void {
|
|
logForDebugging(`MCP server "${serverName}": ${message}`)
|
|
|
|
const logFile = getMCPLogsPath(serverName)
|
|
|
|
const debugInfo = {
|
|
debug: message,
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: getSessionId(),
|
|
cwd: getFsImplementation().cwd(),
|
|
}
|
|
|
|
getLogWriter(logFile).write(debugInfo)
|
|
}
|
|
|
|
/**
|
|
* Initialize the error log sink.
|
|
*
|
|
* Call this during app startup to attach the error logging backend.
|
|
* Any errors logged before this is called will be queued and drained.
|
|
*
|
|
* Should be called BEFORE initializeAnalyticsSink() in the startup sequence.
|
|
*
|
|
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
|
|
*/
|
|
export function initializeErrorLogSink(): void {
|
|
attachErrorLogSink({
|
|
logError: logErrorImpl,
|
|
logMCPError: logMCPErrorImpl,
|
|
logMCPDebug: logMCPDebugImpl,
|
|
getErrorsPath,
|
|
getMCPLogsPath,
|
|
})
|
|
|
|
logForDebugging('Error log sink initialized')
|
|
}
|