Re_Backend/src/utils/logger.ts

464 lines
12 KiB
TypeScript

import winston from 'winston';
import path from 'path';
import os from 'os';
const logDir = process.env.LOG_FILE_PATH || './logs';
const isProduction = process.env.NODE_ENV === 'production';
// ============ SENSITIVE DATA PATTERNS ============
const SENSITIVE_KEYS = [
'password', 'secret', 'token', 'key', 'apikey', 'api_key', 'api-key',
'authorization', 'auth', 'credential', 'private', 'access_token',
'refresh_token', 'jwt', 'bearer', 'session', 'cookie', 'csrf',
'vapid', 'smtp_password', 'db_password', 'redis_url', 'connection_string'
];
const SENSITIVE_PATTERN = new RegExp(
`(${SENSITIVE_KEYS.join('|')})\\s*[=:]\\s*['"]?([^'\"\\s,}\\]]+)['"]?`,
'gi'
);
/**
* Mask sensitive values in strings (API keys, passwords, tokens)
* Uses a WeakSet to prevent infinite recursion on circular objects
*/
const maskSensitiveData = (value: any, visited = new WeakSet()): any => {
if (typeof value === 'string') {
// Mask patterns like "API_KEY = abc123" or "password: secret"
let masked = value.replace(SENSITIVE_PATTERN, (match, key, val) => {
if (val && val.length > 0) {
const maskedVal = val.length > 4
? val.substring(0, 2) + '***' + val.substring(val.length - 2)
: '***';
return `${key}=${maskedVal}`;
}
return match;
});
// Mask standalone tokens/keys (long alphanumeric strings that look like secrets)
// e.g., "sk-abc123xyz789..." or "ghp_xxxx..."
masked = masked.replace(
/\b(sk-|ghp_|gho_|github_pat_|xox[baprs]-|Bearer\s+)([A-Za-z0-9_-]{20,})/gi,
(match, prefix, token) => `${prefix}${'*'.repeat(8)}...`
);
return masked;
}
if (Array.isArray(value)) {
if (visited.has(value)) return '[Circular]';
visited.add(value);
return value.map(item => maskSensitiveData(item, visited));
}
if (value && typeof value === 'object') {
// Special handling for common non-recursive objects to improve performance
if (value instanceof Date || value instanceof RegExp) {
return value;
}
if (visited.has(value)) return '[Circular]';
visited.add(value);
// Prevent deep recursion into huge circular objects like Sequelize instances, Request, Response
// If it looks like a Sequelize instance or complex object, we might want to be careful
// but visited.has() should handle it.
const masked: any = {};
for (const [k, v] of Object.entries(value)) {
const keyLower = k.toLowerCase();
// Check if key itself is sensitive
if (SENSITIVE_KEYS.some(sk => keyLower.includes(sk))) {
masked[k] = typeof v === 'string' && v.length > 0 ? '***REDACTED***' : v;
} else {
masked[k] = maskSensitiveData(v, visited);
}
}
return masked;
}
return value;
};
// ============ COMMON LABELS/METADATA ============
const appMeta = {
app: 're-workflow',
service: 'backend',
environment: process.env.NODE_ENV || 'development',
version: process.env.APP_VERSION || '1.2.0',
};
// ============ TRANSPORTS ============
const transports: winston.transport[] = [
// Local file transport - Error logs
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
tailable: true,
}),
// Local file transport - Combined logs
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
tailable: true,
}),
];
// ============ LOKI TRANSPORT (Grafana) ============
// Only enable Loki in production (or LOKI_FORCE=true); in dev, connection errors are noisy when Loki isn't running
if (process.env.LOKI_HOST && (isProduction || process.env.LOKI_FORCE === 'true')) {
try {
const LokiTransport = require('winston-loki');
const lokiTransportOptions: any = {
host: process.env.LOKI_HOST,
labels: appMeta,
json: true,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
replaceTimestamp: true,
onConnectionError: (err: Error) => {
if (isProduction) console.error('[Loki] Connection error:', err.message);
},
batching: true,
interval: 5,
};
if (process.env.LOKI_USER && process.env.LOKI_PASSWORD) {
lokiTransportOptions.basicAuth = `${process.env.LOKI_USER}:${process.env.LOKI_PASSWORD}`;
}
transports.push(new LokiTransport(lokiTransportOptions));
} catch (error) {
console.warn('[Logger] ⚠️ Failed to initialize Loki transport:', (error as Error).message);
}
}
// ============ CONSOLE TRANSPORT ============
// Enabled for all environments to ensure visibility in terminal/container logs
transports.push(
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize({ all: !isProduction }),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length && !meta.service
? ` ${JSON.stringify(meta)}`
: '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
})
);
// ============ ERROR SANITIZER ============
/**
* Sanitize error objects for logging - prevents huge Axios error dumps
*/
const sanitizeError = (error: any): object => {
// Handle Axios errors specifically
if (error?.isAxiosError || error?.name === 'AxiosError') {
return {
name: error.name,
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method,
responseData: error.response?.data,
};
}
// Handle standard errors
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
...(error as any).statusCode && { statusCode: (error as any).statusCode },
};
}
// Fallback for unknown error types
return {
message: String(error),
type: typeof error,
};
};
// Custom format to sanitize errors and mask sensitive data before logging
const sanitizeFormat = winston.format((info) => {
// Sanitize error objects
if (info.error && typeof info.error === 'object') {
info.error = sanitizeError(info.error);
}
// If message is an error object, sanitize it
if (info.message && typeof info.message === 'object' && (info.message as any).stack) {
info.error = sanitizeError(info.message);
info.message = (info.message as Error).message;
}
// Mask sensitive data in message
if (typeof info.message === 'string') {
info.message = maskSensitiveData(info.message);
}
// Mask sensitive data in all metadata
for (const key of Object.keys(info)) {
if (key !== 'level' && key !== 'timestamp' && key !== 'service') {
info[key] = maskSensitiveData(info[key]);
}
}
return info;
});
// ============ CREATE LOGGER ============
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
sanitizeFormat(),
winston.format.json()
),
defaultMeta: {
service: 're-workflow-backend',
hostname: os.hostname(),
},
transports,
});
// ============ HELPER METHODS FOR STRUCTURED LOGGING ============
/**
* Log with additional context labels (will appear in Grafana)
*/
export const logWithContext = (
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
context: {
// Domain labels
requestId?: string;
userId?: string;
priority?: 'STANDARD' | 'EXPRESS';
status?: string;
department?: string;
// API labels
endpoint?: string;
method?: string;
statusCode?: number;
duration?: number;
// Error context
errorType?: string;
error?: any;
stack?: string;
// Custom data
[key: string]: any;
}
) => {
// Sanitize error if present
const sanitizedContext = { ...context };
if (sanitizedContext.error) {
sanitizedContext.error = sanitizeError(sanitizedContext.error);
}
logger.log(level, message, sanitizedContext);
};
/**
* Log API request (use in middleware or controllers)
*/
export const logApiRequest = (
method: string,
endpoint: string,
statusCode: number,
duration: number,
userId?: string,
error?: string
) => {
const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info';
logger.log(level, `${method} ${endpoint} ${statusCode} ${duration}ms`, {
endpoint,
method,
statusCode,
duration,
userId,
...(error && { error }),
});
};
/**
* Log workflow events
*/
export const logWorkflowEvent = (
event: 'created' | 'submitted' | 'approved' | 'rejected' | 'closed' | 'paused' | 'resumed' | 'updated',
requestId: string,
details: {
priority?: string;
status?: string;
department?: string;
userId?: string;
userName?: string;
message?: string;
level?: number;
[key: string]: any;
} = {}
) => {
logger.info(`Workflow ${event}: ${requestId}`, {
workflowEvent: event,
requestId,
...details,
});
};
/**
* Log TAT/SLA events
*/
export const logTATEvent = (
event: 'approaching' | 'breached' | 'resolved' | 'warning',
requestId: string,
details: {
priority?: string;
threshold?: number;
elapsedHours?: number;
tatHours?: number;
level?: number;
[key: string]: any;
} = {}
) => {
const level = event === 'breached' ? 'error' : event === 'approaching' || event === 'warning' ? 'warn' : 'info';
logger.log(level, `TAT ${event}: ${requestId}`, {
tatEvent: event,
requestId,
...details,
});
};
/**
* Log authentication events
*/
export const logAuthEvent = (
event: 'login' | 'logout' | 'token_refresh' | 'token_exchange' | 'auth_failure' | 'sso_callback',
userId: string | undefined,
details: {
email?: string;
role?: string;
ip?: string;
userAgent?: string;
error?: any;
[key: string]: any;
} = {}
) => {
const level = event === 'auth_failure' ? 'warn' : 'info';
// Sanitize error if present
const sanitizedDetails = { ...details };
if (sanitizedDetails.error) {
sanitizedDetails.error = sanitizeError(sanitizedDetails.error);
}
logger.log(level, `Auth ${event}${userId ? `: ${userId}` : ''}`, {
authEvent: event,
userId,
...sanitizedDetails,
});
};
/**
* Log document events
*/
export const logDocumentEvent = (
event: 'uploaded' | 'downloaded' | 'deleted' | 'previewed',
documentId: string,
details: {
requestId?: string;
userId?: string;
fileName?: string;
fileType?: string;
fileSize?: number;
[key: string]: any;
} = {}
) => {
logger.info(`Document ${event}: ${documentId}`, {
documentEvent: event,
documentId,
...details,
});
};
/**
* Log notification events
*/
export const logNotificationEvent = (
event: 'sent' | 'failed' | 'queued',
details: {
type?: string;
userId?: string;
requestId?: string;
channel?: 'push' | 'email' | 'in-app';
error?: any;
[key: string]: any;
} = {}
) => {
const level = event === 'failed' ? 'error' : 'info';
// Sanitize error if present
const sanitizedDetails = { ...details };
if (sanitizedDetails.error) {
sanitizedDetails.error = sanitizeError(sanitizedDetails.error);
}
logger.log(level, `Notification ${event}`, {
notificationEvent: event,
...sanitizedDetails,
});
};
/**
* Log AI service events
*/
export const logAIEvent = (
event: 'request' | 'response' | 'error' | 'fallback',
details: {
provider?: string;
model?: string;
requestId?: string;
duration?: number;
error?: any;
[key: string]: any;
} = {}
) => {
const level = event === 'error' ? 'error' : 'info';
// Sanitize error if present
const sanitizedDetails = { ...details };
if (sanitizedDetails.error) {
sanitizedDetails.error = sanitizeError(sanitizedDetails.error);
}
logger.log(level, `AI ${event}`, {
aiEvent: event,
...sanitizedDetails,
});
};
// ============ MORGAN STREAM ============
// Create a stream object for Morgan HTTP logging
const loggerWithStream = logger as any;
loggerWithStream.stream = {
write: (message: string) => {
logger.info(message.trim());
},
};
// Export helper functions and logger
export { sanitizeError };
export default loggerWithStream as winston.Logger;