import * as plugins from '../plugins.js'; import { DcRouter } from '../classes.dcrouter.js'; import { MetricsCache } from './classes.metricscache.js'; import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js'; import { logger } from '../logger.js'; import type { IAsnActivity, IProtocolConnectionHistoryPoint } from '../../ts_interfaces/data/stats.js'; import { EmailTrafficBucketDoc, type IEmailTrafficBucketSnapshot, } from '../db/documents/classes.email-traffic-bucket.doc.js'; const EMAIL_TRAFFIC_WINDOW_MINUTES = 24 * 60; const EMAIL_TRAFFIC_FLUSH_INTERVAL_MS = 5_000; const EMAIL_TRAFFIC_PRUNE_INTERVAL_MS = 60 * 60 * 1000; const AUTHENTICATION_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; const NETWORK_STATS_CACHE_TTL_MS = 3_000; const ASN_INTELLIGENCE_CACHE_TTL_MS = 60_000; const ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS = 3_000; const OBSERVED_IP_SAMPLE_INTERVAL_MS = 60_000; const MAX_ASN_CANDIDATE_IPS = 500; const MAX_OBSERVED_IP_SAMPLE_SIZE = 20; const MAX_SNAPSHOT_LIMIT = 1_000; const NETWORK_HISTORY_SECONDS = 600; export class MetricsManager { private metricsLogger: plugins.smartlog.Smartlog; private smartMetrics: plugins.smartmetrics.SmartMetrics; private dcRouter: DcRouter; private resetInterval?: NodeJS.Timeout; private observedIpSamplerInterval?: NodeJS.Timeout; private emailTrafficFlushInterval?: NodeJS.Timeout; private persistedMetricsPruneInterval?: NodeJS.Timeout; private emailTrafficFlushChain: Promise = Promise.resolve(); private persistedMetricsFlushInFlight?: Promise; private persistedMetricsPruneInFlight?: Promise; private emailDirtyBuckets = new Map(); private metricsCache: MetricsCache; // Constants private readonly MAX_TOP_DOMAINS = 1000; // Limit topDomains Map size // Track email-specific metrics private emailMetrics = { sentToday: 0, receivedToday: 0, failedToday: 0, bouncedToday: 0, queueSize: 0, lastResetDate: new Date().toISOString().slice(0, 10), deliveryTimes: [] as number[], // Track delivery times in ms recipients: new Map(), // Track email count by recipient recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>, }; // Track DNS-specific metrics private dnsMetrics = { totalQueries: 0, cacheHits: 0, cacheMisses: 0, queryTypes: {} as Record, topDomains: new Map(), lastResetDate: new Date().toDateString(), // Per-second query count ring buffer (300 entries = 5 minutes) queryRing: new Int32Array(300), queryRingLastSecond: 0, // last epoch second that was written responseTimes: [] as number[], // Track response times in ms recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>, }; // Per-minute time-series buckets for charts private emailMinuteBuckets = new Map(); private dnsMinuteBuckets = new Map(); // Track security-specific metrics private securityMetrics = { blockedIPs: 0, authFailures: 0, spamDetected: 0, malwareDetected: 0, phishingDetected: 0, lastResetDate: new Date().toDateString(), incidents: [] as Array<{ timestamp: number; type: string; severity: string; details: string }>, }; constructor(dcRouter: DcRouter) { this.dcRouter = dcRouter; // Create a Smartlog instance for SmartMetrics (requires its own instance) this.metricsLogger = new plugins.smartlog.Smartlog({ logContext: { environment: 'production', runtime: 'node', zone: 'dcrouter-metrics', } }); this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter'); // Individual metrics select an appropriate TTL where their collection cost differs. this.metricsCache = new MetricsCache(500); } public async start(): Promise { if (this.canPersistEmailTraffic()) { await this.loadEmailTrafficBuckets(); await this.prunePersistedMetrics(); } // Start SmartMetrics collection this.smartMetrics.start(); // Maintain UTC daily ancillary counters and bounded in-memory windows. this.resetInterval = setInterval(() => { const currentDate = new Date().toDateString(); this.ensureEmailDailyState(); if (currentDate !== this.dnsMetrics.lastResetDate) { this.dnsMetrics.totalQueries = 0; this.dnsMetrics.cacheHits = 0; this.dnsMetrics.cacheMisses = 0; this.dnsMetrics.queryTypes = {}; this.dnsMetrics.topDomains.clear(); this.dnsMetrics.queryRing.fill(0); this.dnsMetrics.queryRingLastSecond = 0; this.dnsMetrics.responseTimes = []; this.dnsMetrics.recentQueries = []; this.dnsMetrics.lastResetDate = currentDate; } if (currentDate !== this.securityMetrics.lastResetDate) { this.securityMetrics.blockedIPs = 0; this.securityMetrics.authFailures = 0; this.securityMetrics.spamDetected = 0; this.securityMetrics.malwareDetected = 0; this.securityMetrics.phishingDetected = 0; this.securityMetrics.incidents = []; this.securityMetrics.lastResetDate = currentDate; } // Prune old time-series buckets every minute (don't wait for lazy query) this.pruneOldBuckets(); }, 60000); // Check every minute this.resetInterval.unref(); this.observedIpSamplerInterval = setInterval(() => { this.sampleObservedIps(); }, OBSERVED_IP_SAMPLE_INTERVAL_MS); this.observedIpSamplerInterval.unref(); if (this.canPersistEmailTraffic()) { this.emailTrafficFlushInterval = setInterval(() => { void this.flushPersistedMetrics().catch((error) => { logger.log('warn', `Metrics persistence flush failed: ${(error as Error).message}`); }); }, EMAIL_TRAFFIC_FLUSH_INTERVAL_MS); this.emailTrafficFlushInterval.unref(); this.persistedMetricsPruneInterval = setInterval(() => { void this.prunePersistedMetrics().catch((error) => { logger.log('warn', `Metrics persistence pruning failed: ${(error as Error).message}`); }); }, EMAIL_TRAFFIC_PRUNE_INTERVAL_MS); this.persistedMetricsPruneInterval.unref(); } logger.log('info', 'MetricsManager started'); } public async stop(): Promise { if (this.resetInterval) { clearInterval(this.resetInterval); this.resetInterval = undefined; } if (this.observedIpSamplerInterval) { clearInterval(this.observedIpSamplerInterval); this.observedIpSamplerInterval = undefined; } if (this.emailTrafficFlushInterval) { clearInterval(this.emailTrafficFlushInterval); this.emailTrafficFlushInterval = undefined; } if (this.persistedMetricsPruneInterval) { clearInterval(this.persistedMetricsPruneInterval); this.persistedMetricsPruneInterval = undefined; } if (this.persistedMetricsPruneInFlight) { try { await this.persistedMetricsPruneInFlight; } catch (error) { logger.log('warn', `Final metrics pruning wait failed: ${(error as Error).message}`); } } let finalFlushError: Error | undefined; if (this.canPersistEmailTraffic()) { const activeFlush = this.persistedMetricsFlushInFlight; if (activeFlush) { try { await activeFlush; } catch (error) { logger.log( 'warn', `Active metrics persistence flush failed before final drain: ${(error as Error).message}`, ); } } // OpsServer and EmailServer have stopped by this point. Run a fresh, // non-coalesced pass so events added after an earlier flush snapshot are // included in the final durable drain. for (let attempt = 0; attempt < 3; attempt++) { try { await this.flushPersistedMetricsUnlocked(); finalFlushError = undefined; break; } catch (error) { finalFlushError = error as Error; } } } this.smartMetrics.stop(); if (finalFlushError) { throw new Error(`Final metrics persistence flush failed: ${finalFlushError.message}`); } this.metricsCache.clear(); this.emailMinuteBuckets.clear(); this.emailDirtyBuckets.clear(); this.dnsMinuteBuckets.clear(); logger.log('info', 'MetricsManager stopped'); } // Get server metrics from SmartMetrics and SmartProxy public async getServerStats() { return this.metricsCache.get('serverStats', async () => { const smartMetricsData = await this.smartMetrics.getMetrics(); const smartProxy = this.dcRouter.smartProxy; const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null; const proxyStats = smartProxy ? await smartProxy.getStatistics() : null; const { heapUsed, heapTotal, external, rss } = process.memoryUsage(); return { uptime: process.uptime(), startTime: Date.now() - (process.uptime() * 1000), memoryUsage: { heapUsed, heapTotal, external, rss, maxMemoryMB: this.smartMetrics.maxMemoryMB, actualUsageBytes: smartMetricsData.memoryUsageBytes, actualUsagePercentage: smartMetricsData.memoryPercentage, }, cpuUsage: { user: smartMetricsData.cpuPercentage, system: 0, }, activeConnections: proxyStats ? proxyStats.activeConnections : 0, totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0, requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0, throughput: proxyMetrics ? { bytesIn: proxyMetrics.totals.bytesIn(), bytesOut: proxyMetrics.totals.bytesOut(), bytesInPerSecond: proxyMetrics.throughput.instant().in, bytesOutPerSecond: proxyMetrics.throughput.instant().out, } : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 }, }; }); } // Get email metrics public async getEmailStats() { return this.metricsCache.get('emailStats', () => { this.ensureEmailDailyState(); const totals = this.getUtcTodayEmailTotals(); const deliveryAttempts = totals.sent + totals.failed; const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0 ? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length : 0; const topRecipients = Array.from(this.emailMetrics.recipients.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([email, count]) => ({ email, count })); const recentActivity = this.emailMetrics.recentActivity.slice(-50); return { sentToday: totals.sent, receivedToday: totals.received, failedToday: totals.failed, bouncedToday: this.emailMetrics.bouncedToday, bounceRate: deliveryAttempts > 0 ? Math.min(1, Math.max(0, this.emailMetrics.bouncedToday / deliveryAttempts)) : 0, deliveryRate: deliveryAttempts > 0 ? Math.min(1, Math.max(0, totals.sent / deliveryAttempts)) : 1, queueSize: this.emailMetrics.queueSize, averageDeliveryTime: Math.round(avgDeliveryTime), topRecipients, recentActivity, }; }); } // Get DNS metrics public async getDnsStats() { return this.metricsCache.get('dnsStats', () => { const cacheHitRate = this.dnsMetrics.totalQueries > 0 ? (this.dnsMetrics.cacheHits / this.dnsMetrics.totalQueries) * 100 : 0; const topDomains = Array.from(this.dnsMetrics.topDomains.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([domain, count]) => ({ domain, count })); // Calculate queries per second from ring buffer (sum last 60 seconds) const queriesPerSecond = this.getQueryRingSum(60) / 60; // Calculate average response time const avgResponseTime = this.dnsMetrics.responseTimes.length > 0 ? this.dnsMetrics.responseTimes.reduce((a, b) => a + b, 0) / this.dnsMetrics.responseTimes.length : 0; return { queriesPerSecond: Math.round(queriesPerSecond * 10) / 10, totalQueries: this.dnsMetrics.totalQueries, cacheHits: this.dnsMetrics.cacheHits, cacheMisses: this.dnsMetrics.cacheMisses, cacheHitRate: cacheHitRate, topDomains: topDomains, queryTypes: this.dnsMetrics.queryTypes, averageResponseTime: Math.round(avgResponseTime), activeDomains: this.dnsMetrics.topDomains.size, recentQueries: this.dnsMetrics.recentQueries.slice(), }; }); } /** * Sync security metrics from the SecurityLogger singleton (last 24h). * Called before returning security stats so counters reflect real events. */ private syncFromSecurityLogger(): void { try { const securityLogger = SecurityLogger.getInstance(); const summary = securityLogger.getEventsSummary(86400000); // last 24h this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0; this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0; this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC this.securityMetrics.authFailures = securityLogger .getRecentEvents(10_000, { type: SecurityEventType.AUTHENTICATION, fromTimestamp: Date.now() - 86_400_000, }) .filter((event) => event.success === false) .length; this.securityMetrics.blockedIPs = (summary.byType[SecurityEventType.IP_REPUTATION] || 0) + (summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0); } catch { // SecurityLogger may not be initialized yet — ignore } } // Get security metrics public async getSecurityStats() { return this.metricsCache.get('securityStats', () => { // Sync counters from the real SecurityLogger events this.syncFromSecurityLogger(); // Get recent incidents (last 20) const recentIncidents = this.securityMetrics.incidents.slice(-20); return { blockedIPs: this.securityMetrics.blockedIPs, authFailures: this.securityMetrics.authFailures, spamDetected: this.securityMetrics.spamDetected, malwareDetected: this.securityMetrics.malwareDetected, phishingDetected: this.securityMetrics.phishingDetected, totalThreatsBlocked: this.securityMetrics.spamDetected + this.securityMetrics.malwareDetected + this.securityMetrics.phishingDetected, recentIncidents, }; }); } public async getActiveConnectionSnapshots( options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {}, ): Promise { const normalizedOptions = { ...options, limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT), }; const afterId = (normalizedOptions as typeof normalizedOptions & { afterId?: string }).afterId ?? ''; const cacheKey = `activeConnectionSnapshots:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`; return await this.metricsCache.get(cacheKey, async () => { if (!this.dcRouter.smartProxy) { return []; } return this.dcRouter.smartProxy.getActiveConnectionSnapshots(normalizedOptions); }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS); } public async getActiveConnectionSnapshotPage( options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {}, ): Promise { const normalizedOptions = { ...options, limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT), }; const afterId = normalizedOptions.afterId ?? ''; const cacheKey = `activeConnectionSnapshotPage:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`; return await this.metricsCache.get(cacheKey, async () => { if (!this.dcRouter.smartProxy) { return { snapshots: [], hasMore: false, nextCursor: null }; } return this.dcRouter.smartProxy.getActiveConnectionSnapshotPage(normalizedOptions); }, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS); } // Get connection info from SmartProxy public async getConnectionInfo() { return this.metricsCache.get('connectionInfo', async () => { const snapshots = await this.getActiveConnectionSnapshots({ limit: 10000 }); const connectionsByRoute = new Map(); for (const snapshot of snapshots) { const source = snapshot.routeId || snapshot.domain || `${snapshot.protocol || 'connection'}:${snapshot.localPort}`; const existing = connectionsByRoute.get(source) || { count: 0, lastActivity: new Date(snapshot.startedAtMs) }; existing.count++; if (snapshot.startedAtMs > existing.lastActivity.getTime()) { existing.lastActivity = new Date(snapshot.startedAtMs); } connectionsByRoute.set(source, existing); } const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = []; for (const [source, info] of connectionsByRoute) { connectionInfo.push({ type: 'https', count: info.count, source, lastActivity: info.lastActivity, }); } return connectionInfo; }); } // Email event tracking methods public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void { this.ensureEmailDailyState(); this.incrementEmailBucket('sent'); if (recipient) { const count = this.emailMetrics.recipients.get(recipient) || 0; this.emailMetrics.recipients.set(recipient, count + 1); // Cap recipients map to prevent unbounded growth within a day if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) { const sorted = Array.from(this.emailMetrics.recipients.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); this.emailMetrics.recipients = new Map(sorted); } } if (deliveryTimeMs) { this.emailMetrics.deliveryTimes.push(deliveryTimeMs); // Keep only last 1000 delivery times if (this.emailMetrics.deliveryTimes.length > 1000) { this.emailMetrics.deliveryTimes.shift(); } } this.emailMetrics.recentActivity.push({ timestamp: Date.now(), type: 'sent', details: recipient || 'unknown', }); // Keep only last 1000 activities if (this.emailMetrics.recentActivity.length > 1000) { this.emailMetrics.recentActivity.shift(); } } public trackEmailReceived(sender?: string): void { this.ensureEmailDailyState(); this.incrementEmailBucket('received'); this.emailMetrics.recentActivity.push({ timestamp: Date.now(), type: 'received', details: sender || 'unknown', }); // Keep only last 1000 activities if (this.emailMetrics.recentActivity.length > 1000) { this.emailMetrics.recentActivity.shift(); } } public trackEmailFailed(recipient?: string, reason?: string): void { this.ensureEmailDailyState(); this.incrementEmailBucket('failed'); this.emailMetrics.recentActivity.push({ timestamp: Date.now(), type: 'failed', details: `${recipient || 'unknown'}: ${reason || 'unknown error'}`, }); // Keep only last 1000 activities if (this.emailMetrics.recentActivity.length > 1000) { this.emailMetrics.recentActivity.shift(); } } public trackEmailBounced(recipient?: string): void { this.ensureEmailDailyState(); this.emailMetrics.bouncedToday++; this.emailMetrics.recentActivity.push({ timestamp: Date.now(), type: 'bounced', details: recipient || 'unknown', }); // Keep only last 1000 activities if (this.emailMetrics.recentActivity.length > 1000) { this.emailMetrics.recentActivity.shift(); } } public updateQueueSize(size: number): void { this.emailMetrics.queueSize = size; } // DNS event tracking methods public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void { this.dnsMetrics.totalQueries++; this.incrementDnsBucket(); // Store recent query entry this.dnsMetrics.recentQueries.push({ timestamp: Date.now(), domain, type: queryType, answered: answered ?? true, responseTimeMs: responseTimeMs ?? 0, }); if (this.dnsMetrics.recentQueries.length > 100) { this.dnsMetrics.recentQueries.shift(); } if (cacheHit) { this.dnsMetrics.cacheHits++; } else { this.dnsMetrics.cacheMisses++; } // Increment per-second query counter in ring buffer this.incrementQueryRing(); // Track response time if provided if (responseTimeMs) { this.dnsMetrics.responseTimes.push(responseTimeMs); // Keep only last 1000 response times if (this.dnsMetrics.responseTimes.length > 1000) { this.dnsMetrics.responseTimes.shift(); } } // Track query types this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1; // Track top domains with size limit const currentCount = this.dnsMetrics.topDomains.get(domain) || 0; this.dnsMetrics.topDomains.set(domain, currentCount + 1); // If we've exceeded the limit, remove the least accessed domains if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) { // Convert to array, sort by count, and keep only top domains const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup // Clear and repopulate with top domains this.dnsMetrics.topDomains.clear(); sortedDomains.forEach(([domain, count]) => { this.dnsMetrics.topDomains.set(domain, count); }); } } // Security event tracking methods public trackBlockedIP(ip?: string, reason?: string): void { this.securityMetrics.blockedIPs++; this.securityMetrics.incidents.push({ timestamp: Date.now(), type: 'ip_blocked', severity: 'medium', details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`, }); // Keep only last 1000 incidents if (this.securityMetrics.incidents.length > 1000) { this.securityMetrics.incidents.shift(); } } public trackAuthFailure(username?: string, ip?: string): void { this.securityMetrics.authFailures++; this.securityMetrics.incidents.push({ timestamp: Date.now(), type: 'auth_failure', severity: 'low', details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`, }); // Keep only last 1000 incidents if (this.securityMetrics.incidents.length > 1000) { this.securityMetrics.incidents.shift(); } } public trackSpamDetected(sender?: string): void { this.securityMetrics.spamDetected++; this.securityMetrics.incidents.push({ timestamp: Date.now(), type: 'spam_detected', severity: 'low', details: `Spam detected from ${sender || 'unknown'}`, }); // Keep only last 1000 incidents if (this.securityMetrics.incidents.length > 1000) { this.securityMetrics.incidents.shift(); } } public trackMalwareDetected(source?: string): void { this.securityMetrics.malwareDetected++; this.securityMetrics.incidents.push({ timestamp: Date.now(), type: 'malware_detected', severity: 'high', details: `Malware detected from ${source || 'unknown'}`, }); // Keep only last 1000 incidents if (this.securityMetrics.incidents.length > 1000) { this.securityMetrics.incidents.shift(); } } public trackPhishingDetected(source?: string): void { this.securityMetrics.phishingDetected++; this.securityMetrics.incidents.push({ timestamp: Date.now(), type: 'phishing_detected', severity: 'high', details: `Phishing attempt from ${source || 'unknown'}`, }); // Keep only last 1000 incidents if (this.securityMetrics.incidents.length > 1000) { this.securityMetrics.incidents.shift(); } } /** * Feed IP intelligence from maintained data-plane counters, independently of * dashboard reads. Startup order is intentionally tolerated: until both * SmartProxy and SecurityPolicyManager exist, the sample is a no-op. */ private sampleObservedIps(): void { const smartProxy = this.dcRouter.smartProxy; const securityPolicyManager = this.dcRouter.securityPolicyManager; if (!smartProxy || !securityPolicyManager) { return; } try { const proxyMetrics = smartProxy.getMetrics(); const connectionsByIP = proxyMetrics.connections.byIP(); const throughputByIP = proxyMetrics.throughput.byIP(); const candidates = new Map(); for (const [ip, connections] of connectionsByIP) { candidates.set(ip, { connections, throughput: 0 }); } for (const [ip, throughput] of throughputByIP) { const candidate = candidates.get(ip) || { connections: 0, throughput: 0 }; candidate.throughput = throughput.in + throughput.out; candidates.set(ip, candidate); } for (const ip of proxyMetrics.connections.domainRequestsByIP().keys()) { if (!candidates.has(ip)) { candidates.set(ip, { connections: 0, throughput: 0 }); } } const observedIps = [...candidates.entries()] .filter(([ip]) => Boolean(ip)) .sort((a, b) => ( b[1].connections - a[1].connections || b[1].throughput - a[1].throughput || a[0].localeCompare(b[0]) )) .slice(0, MAX_OBSERVED_IP_SAMPLE_SIZE) .map(([ip]) => ip); if (observedIps.length > 0) { securityPolicyManager.queueObservedIps(observedIps); } } catch (error: unknown) { logger.log('warn', `Observed-IP metrics sample failed: ${(error as Error).message}`); } } // Get network metrics from SmartProxy public async getNetworkStats() { return this.metricsCache.get('networkStats', async () => { const smartProxy = this.dcRouter.smartProxy; if (smartProxy) { try { await smartProxy.refreshMetricsHistory(NETWORK_HISTORY_SECONDS); } catch (error) { logger.log('warn', `SmartProxy history refresh failed: ${(error as Error).message}`); } } const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null; if (!proxyMetrics) { return { connectionsByIP: new Map(), activeConnections: 0, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, topIPs: [] as Array<{ ip: string; count: number }>, topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>, topASNs: [] as IAsnActivity[], totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>, frontendConnectionHistory: [] as IProtocolConnectionHistoryPoint[], backendConnectionHistory: [] as IProtocolConnectionHistoryPoint[], throughputByIP: new Map(), requestsPerSecond: 0, requestsTotal: 0, backends: [] as Array, domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>, frontendProtocols: null, backendProtocols: null, }; } const connectionsByIP = proxyMetrics.connections.byIP(); const connectionsByRoute = proxyMetrics.connections.byRoute(); const activeConnections = proxyMetrics.connections.active(); const instantThroughput = proxyMetrics.throughput.instant(); // Get throughput rate const throughputRate = { bytesInPerSecond: instantThroughput.in, bytesOutPerSecond: instantThroughput.out }; // Get top IPs by active connection count const topIPs = Array.from(connectionsByIP.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([ip, count]) => ({ ip, count })); // Get total data transferred const totalDataTransferred = { bytesIn: proxyMetrics.totals.bytesIn(), bytesOut: proxyMetrics.totals.bytesOut() }; // Read the ten-minute histories populated by the bounded Rust history refresh. const throughputHistory = proxyMetrics.throughput.history(NETWORK_HISTORY_SECONDS); // Get per-IP throughput const throughputByIP = proxyMetrics.throughput.byIP(); // Get HTTP request rates const requestsPerSecond = proxyMetrics.requests.perSecond(); const requestsTotal = proxyMetrics.requests.total(); const domainRequestRates = proxyMetrics.requests.byDomain(); // Get frontend/backend protocol distribution const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null; const backendProtocols = proxyMetrics.connections.backendProtocols() ?? null; const frontendConnectionHistory = proxyMetrics.connections.frontendHistory(NETWORK_HISTORY_SECONDS); const backendConnectionHistory = proxyMetrics.connections.backendHistory(NETWORK_HISTORY_SECONDS); // Collect backend protocol data const backendMetrics = proxyMetrics.backends.byBackend(); const protocolCache = proxyMetrics.backends.detectedProtocols(); // Group protocol cache entries by host:port so we can match them to backend metrics. // The protocol cache is keyed by (host, port, domain) in Rust, so the same host:port // can have multiple entries for different domains. const cacheByBackend = new Map(); for (const entry of protocolCache) { const backendKey = `${entry.host}:${entry.port}`; let entries = cacheByBackend.get(backendKey); if (!entries) { entries = []; cacheByBackend.set(backendKey, entries); } entries.push(entry); } const backends: Array = []; const seenCacheKeys = new Set(); for (const [key, bm] of backendMetrics) { backends.push({ id: `backend:${key}`, backend: key, domain: null, protocol: bm.protocol, activeConnections: bm.activeConnections, totalConnections: bm.totalConnections, connectErrors: bm.connectErrors, handshakeErrors: bm.handshakeErrors, requestErrors: bm.requestErrors, avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, h2Failures: bm.h2Failures, h2Suppressed: false, h3Suppressed: false, h2CooldownRemainingSecs: null, h3CooldownRemainingSecs: null, h2ConsecutiveFailures: null, h3ConsecutiveFailures: null, h3Port: null, cacheAgeSecs: null, }); const cacheEntries = cacheByBackend.get(key); if (cacheEntries && cacheEntries.length > 0) { // Protocol cache rows are domain-scoped metadata, not live backend connections. for (const cache of cacheEntries) { const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`; seenCacheKeys.add(compositeKey); backends.push({ id: `cache:${compositeKey}`, backend: key, domain: cache.domain ?? null, protocol: cache.protocol ?? bm.protocol, activeConnections: 0, totalConnections: 0, connectErrors: 0, handshakeErrors: 0, requestErrors: 0, avgConnectTimeMs: 0, poolHitRate: 0, h2Failures: 0, h2Suppressed: cache.h2Suppressed, h3Suppressed: cache.h3Suppressed, h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs, h3CooldownRemainingSecs: cache.h3CooldownRemainingSecs, h2ConsecutiveFailures: cache.h2ConsecutiveFailures, h3ConsecutiveFailures: cache.h3ConsecutiveFailures, h3Port: cache.h3Port, cacheAgeSecs: cache.ageSecs, }); } } } // Include protocol cache entries with no matching backend metric for (const entry of protocolCache) { const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`; if (!seenCacheKeys.has(compositeKey)) { backends.push({ id: `cache:${compositeKey}`, backend: `${entry.host}:${entry.port}`, domain: entry.domain, protocol: entry.protocol, activeConnections: 0, totalConnections: 0, connectErrors: 0, handshakeErrors: 0, requestErrors: 0, avgConnectTimeMs: 0, poolHitRate: 0, h2Failures: 0, h2Suppressed: entry.h2Suppressed, h3Suppressed: entry.h3Suppressed, h2CooldownRemainingSecs: entry.h2CooldownRemainingSecs, h3CooldownRemainingSecs: entry.h3CooldownRemainingSecs, h2ConsecutiveFailures: entry.h2ConsecutiveFailures, h3ConsecutiveFailures: entry.h3ConsecutiveFailures, h3Port: entry.h3Port, cacheAgeSecs: entry.ageSecs, }); } } // Build top 10 IPs by bandwidth (sorted by total throughput desc) const allIPData = new Map(); for (const [ip, count] of connectionsByIP) { allIPData.set(ip, { count, bwIn: 0, bwOut: 0 }); } for (const [ip, tp] of throughputByIP) { const existing = allIPData.get(ip); if (existing) { existing.bwIn = tp.in; existing.bwOut = tp.out; } else { allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out }); } } const topIPsByBandwidth = Array.from(allIPData.entries()) .sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut)) .slice(0, 10) .map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut })); const observedIps = [...new Set([ ...connectionsByIP.keys(), ...throughputByIP.keys(), ...topIPs.map((item) => item.ip), ...topIPsByBandwidth.map((item) => item.ip), ])]; const topASNs = await this.buildTopASNs(observedIps, allIPData); // Build domain activity using per-IP domain request counts from Rust engine const throughputByRoute = proxyMetrics.throughput.byRoute(); // Aggregate per-IP domain request counts into per-domain totals const domainRequestTotals = new Map(); const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP(); for (const [, domainMap] of domainRequestsByIP) { for (const [domain, count] of domainMap) { domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count); } } // Map canonical route key → domains from route config const routeDomains = new Map(); if (this.dcRouter.smartProxy) { for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) { const routeKey = route.name || route.id; if (!routeKey || !route.match.domains) continue; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; if (domains.length > 0) { routeDomains.set(routeKey, domains); } } } // Resolve wildcards using domains seen in request metrics const allKnownDomains = new Set(domainRequestTotals.keys()); for (const domain of domainRequestRates.keys()) { allKnownDomains.add(domain); } for (const entry of protocolCache) { if (entry.domain) allKnownDomains.add(entry.domain); } // Build reverse map: concrete domain → canonical route key(s) const domainToRoutes = new Map(); for (const [routeKey, domains] of routeDomains) { for (const pattern of domains) { if (pattern.includes('*')) { const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$'); for (const knownDomain of allKnownDomains) { if (regex.test(knownDomain)) { const existing = domainToRoutes.get(knownDomain); if (existing) { existing.push(routeKey); } else { domainToRoutes.set(knownDomain, [routeKey]); } } } } else { const existing = domainToRoutes.get(pattern); if (existing) { existing.push(routeKey); } else { domainToRoutes.set(pattern, [routeKey]); } } } } const hasLiveDomainRates = domainRequestRates.size > 0; const getDomainWeight = (domain: string): number => { const liveRate = domainRequestRates.get(domain); return hasLiveDomainRates ? (liveRate?.lastMinute ?? 0) : (domainRequestTotals.get(domain) || 0); }; // For each route, compute the total activity weight across all resolved domains // so we can distribute route-level throughput/connections. Prefer live domain // request rates from SmartProxy 27.8+, falling back to lifetime counters. const routeTotalRequests = new Map(); for (const [domain, routeKeys] of domainToRoutes) { const reqs = getDomainWeight(domain); for (const routeKey of routeKeys) { routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs); } } // Aggregate metrics per domain using request-count-proportional splitting const domainAgg = new Map(); for (const [domain, routeKeys] of domainToRoutes) { const domainReqs = getDomainWeight(domain); const requestRate = domainRequestRates.get(domain); let totalConns = 0; let totalIn = 0; let totalOut = 0; for (const routeKey of routeKeys) { const conns = connectionsByRoute.get(routeKey) || 0; const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 }; const routeTotal = routeTotalRequests.get(routeKey) || 0; const share = routeTotal > 0 ? domainReqs / routeTotal : 0; totalConns += conns * share; totalIn += tp.in * share; totalOut += tp.out * share; } domainAgg.set(domain, { activeConnections: Math.round(totalConns), bytesInPerSec: totalIn, bytesOutPerSec: totalOut, routeCount: routeKeys.length, requestCount: domainRequestTotals.get(domain) || 0, requestsPerSecond: requestRate?.perSecond ?? 0, requestsLastMinute: requestRate?.lastMinute ?? 0, }); } const domainActivity = Array.from(domainAgg.entries()) .map(([domain, data]) => ({ domain, bytesInPerSecond: data.bytesInPerSec, bytesOutPerSecond: data.bytesOutPerSec, activeConnections: data.activeConnections, routeCount: data.routeCount, requestCount: data.requestCount, requestsPerSecond: data.requestsPerSecond, requestsLastMinute: data.requestsLastMinute, })) .sort((a, b) => { if (hasLiveDomainRates) { return (b.requestsPerSecond - a.requestsPerSecond) || (b.requestsLastMinute - a.requestsLastMinute) || ((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond)); } return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond); }); return { connectionsByIP, activeConnections, throughputRate, topIPs, topIPsByBandwidth, topASNs, totalDataTransferred, throughputHistory, frontendConnectionHistory, backendConnectionHistory, throughputByIP, requestsPerSecond, requestsTotal, backends, frontendProtocols, backendProtocols, domainActivity, }; }, NETWORK_STATS_CACHE_TTL_MS); } private async buildTopASNs( observedIps: string[], allIPData: Map, ): Promise { const manager = this.dcRouter.securityPolicyManager; const candidateIps = [...new Set(observedIps.filter(Boolean))] .sort() .slice(0, MAX_ASN_CANDIDATE_IPS); if (!manager || candidateIps.length === 0) { return []; } const intelligenceRecords = await this.metricsCache.get( 'networkAsnIntelligence', () => manager.listIpIntelligence({ ipAddresses: candidateIps, limit: Math.max(100, candidateIps.length), }), ASN_INTELLIGENCE_CACHE_TTL_MS, ); const asnActivity = new Map(); for (const record of intelligenceRecords) { if (typeof record.asn !== 'number') continue; const ipData = allIPData.get(record.ipAddress); if (!ipData) continue; const existing = asnActivity.get(record.asn); const activity = existing || { asn: record.asn, organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`, country: record.countryCode || record.country || record.registrantCountry || null, activeConnections: 0, ipCount: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0, sampleIps: [], }; activity.activeConnections += ipData.count; activity.bytesInPerSecond += ipData.bwIn; activity.bytesOutPerSecond += ipData.bwOut; activity.ipCount++; if (activity.sampleIps.length < 5) { activity.sampleIps.push(record.ipAddress); } asnActivity.set(record.asn, activity); } return [...asnActivity.values()] .sort((a, b) => { const connectionDiff = b.activeConnections - a.activeConnections; if (connectionDiff !== 0) return connectionDiff; const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond; const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond; return bandwidthB - bandwidthA; }) .slice(0, 10); } // --- Time-series helpers --- private static minuteKey(ts: number = Date.now()): number { return Math.floor(ts / 60000) * 60000; } private static firstMinuteForHours(hoursArg: number, nowArg = Date.now()): number { const pointCount = Math.max(1, Math.floor(hoursArg * 60)); return MetricsManager.minuteKey(nowArg) - (pointCount - 1) * 60_000; } private isEmailTrafficPersistenceConfigured(): boolean { return this.dcRouter.options.dbConfig?.enabled !== false; } private canPersistEmailTraffic(): boolean { return this.isEmailTrafficPersistenceConfigured() && Boolean(this.dcRouter.dcRouterDb?.isReady()); } private ensureEmailDailyState(nowArg = Date.now()): void { const currentUtcDate = new Date(nowArg).toISOString().slice(0, 10); if (currentUtcDate === this.emailMetrics.lastResetDate) return; this.emailMetrics.sentToday = 0; this.emailMetrics.receivedToday = 0; this.emailMetrics.failedToday = 0; this.emailMetrics.bouncedToday = 0; this.emailMetrics.deliveryTimes = []; this.emailMetrics.recipients.clear(); this.emailMetrics.recentActivity = []; this.emailMetrics.lastResetDate = currentUtcDate; } private getUtcTodayEmailTotals(nowArg = Date.now()): { sent: number; received: number; failed: number; } { const now = new Date(nowArg); const utcMidnight = Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), ); const totals = { sent: 0, received: 0, failed: 0 }; for (const [bucketStart, bucket] of this.emailMinuteBuckets) { if (bucketStart < utcMidnight || bucketStart > nowArg) continue; totals.sent += bucket.sent; totals.received += bucket.received; totals.failed += bucket.failed; } return totals; } private async loadEmailTrafficBuckets(): Promise { const snapshots = await EmailTrafficBucketDoc.loadSince( MetricsManager.firstMinuteForHours(24), ); for (const snapshot of snapshots) { this.emailMinuteBuckets.set(snapshot.bucketStart, { sent: snapshot.sent, received: snapshot.received, failed: snapshot.failed, revision: 0, }); } } private flushPersistedMetrics(): Promise { if (this.persistedMetricsFlushInFlight) return this.persistedMetricsFlushInFlight; const run = this.flushPersistedMetricsUnlocked(); this.persistedMetricsFlushInFlight = run; run.then( () => { if (this.persistedMetricsFlushInFlight === run) this.persistedMetricsFlushInFlight = undefined; }, () => { if (this.persistedMetricsFlushInFlight === run) this.persistedMetricsFlushInFlight = undefined; }, ); return run; } private async flushPersistedMetricsUnlocked(): Promise { const results = await Promise.allSettled([ this.flushEmailTrafficBuckets(), this.dcRouter.authenticationEventManager.flushBuffered(), ]); const failure = results.find((result) => result.status === 'rejected'); if (failure?.status === 'rejected') { throw failure.reason; } } private flushEmailTrafficBuckets(): Promise { const run = this.emailTrafficFlushChain.then( () => this.flushEmailTrafficBucketsUnlocked(), () => this.flushEmailTrafficBucketsUnlocked(), ); this.emailTrafficFlushChain = run.then(() => undefined, () => undefined); return run; } private async flushEmailTrafficBucketsUnlocked(): Promise { if (!this.canPersistEmailTraffic() || this.emailDirtyBuckets.size === 0) return; const snapshots: Array = []; for (const [bucketStart, revision] of this.emailDirtyBuckets) { const bucket = this.emailMinuteBuckets.get(bucketStart); if (!bucket) continue; snapshots.push({ bucketStart, sent: bucket.sent, received: bucket.received, failed: bucket.failed, revision, }); } for (let offset = 0; offset < snapshots.length; offset += 500) { const batch = snapshots.slice(offset, offset + 500); await EmailTrafficBucketDoc.persistAbsolute(batch); for (const snapshot of batch) { const current = this.emailMinuteBuckets.get(snapshot.bucketStart); if ( current?.revision === snapshot.revision && this.emailDirtyBuckets.get(snapshot.bucketStart) === snapshot.revision ) { this.emailDirtyBuckets.delete(snapshot.bucketStart); } } } } private prunePersistedMetrics(): Promise { if (this.persistedMetricsPruneInFlight) return this.persistedMetricsPruneInFlight; const run = this.prunePersistedMetricsUnlocked(); this.persistedMetricsPruneInFlight = run; run.then( () => { if (this.persistedMetricsPruneInFlight === run) this.persistedMetricsPruneInFlight = undefined; }, () => { if (this.persistedMetricsPruneInFlight === run) this.persistedMetricsPruneInFlight = undefined; }, ); return run; } private async prunePersistedMetricsUnlocked(): Promise { if (!this.canPersistEmailTraffic()) return; await Promise.all([ EmailTrafficBucketDoc.pruneBefore(MetricsManager.firstMinuteForHours(24)), this.dcRouter.authenticationEventManager.pruneBefore( Date.now() - AUTHENTICATION_EVENT_RETENTION_MS, ), ]); } private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void { const key = MetricsManager.minuteKey(); let bucket = this.emailMinuteBuckets.get(key); if (!bucket) { bucket = { sent: 0, received: 0, failed: 0, revision: 0 }; this.emailMinuteBuckets.set(key, bucket); } bucket[field]++; bucket.revision++; if (this.isEmailTrafficPersistenceConfigured()) { this.emailDirtyBuckets.set(key, bucket.revision); } } private incrementDnsBucket(): void { const key = MetricsManager.minuteKey(); let bucket = this.dnsMinuteBuckets.get(key); if (!bucket) { bucket = { queries: 0 }; this.dnsMinuteBuckets.set(key, bucket); } bucket.queries++; } /** * Increment the per-second query counter in the ring buffer. * Zeros any stale slots between the last write and the current second. */ private incrementQueryRing(): void { const currentSecond = Math.floor(Date.now() / 1000); const ring = this.dnsMetrics.queryRing; const last = this.dnsMetrics.queryRingLastSecond; if (last === 0) { // First call — zero and anchor ring.fill(0); this.dnsMetrics.queryRingLastSecond = currentSecond; ring[currentSecond % ring.length] = 1; return; } const gap = currentSecond - last; if (gap >= ring.length) { // Entire ring is stale — clear all ring.fill(0); } else if (gap > 0) { // Zero slots from (last+1) to currentSecond (inclusive) for (let s = last + 1; s <= currentSecond; s++) { ring[s % ring.length] = 0; } } this.dnsMetrics.queryRingLastSecond = currentSecond; ring[currentSecond % ring.length]++; } /** * Sum query counts from the ring buffer for the last N seconds. */ private getQueryRingSum(seconds: number): number { const currentSecond = Math.floor(Date.now() / 1000); const ring = this.dnsMetrics.queryRing; const last = this.dnsMetrics.queryRingLastSecond; if (last === 0) return 0; // First, zero stale slots so reads are accurate even without writes const gap = currentSecond - last; if (gap >= ring.length) return 0; // all data is stale let sum = 0; const limit = Math.min(seconds, ring.length); for (let i = 0; i < limit; i++) { const sec = currentSecond - i; if (sec < last - (ring.length - 1)) break; // slot is from older cycle if (sec > last) continue; // no writes yet for this second sum += ring[sec % ring.length]; } return sum; } private pruneOldBuckets(): void { const emailCutoff = MetricsManager.firstMinuteForHours(24); for (const key of this.emailMinuteBuckets.keys()) { if (key < emailCutoff) { this.emailMinuteBuckets.delete(key); this.emailDirtyBuckets.delete(key); } } const dnsCutoff = Date.now() - 86400000; for (const key of this.dnsMinuteBuckets.keys()) { if (key < dnsCutoff) this.dnsMinuteBuckets.delete(key); } } /** * Get email time-series data for the last N hours, aggregated per minute. */ public getEmailTimeSeries(hours: number = 24): { sent: Array<{ timestamp: number; value: number }>; received: Array<{ timestamp: number; value: number }>; failed: Array<{ timestamp: number; value: number }>; } { this.pruneOldBuckets(); const pointCount = Math.max( 1, Math.min(EMAIL_TRAFFIC_WINDOW_MINUTES, Math.floor(hours * 60)), ); const end = MetricsManager.minuteKey(); const start = end - (pointCount - 1) * 60_000; const sent: Array<{ timestamp: number; value: number }> = []; const received: Array<{ timestamp: number; value: number }> = []; const failed: Array<{ timestamp: number; value: number }> = []; for (let timestamp = start; timestamp <= end; timestamp += 60_000) { const bucket = this.emailMinuteBuckets.get(timestamp); sent.push({ timestamp, value: bucket?.sent || 0 }); received.push({ timestamp, value: bucket?.received || 0 }); failed.push({ timestamp, value: bucket?.failed || 0 }); } return { sent, received, failed }; } /** * Get DNS time-series data for the last N hours, aggregated per minute. */ public getDnsTimeSeries(hours: number = 24): { queries: Array<{ timestamp: number; value: number }>; } { this.pruneOldBuckets(); const cutoff = Date.now() - hours * 3600000; const queries: Array<{ timestamp: number; value: number }> = []; const sortedKeys = Array.from(this.dnsMinuteBuckets.keys()) .filter((k) => k >= cutoff) .sort((a, b) => a - b); for (const key of sortedKeys) { const bucket = this.dnsMinuteBuckets.get(key)!; queries.push({ timestamp: key, value: bucket.queries }); } return { queries }; } }