dcrouter/ts/mail/delivery/smtpserver/utils/adaptive-logging.ts
2025-05-23 01:00:37 +00:00

514 lines
15 KiB
TypeScript

/**
* Adaptive SMTP Logging System
* Automatically switches between logging modes based on server load (active connections)
* to maintain performance during high-concurrency scenarios
*/
import * as plugins from '../../../../plugins.js';
import { logger } from '../../../../logger.js';
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
import type { ISmtpSession } from '../interfaces.js';
import type { LogLevel, ISmtpLogOptions } from './logging.js';
/**
* Log modes based on server load
*/
export enum LogMode {
VERBOSE = 'VERBOSE', // < 20 connections: Full detailed logging
REDUCED = 'REDUCED', // 20-40 connections: Limited command/response logging, full error logging
MINIMAL = 'MINIMAL' // 40+ connections: Aggregated logging only, critical errors only
}
/**
* Configuration for adaptive logging thresholds
*/
export interface IAdaptiveLogConfig {
verboseThreshold: number; // Switch to REDUCED mode above this connection count
reducedThreshold: number; // Switch to MINIMAL mode above this connection count
aggregationInterval: number; // How often to flush aggregated logs (ms)
maxAggregatedEntries: number; // Max entries to hold before forced flush
}
/**
* Aggregated log entry for batching similar events
*/
interface IAggregatedLogEntry {
type: 'connection' | 'command' | 'response' | 'error';
count: number;
firstSeen: number;
lastSeen: number;
sample: {
message: string;
level: LogLevel;
options?: ISmtpLogOptions;
};
}
/**
* Connection metadata for aggregation tracking
*/
interface IConnectionTracker {
activeConnections: number;
peakConnections: number;
totalConnections: number;
connectionsPerSecond: number;
lastConnectionTime: number;
}
/**
* Adaptive SMTP Logger that scales logging based on server load
*/
export class AdaptiveSmtpLogger {
private static instance: AdaptiveSmtpLogger;
private currentMode: LogMode = LogMode.VERBOSE;
private config: IAdaptiveLogConfig;
private aggregatedEntries: Map<string, IAggregatedLogEntry> = new Map();
private aggregationTimer: NodeJS.Timeout | null = null;
private connectionTracker: IConnectionTracker = {
activeConnections: 0,
peakConnections: 0,
totalConnections: 0,
connectionsPerSecond: 0,
lastConnectionTime: Date.now()
};
private constructor(config?: Partial<IAdaptiveLogConfig>) {
this.config = {
verboseThreshold: 20,
reducedThreshold: 40,
aggregationInterval: 30000, // 30 seconds
maxAggregatedEntries: 100,
...config
};
this.startAggregationTimer();
}
/**
* Get singleton instance
*/
public static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger {
if (!AdaptiveSmtpLogger.instance) {
AdaptiveSmtpLogger.instance = new AdaptiveSmtpLogger(config);
}
return AdaptiveSmtpLogger.instance;
}
/**
* Update active connection count and adjust log mode if needed
*/
public updateConnectionCount(activeConnections: number): void {
this.connectionTracker.activeConnections = activeConnections;
this.connectionTracker.peakConnections = Math.max(
this.connectionTracker.peakConnections,
activeConnections
);
const newMode = this.determineLogMode(activeConnections);
if (newMode !== this.currentMode) {
this.switchLogMode(newMode);
}
}
/**
* Track new connection for rate calculation
*/
public trackConnection(): void {
this.connectionTracker.totalConnections++;
const now = Date.now();
const timeDiff = (now - this.connectionTracker.lastConnectionTime) / 1000;
if (timeDiff > 0) {
this.connectionTracker.connectionsPerSecond = 1 / timeDiff;
}
this.connectionTracker.lastConnectionTime = now;
}
/**
* Get current logging mode
*/
public getCurrentMode(): LogMode {
return this.currentMode;
}
/**
* Get connection statistics
*/
public getConnectionStats(): IConnectionTracker {
return { ...this.connectionTracker };
}
/**
* Log a message with adaptive behavior
*/
public log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void {
// Always log structured data
const errorInfo = options.error ? {
errorMessage: options.error.message,
errorStack: options.error.stack,
errorName: options.error.name
} : {};
const logData = {
component: 'smtp-server',
logMode: this.currentMode,
activeConnections: this.connectionTracker.activeConnections,
...options,
...errorInfo
};
if (logData.error) {
delete logData.error;
}
logger.log(level, message, logData);
// Adaptive console logging based on mode
switch (this.currentMode) {
case LogMode.VERBOSE:
// Full console logging
if (level === 'error' || level === 'warn') {
console[level](`[SMTP] ${message}`, logData);
}
break;
case LogMode.REDUCED:
// Only errors and warnings to console
if (level === 'error' || level === 'warn') {
console[level](`[SMTP] ${message}`, logData);
}
break;
case LogMode.MINIMAL:
// Only critical errors to console
if (level === 'error' && (message.includes('critical') || message.includes('security') || message.includes('crash'))) {
console[level](`[SMTP] ${message}`, logData);
}
break;
}
}
/**
* Log command with adaptive behavior
*/
public logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void {
const clientInfo = {
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
secure: socket instanceof plugins.tls.TLSSocket,
sessionId: session?.id,
sessionState: session?.state
};
switch (this.currentMode) {
case LogMode.VERBOSE:
this.log('info', `Command received: ${command}`, {
...clientInfo,
command: command.split(' ')[0]?.toUpperCase()
});
console.log(`${command}`);
break;
case LogMode.REDUCED:
// Aggregate commands instead of logging each one
this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo);
// Only show error commands
if (command.toUpperCase().startsWith('QUIT') || command.includes('error')) {
console.log(`${command}`);
}
break;
case LogMode.MINIMAL:
// Only aggregate, no console output unless it's an error command
this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo);
break;
}
}
/**
* Log response with adaptive behavior
*/
public logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const clientInfo = {
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
secure: socket instanceof plugins.tls.TLSSocket
};
const responseCode = response.substring(0, 3);
const isError = responseCode.startsWith('4') || responseCode.startsWith('5');
switch (this.currentMode) {
case LogMode.VERBOSE:
if (responseCode.startsWith('2') || responseCode.startsWith('3')) {
this.log('debug', `Response sent: ${response}`, clientInfo);
} else if (responseCode.startsWith('4')) {
this.log('warn', `Temporary error response: ${response}`, clientInfo);
} else if (responseCode.startsWith('5')) {
this.log('error', `Permanent error response: ${response}`, clientInfo);
}
console.log(`${response}`);
break;
case LogMode.REDUCED:
// Log errors normally, aggregate success responses
if (isError) {
if (responseCode.startsWith('4')) {
this.log('warn', `Temporary error response: ${response}`, clientInfo);
} else {
this.log('error', `Permanent error response: ${response}`, clientInfo);
}
console.log(`${response}`);
} else {
this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo);
}
break;
case LogMode.MINIMAL:
// Only log critical errors
if (responseCode.startsWith('5')) {
this.log('error', `Permanent error response: ${response}`, clientInfo);
console.log(`${response}`);
} else {
this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo);
}
break;
}
}
/**
* Log connection event with adaptive behavior
*/
public logConnection(
socket: plugins.net.Socket | plugins.tls.TLSSocket,
eventType: 'connect' | 'close' | 'error',
session?: ISmtpSession,
error?: Error
): void {
const clientInfo = {
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
secure: socket instanceof plugins.tls.TLSSocket,
sessionId: session?.id,
sessionState: session?.state
};
if (eventType === 'connect') {
this.trackConnection();
}
switch (this.currentMode) {
case LogMode.VERBOSE:
// Full connection logging
switch (eventType) {
case 'connect':
this.log('info', `New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
break;
case 'close':
this.log('info', `Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
break;
case 'error':
this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
...clientInfo,
error
});
break;
}
break;
case LogMode.REDUCED:
// Aggregate normal connections, log errors
if (eventType === 'error') {
this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
...clientInfo,
error
});
} else {
this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo);
}
break;
case LogMode.MINIMAL:
// Only aggregate, except for critical errors
if (eventType === 'error' && error && (error.message.includes('security') || error.message.includes('critical'))) {
this.log('error', `Critical connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
...clientInfo,
error
});
} else {
this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo);
}
break;
}
}
/**
* Log security event (always logged regardless of mode)
*/
public logSecurityEvent(
level: SecurityLogLevel,
type: SecurityEventType,
message: string,
details: Record<string, any>,
ipAddress?: string,
domain?: string,
success?: boolean
): void {
const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' :
level === SecurityLogLevel.INFO ? 'info' :
level === SecurityLogLevel.WARN ? 'warn' : 'error';
// Security events are always logged in full detail
this.log(logLevel, message, {
component: 'smtp-security',
eventType: type,
success,
ipAddress,
domain,
...details
});
}
/**
* Determine appropriate log mode based on connection count
*/
private determineLogMode(activeConnections: number): LogMode {
if (activeConnections >= this.config.reducedThreshold) {
return LogMode.MINIMAL;
} else if (activeConnections >= this.config.verboseThreshold) {
return LogMode.REDUCED;
} else {
return LogMode.VERBOSE;
}
}
/**
* Switch to a new log mode
*/
private switchLogMode(newMode: LogMode): void {
const oldMode = this.currentMode;
this.currentMode = newMode;
// Log the mode switch
console.log(`[SMTP] Adaptive logging switched from ${oldMode} to ${newMode} (${this.connectionTracker.activeConnections} active connections)`);
this.log('info', `Adaptive logging mode changed to ${newMode}`, {
oldMode,
newMode,
activeConnections: this.connectionTracker.activeConnections,
peakConnections: this.connectionTracker.peakConnections,
totalConnections: this.connectionTracker.totalConnections
});
// If switching to more verbose mode, flush aggregated entries
if ((oldMode === LogMode.MINIMAL && newMode !== LogMode.MINIMAL) ||
(oldMode === LogMode.REDUCED && newMode === LogMode.VERBOSE)) {
this.flushAggregatedEntries();
}
}
/**
* Add entry to aggregation buffer
*/
private aggregateEntry(
type: 'connection' | 'command' | 'response' | 'error',
level: LogLevel,
message: string,
options?: ISmtpLogOptions
): void {
const key = `${type}:${message}`;
const now = Date.now();
if (this.aggregatedEntries.has(key)) {
const entry = this.aggregatedEntries.get(key)!;
entry.count++;
entry.lastSeen = now;
} else {
this.aggregatedEntries.set(key, {
type,
count: 1,
firstSeen: now,
lastSeen: now,
sample: { message, level, options }
});
}
// Force flush if we have too many entries
if (this.aggregatedEntries.size >= this.config.maxAggregatedEntries) {
this.flushAggregatedEntries();
}
}
/**
* Start the aggregation timer
*/
private startAggregationTimer(): void {
if (this.aggregationTimer) {
clearInterval(this.aggregationTimer);
}
this.aggregationTimer = setInterval(() => {
this.flushAggregatedEntries();
}, this.config.aggregationInterval);
// Unref the timer so it doesn't keep the process alive
if (this.aggregationTimer && typeof this.aggregationTimer.unref === 'function') {
this.aggregationTimer.unref();
}
}
/**
* Flush aggregated entries to logs
*/
private flushAggregatedEntries(): void {
if (this.aggregatedEntries.size === 0) {
return;
}
const summary: Record<string, number> = {};
let totalAggregated = 0;
for (const [key, entry] of this.aggregatedEntries.entries()) {
summary[entry.type] = (summary[entry.type] || 0) + entry.count;
totalAggregated += entry.count;
// Log a sample of high-frequency entries
if (entry.count >= 10) {
this.log(entry.sample.level, `${entry.sample.message} (aggregated: ${entry.count} occurrences)`, {
...entry.sample.options,
aggregated: true,
occurrences: entry.count,
timeSpan: entry.lastSeen - entry.firstSeen
});
}
}
// Log aggregation summary
console.log(`[SMTP] Aggregated ${totalAggregated} log entries: ${JSON.stringify(summary)}`);
this.log('info', 'Aggregated log summary', {
totalEntries: totalAggregated,
breakdown: summary,
logMode: this.currentMode,
activeConnections: this.connectionTracker.activeConnections
});
// Clear aggregated entries
this.aggregatedEntries.clear();
}
/**
* Cleanup resources
*/
public destroy(): void {
if (this.aggregationTimer) {
clearInterval(this.aggregationTimer);
this.aggregationTimer = null;
}
this.flushAggregatedEntries();
}
}
/**
* Default instance for easy access
*/
export const adaptiveLogger = AdaptiveSmtpLogger.getInstance();