|
|
const winston = require('winston') |
|
|
const DailyRotateFile = require('winston-daily-rotate-file') |
|
|
const config = require('../../config/config') |
|
|
const { formatDateWithTimezone } = require('../utils/dateHelper') |
|
|
const path = require('path') |
|
|
const fs = require('fs') |
|
|
const os = require('os') |
|
|
|
|
|
|
|
|
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { |
|
|
const seen = new WeakSet() |
|
|
|
|
|
const actualMaxDepth = fullDepth ? 10 : maxDepth |
|
|
|
|
|
const replacer = (key, value, depth = 0) => { |
|
|
if (depth > actualMaxDepth) { |
|
|
return '[Max Depth Reached]' |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof value === 'string') { |
|
|
try { |
|
|
|
|
|
let cleanValue = value |
|
|
|
|
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') |
|
|
.replace(/[\uD800-\uDFFF]/g, '') |
|
|
|
|
|
.replace(/\u0000/g, '') |
|
|
|
|
|
|
|
|
if (cleanValue.length > 1000) { |
|
|
cleanValue = `${cleanValue.substring(0, 997)}...` |
|
|
} |
|
|
|
|
|
return cleanValue |
|
|
} catch (error) { |
|
|
return '[Invalid String Data]' |
|
|
} |
|
|
} |
|
|
|
|
|
if (value !== null && typeof value === 'object') { |
|
|
if (seen.has(value)) { |
|
|
return '[Circular Reference]' |
|
|
} |
|
|
seen.add(value) |
|
|
|
|
|
|
|
|
if (value.constructor) { |
|
|
const constructorName = value.constructor.name |
|
|
if ( |
|
|
['Socket', 'TLSSocket', 'HTTPParser', 'IncomingMessage', 'ServerResponse'].includes( |
|
|
constructorName |
|
|
) |
|
|
) { |
|
|
return `[${constructorName} Object]` |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (Array.isArray(value)) { |
|
|
return value.map((item, index) => replacer(index, item, depth + 1)) |
|
|
} else { |
|
|
const result = {} |
|
|
for (const [k, v] of Object.entries(value)) { |
|
|
|
|
|
|
|
|
const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k |
|
|
result[safeKey] = replacer(safeKey, v, depth + 1) |
|
|
} |
|
|
return result |
|
|
} |
|
|
} |
|
|
|
|
|
return value |
|
|
} |
|
|
|
|
|
try { |
|
|
const processed = replacer('', obj) |
|
|
return JSON.stringify(processed) |
|
|
} catch (error) { |
|
|
|
|
|
try { |
|
|
return JSON.stringify({ |
|
|
error: 'Failed to serialize object', |
|
|
message: error.message, |
|
|
type: typeof obj, |
|
|
keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined |
|
|
}) |
|
|
} catch (finalError) { |
|
|
return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}' |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const createLogFormat = (colorize = false) => { |
|
|
const formats = [ |
|
|
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), |
|
|
winston.format.errors({ stack: true }) |
|
|
|
|
|
] |
|
|
|
|
|
if (colorize) { |
|
|
formats.push(winston.format.colorize()) |
|
|
} |
|
|
|
|
|
formats.push( |
|
|
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => { |
|
|
const emoji = { |
|
|
error: '❌', |
|
|
warn: '⚠️ ', |
|
|
info: 'ℹ️ ', |
|
|
debug: '🐛', |
|
|
verbose: '📝' |
|
|
} |
|
|
|
|
|
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` |
|
|
|
|
|
|
|
|
const additionalData = { ...rest } |
|
|
delete additionalData.level |
|
|
delete additionalData.message |
|
|
delete additionalData.timestamp |
|
|
delete additionalData.stack |
|
|
|
|
|
if (Object.keys(additionalData).length > 0) { |
|
|
logMessage += ` | ${safeStringify(additionalData)}` |
|
|
} |
|
|
|
|
|
return stack ? `${logMessage}\n${stack}` : logMessage |
|
|
}) |
|
|
) |
|
|
|
|
|
return winston.format.combine(...formats) |
|
|
} |
|
|
|
|
|
const logFormat = createLogFormat(false) |
|
|
const consoleFormat = createLogFormat(true) |
|
|
|
|
|
|
|
|
if (!fs.existsSync(config.logging.dirname)) { |
|
|
fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 }) |
|
|
} |
|
|
|
|
|
|
|
|
const createRotateTransport = (filename, level = null) => { |
|
|
const transport = new DailyRotateFile({ |
|
|
filename: path.join(config.logging.dirname, filename), |
|
|
datePattern: 'YYYY-MM-DD', |
|
|
zippedArchive: true, |
|
|
maxSize: config.logging.maxSize, |
|
|
maxFiles: config.logging.maxFiles, |
|
|
auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`), |
|
|
format: logFormat |
|
|
}) |
|
|
|
|
|
if (level) { |
|
|
transport.level = level |
|
|
} |
|
|
|
|
|
|
|
|
transport.on('rotate', (oldFilename, newFilename) => { |
|
|
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`) |
|
|
}) |
|
|
|
|
|
transport.on('new', (newFilename) => { |
|
|
console.log(`📄 New log file created: ${newFilename}`) |
|
|
}) |
|
|
|
|
|
transport.on('archive', (zipFilename) => { |
|
|
console.log(`🗜️ Log archived: ${zipFilename}`) |
|
|
}) |
|
|
|
|
|
return transport |
|
|
} |
|
|
|
|
|
const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log') |
|
|
const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error') |
|
|
|
|
|
|
|
|
const securityLogger = winston.createLogger({ |
|
|
level: 'warn', |
|
|
format: logFormat, |
|
|
transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')], |
|
|
silent: false |
|
|
}) |
|
|
|
|
|
|
|
|
const authDetailLogger = winston.createLogger({ |
|
|
level: 'info', |
|
|
format: winston.format.combine( |
|
|
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), |
|
|
winston.format.printf(({ level, message, timestamp, data }) => { |
|
|
|
|
|
const jsonData = data ? JSON.stringify(data, null, 2) : '{}' |
|
|
return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}` |
|
|
}) |
|
|
), |
|
|
transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')], |
|
|
silent: false |
|
|
}) |
|
|
|
|
|
|
|
|
const logger = winston.createLogger({ |
|
|
level: process.env.LOG_LEVEL || config.logging.level, |
|
|
format: logFormat, |
|
|
transports: [ |
|
|
|
|
|
dailyRotateFileTransport, |
|
|
errorFileTransport, |
|
|
|
|
|
|
|
|
new winston.transports.Console({ |
|
|
format: consoleFormat, |
|
|
handleExceptions: false, |
|
|
handleRejections: false |
|
|
}) |
|
|
], |
|
|
|
|
|
|
|
|
exceptionHandlers: [ |
|
|
new winston.transports.File({ |
|
|
filename: path.join(config.logging.dirname, 'exceptions.log'), |
|
|
format: logFormat, |
|
|
maxsize: 10485760, |
|
|
maxFiles: 5 |
|
|
}), |
|
|
new winston.transports.Console({ |
|
|
format: consoleFormat |
|
|
}) |
|
|
], |
|
|
|
|
|
|
|
|
rejectionHandlers: [ |
|
|
new winston.transports.File({ |
|
|
filename: path.join(config.logging.dirname, 'rejections.log'), |
|
|
format: logFormat, |
|
|
maxsize: 10485760, |
|
|
maxFiles: 5 |
|
|
}), |
|
|
new winston.transports.Console({ |
|
|
format: consoleFormat |
|
|
}) |
|
|
], |
|
|
|
|
|
|
|
|
exitOnError: false |
|
|
}) |
|
|
|
|
|
|
|
|
logger.success = (message, metadata = {}) => { |
|
|
logger.info(`✅ ${message}`, { type: 'success', ...metadata }) |
|
|
} |
|
|
|
|
|
logger.start = (message, metadata = {}) => { |
|
|
logger.info(`🚀 ${message}`, { type: 'startup', ...metadata }) |
|
|
} |
|
|
|
|
|
logger.request = (method, url, status, duration, metadata = {}) => { |
|
|
const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢' |
|
|
const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info' |
|
|
|
|
|
logger[level](`${emoji} ${method} ${url} - ${status} (${duration}ms)`, { |
|
|
type: 'request', |
|
|
method, |
|
|
url, |
|
|
status, |
|
|
duration, |
|
|
...metadata |
|
|
}) |
|
|
} |
|
|
|
|
|
logger.api = (message, metadata = {}) => { |
|
|
logger.info(`🔗 ${message}`, { type: 'api', ...metadata }) |
|
|
} |
|
|
|
|
|
logger.security = (message, metadata = {}) => { |
|
|
const securityData = { |
|
|
type: 'security', |
|
|
timestamp: new Date().toISOString(), |
|
|
pid: process.pid, |
|
|
hostname: os.hostname(), |
|
|
...metadata |
|
|
} |
|
|
|
|
|
|
|
|
logger.warn(`🔒 ${message}`, securityData) |
|
|
|
|
|
|
|
|
try { |
|
|
securityLogger.warn(`🔒 ${message}`, securityData) |
|
|
} catch (error) { |
|
|
|
|
|
console.warn('Security logger not available:', error.message) |
|
|
} |
|
|
} |
|
|
|
|
|
logger.database = (message, metadata = {}) => { |
|
|
logger.debug(`💾 ${message}`, { type: 'database', ...metadata }) |
|
|
} |
|
|
|
|
|
logger.performance = (message, metadata = {}) => { |
|
|
logger.info(`⚡ ${message}`, { type: 'performance', ...metadata }) |
|
|
} |
|
|
|
|
|
logger.audit = (message, metadata = {}) => { |
|
|
logger.info(`📋 ${message}`, { |
|
|
type: 'audit', |
|
|
timestamp: new Date().toISOString(), |
|
|
pid: process.pid, |
|
|
...metadata |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
logger.timer = (label) => { |
|
|
const start = Date.now() |
|
|
return { |
|
|
end: (message = '', metadata = {}) => { |
|
|
const duration = Date.now() - start |
|
|
logger.performance(`${label} ${message}`, { duration, ...metadata }) |
|
|
return duration |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
logger.stats = { |
|
|
requests: 0, |
|
|
errors: 0, |
|
|
warnings: 0 |
|
|
} |
|
|
|
|
|
|
|
|
const originalError = logger.error |
|
|
const originalWarn = logger.warn |
|
|
const originalInfo = logger.info |
|
|
|
|
|
logger.error = function (message, ...args) { |
|
|
logger.stats.errors++ |
|
|
return originalError.call(this, message, ...args) |
|
|
} |
|
|
|
|
|
logger.warn = function (message, ...args) { |
|
|
logger.stats.warnings++ |
|
|
return originalWarn.call(this, message, ...args) |
|
|
} |
|
|
|
|
|
logger.info = function (message, ...args) { |
|
|
|
|
|
if (args.length > 0 && typeof args[0] === 'object' && args[0].type === 'request') { |
|
|
logger.stats.requests++ |
|
|
} |
|
|
return originalInfo.call(this, message, ...args) |
|
|
} |
|
|
|
|
|
|
|
|
logger.getStats = () => ({ ...logger.stats }) |
|
|
|
|
|
|
|
|
logger.resetStats = () => { |
|
|
logger.stats.requests = 0 |
|
|
logger.stats.errors = 0 |
|
|
logger.stats.warnings = 0 |
|
|
} |
|
|
|
|
|
|
|
|
logger.healthCheck = () => { |
|
|
try { |
|
|
const testMessage = 'Logger health check' |
|
|
logger.debug(testMessage) |
|
|
return { healthy: true, timestamp: new Date().toISOString() } |
|
|
} catch (error) { |
|
|
return { healthy: false, error: error.message, timestamp: new Date().toISOString() } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
logger.authDetail = (message, data = {}) => { |
|
|
try { |
|
|
|
|
|
logger.info(`🔐 ${message}`, { |
|
|
type: 'auth-detail', |
|
|
summary: { |
|
|
hasAccessToken: !!data.access_token, |
|
|
hasRefreshToken: !!data.refresh_token, |
|
|
scopes: data.scope || data.scopes, |
|
|
organization: data.organization?.name, |
|
|
account: data.account?.email_address |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
authDetailLogger.info(message, { data }) |
|
|
} catch (error) { |
|
|
logger.error('Failed to log auth detail:', error) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
logger.start('Logger initialized', { |
|
|
level: process.env.LOG_LEVEL || config.logging.level, |
|
|
directory: config.logging.dirname, |
|
|
maxSize: config.logging.maxSize, |
|
|
maxFiles: config.logging.maxFiles, |
|
|
envOverride: process.env.LOG_LEVEL ? true : false |
|
|
}) |
|
|
|
|
|
module.exports = logger |
|
|
|