464 lines
12 KiB
TypeScript
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;
|