diff --git a/changelog.md b/changelog.md index c9a9d79..5221205 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-14 - 27.8.0 - feat(metrics) +add per-domain HTTP request rate metrics + +- Record canonicalized HTTP request rates per domain in the Rust metrics collector and expose per-second and last-minute values in snapshots. +- Add TypeScript metrics interfaces and adapter support for requests.byDomain(). +- Cover HTTP domain rate tracking and ensure TLS passthrough SNI traffic does not affect HTTP request rate metrics. + ## 2026-04-14 - 27.7.4 - fix(rustproxy metrics) use stable route metrics keys across HTTP and passthrough listeners diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index 668e29a..a8b9035 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -644,6 +644,7 @@ impl HttpProxyService { let ip_str = ip_string; // reuse from above (avoid redundant to_string()) self.metrics.record_http_request(); if let Some(ref h) = host { + self.metrics.record_http_domain_request(h); self.metrics.record_ip_domain_request(&ip_str, h); } diff --git a/rust/crates/rustproxy-metrics/src/collector.rs b/rust/crates/rustproxy-metrics/src/collector.rs index e026f3e..803a553 100644 --- a/rust/crates/rustproxy-metrics/src/collector.rs +++ b/rust/crates/rustproxy-metrics/src/collector.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; -use crate::throughput::{ThroughputSample, ThroughputTracker}; +use crate::throughput::{RequestRateTracker, ThroughputSample, ThroughputTracker}; /// Aggregated metrics snapshot. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,6 +26,7 @@ pub struct Metrics { pub total_http_requests: u64, pub http_requests_per_sec: u64, pub http_requests_per_sec_recent: u64, + pub http_domain_requests: std::collections::HashMap, // UDP metrics pub active_udp_sessions: u64, pub total_udp_sessions: u64, @@ -66,6 +67,14 @@ pub struct IpMetrics { pub domain_requests: HashMap, } +/// Per-domain HTTP request rate metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpDomainRequestMetrics { + pub requests_per_second: u64, + pub requests_last_minute: u64, +} + /// Per-backend metrics (keyed by "host:port"). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -144,6 +153,9 @@ const MAX_BACKENDS_IN_SNAPSHOT: usize = 100; /// Maximum number of distinct domains tracked per IP (prevents subdomain-spray abuse). const MAX_DOMAINS_PER_IP: usize = 256; +/// Number of one-second HTTP request samples retained per domain. +const HTTP_DOMAIN_REQUEST_WINDOW_SECONDS: usize = 60; + fn canonicalize_domain_key(domain: &str) -> Option { let normalized = domain.trim().trim_end_matches('.').to_ascii_lowercase(); if normalized.is_empty() { @@ -201,6 +213,7 @@ pub struct MetricsCollector { total_http_requests: AtomicU64, pending_http_requests: AtomicU64, http_request_throughput: Mutex, + http_domain_request_rates: DashMap>, // ── UDP metrics ── active_udp_sessions: AtomicU64, @@ -284,6 +297,7 @@ impl MetricsCollector { total_http_requests: AtomicU64::new(0), pending_http_requests: AtomicU64::new(0), http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)), + http_domain_request_rates: DashMap::new(), frontend_h1_active: AtomicU64::new(0), frontend_h1_total: AtomicU64::new(0), frontend_h2_active: AtomicU64::new(0), @@ -522,6 +536,24 @@ impl MetricsCollector { self.pending_http_requests.fetch_add(1, Ordering::Relaxed); } + /// Record a real HTTP request for a canonicalized domain. + pub fn record_http_domain_request(&self, domain: &str) { + let Some(domain) = canonicalize_domain_key(domain) else { + return; + }; + + self.http_domain_request_rates + .entry(domain.clone()) + .or_insert_with(|| { + Mutex::new(RequestRateTracker::new(HTTP_DOMAIN_REQUEST_WINDOW_SECONDS)) + }); + if let Some(tracker_ref) = self.http_domain_request_rates.get(domain.as_str()) { + if let Ok(mut tracker) = tracker_ref.value().lock() { + tracker.record_event(); + } + } + } + /// Record a domain request/connection for a frontend IP. /// /// Called per HTTP request (with Host header) and per TCP passthrough @@ -791,8 +823,7 @@ impl MetricsCollector { /// Take a throughput sample on all trackers (cold path, call at 1Hz or configured interval). /// /// Drains the lock-free pending counters and feeds the accumulated bytes - /// into the throughput trackers (under Mutex). This is the only place - /// the Mutex is locked. + /// into the throughput trackers under their sampling mutexes. pub fn sample_all(&self) { // Drain global pending bytes and feed into the tracker let global_in = self.global_pending_tp_in.swap(0, Ordering::Relaxed); @@ -873,6 +904,20 @@ impl MetricsCollector { tracker.sample(); } + // Advance HTTP domain request windows and prune fully idle domains. + let mut stale_http_domains = Vec::new(); + for entry in self.http_domain_request_rates.iter() { + if let Ok(mut tracker) = entry.value().lock() { + tracker.advance_to_now(); + if tracker.is_idle() { + stale_http_domains.push(entry.key().clone()); + } + } + } + for domain in stale_http_domains { + self.http_domain_request_rates.remove(&domain); + } + // Safety-net: prune orphaned per-IP entries that have no corresponding // ip_connections entry. This catches any entries created by a race between // record_bytes and connection_closed. @@ -1179,6 +1224,24 @@ impl MetricsCollector { }) .unwrap_or((0, 0)); + let mut http_domain_requests = std::collections::HashMap::new(); + for entry in self.http_domain_request_rates.iter() { + if let Ok(mut tracker) = entry.value().lock() { + tracker.advance_to_now(); + let requests_per_second = tracker.last_second(); + let requests_last_minute = tracker.last_minute(); + if requests_per_second > 0 || requests_last_minute > 0 { + http_domain_requests.insert( + entry.key().clone(), + HttpDomainRequestMetrics { + requests_per_second, + requests_last_minute, + }, + ); + } + } + } + Metrics { active_connections: self.active_connections(), total_connections: self.total_connections(), @@ -1195,6 +1258,7 @@ impl MetricsCollector { total_http_requests: self.total_http_requests.load(Ordering::Relaxed), http_requests_per_sec: http_rps, http_requests_per_sec_recent: http_rps_recent, + http_domain_requests, active_udp_sessions: self.active_udp_sessions.load(Ordering::Relaxed), total_udp_sessions: self.total_udp_sessions.load(Ordering::Relaxed), total_datagrams_in: self.total_datagrams_in.load(Ordering::Relaxed), @@ -1514,6 +1578,47 @@ mod tests { assert_eq!(snapshot.http_requests_per_sec, 3); } + #[test] + fn test_http_domain_request_rates_are_canonicalized() { + let collector = MetricsCollector::with_retention(60); + + collector.record_http_domain_request("Example.COM"); + collector.record_http_domain_request("example.com."); + collector.record_http_domain_request(" example.com "); + + let now_sec = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if let Some(tracker) = collector.http_domain_request_rates.get("example.com") { + tracker.value().lock().unwrap().advance_to(now_sec + 1); + } + + let snapshot = collector.snapshot(); + let metrics = snapshot.http_domain_requests.get("example.com").unwrap(); + assert_eq!(snapshot.http_domain_requests.len(), 1); + assert_eq!(metrics.requests_per_second, 3); + assert_eq!(metrics.requests_last_minute, 3); + } + + #[test] + fn test_ip_domain_requests_do_not_affect_http_domain_request_rates() { + let collector = MetricsCollector::with_retention(60); + + collector.connection_opened(Some("route-a"), Some("10.0.0.1")); + collector.record_ip_domain_request("10.0.0.1", "example.com"); + + let snapshot = collector.snapshot(); + assert!(snapshot.http_domain_requests.is_empty()); + assert_eq!( + snapshot + .ips + .get("10.0.0.1") + .and_then(|ip| ip.domain_requests.get("example.com")), + Some(&1) + ); + } + #[test] fn test_retain_routes_prunes_stale() { let collector = MetricsCollector::with_retention(60); diff --git a/rust/crates/rustproxy-metrics/src/throughput.rs b/rust/crates/rustproxy-metrics/src/throughput.rs index 1fce59e..e545dfa 100644 --- a/rust/crates/rustproxy-metrics/src/throughput.rs +++ b/rust/crates/rustproxy-metrics/src/throughput.rs @@ -29,6 +29,113 @@ pub struct ThroughputTracker { created_at: Instant, } +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Circular buffer for per-second event counts. +/// +/// Unlike `ThroughputTracker`, events are recorded directly into the current +/// second so request counts remain stable even when the collector is sampled +/// more frequently than once per second. +pub(crate) struct RequestRateTracker { + samples: Vec, + write_index: usize, + count: usize, + capacity: usize, + current_second: Option, + current_count: u64, +} + +impl RequestRateTracker { + pub(crate) fn new(retention_seconds: usize) -> Self { + Self { + samples: Vec::with_capacity(retention_seconds.max(1)), + write_index: 0, + count: 0, + capacity: retention_seconds.max(1), + current_second: None, + current_count: 0, + } + } + + fn push_sample(&mut self, count: u64) { + if self.samples.len() < self.capacity { + self.samples.push(count); + } else { + self.samples[self.write_index] = count; + } + self.write_index = (self.write_index + 1) % self.capacity; + self.count = (self.count + 1).min(self.capacity); + } + + pub(crate) fn record_event(&mut self) { + self.record_events_at(unix_timestamp_seconds(), 1); + } + + pub(crate) fn record_events_at(&mut self, now_sec: u64, count: u64) { + self.advance_to(now_sec); + self.current_count = self.current_count.saturating_add(count); + } + + pub(crate) fn advance_to_now(&mut self) { + self.advance_to(unix_timestamp_seconds()); + } + + pub(crate) fn advance_to(&mut self, now_sec: u64) { + match self.current_second { + Some(current_second) if now_sec > current_second => { + self.push_sample(self.current_count); + for _ in 1..(now_sec - current_second) { + self.push_sample(0); + } + self.current_second = Some(now_sec); + self.current_count = 0; + } + Some(_) => {} + None => { + self.current_second = Some(now_sec); + self.current_count = 0; + } + } + } + + fn sum_recent(&self, window_seconds: usize) -> u64 { + let window = window_seconds.min(self.count); + if window == 0 { + return 0; + } + + let mut total = 0u64; + for i in 0..window { + let idx = if self.write_index >= i + 1 { + self.write_index - i - 1 + } else { + self.capacity - (i + 1 - self.write_index) + }; + if idx < self.samples.len() { + total += self.samples[idx]; + } + } + total + } + + pub(crate) fn last_second(&self) -> u64 { + self.sum_recent(1) + } + + pub(crate) fn last_minute(&self) -> u64 { + self.sum_recent(60) + } + + pub(crate) fn is_idle(&self) -> bool { + self.current_count == 0 && self.sum_recent(self.capacity) == 0 + } +} + impl ThroughputTracker { /// Create a new tracker with the given capacity (seconds of retention). pub fn new(retention_seconds: usize) -> Self { @@ -46,7 +153,8 @@ impl ThroughputTracker { /// Record bytes (called from data flow callbacks). pub fn record_bytes(&self, bytes_in: u64, bytes_out: u64) { self.pending_bytes_in.fetch_add(bytes_in, Ordering::Relaxed); - self.pending_bytes_out.fetch_add(bytes_out, Ordering::Relaxed); + self.pending_bytes_out + .fetch_add(bytes_out, Ordering::Relaxed); } /// Take a sample (called at 1Hz). @@ -229,4 +337,41 @@ mod tests { let history = tracker.history(10); assert!(history.is_empty()); } + + #[test] + fn test_request_rate_tracker_counts_last_second_and_last_minute() { + let mut tracker = RequestRateTracker::new(60); + + tracker.record_events_at(100, 2); + tracker.record_events_at(100, 3); + tracker.advance_to(101); + + assert_eq!(tracker.last_second(), 5); + assert_eq!(tracker.last_minute(), 5); + } + + #[test] + fn test_request_rate_tracker_adds_zero_samples_for_gaps() { + let mut tracker = RequestRateTracker::new(60); + + tracker.record_events_at(100, 4); + tracker.record_events_at(102, 1); + tracker.advance_to(103); + + assert_eq!(tracker.last_second(), 1); + assert_eq!(tracker.last_minute(), 5); + } + + #[test] + fn test_request_rate_tracker_decays_to_zero_over_window() { + let mut tracker = RequestRateTracker::new(60); + + tracker.record_events_at(100, 7); + tracker.advance_to(101); + tracker.advance_to(161); + + assert_eq!(tracker.last_second(), 0); + assert_eq!(tracker.last_minute(), 0); + assert!(tracker.is_idle()); + } } diff --git a/test/test.domain-http-request-rates.node.ts b/test/test.domain-http-request-rates.node.ts new file mode 100644 index 0000000..e6111a5 --- /dev/null +++ b/test/test.domain-http-request-rates.node.ts @@ -0,0 +1,191 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SmartProxy } from '../ts/index.js'; +import * as http from 'http'; +import * as net from 'net'; +import * as tls from 'tls'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { assertPortsFree, findFreePorts } from './helpers/port-allocator.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'); +const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8'); + +let httpBackendPort: number; +let tlsBackendPort: number; +let httpProxyPort: number; +let tlsProxyPort: number; + +let httpBackend: http.Server; +let tlsBackend: tls.Server; +let proxy: SmartProxy; + +async function pollMetrics(proxyToPoll: SmartProxy): Promise { + await (proxyToPoll as any).metricsAdapter.poll(); +} + +async function waitForCondition( + callback: () => Promise, + timeoutMs: number = 5000, + stepMs: number = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await callback()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, stepMs)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); +} + +function hasIpDomainRequest(domain: string): boolean { + const byIp = proxy.getMetrics().connections.domainRequestsByIP(); + for (const domainMap of byIp.values()) { + if (domainMap.has(domain)) { + return true; + } + } + return false; +} + +tap.test('setup - backend servers for HTTP domain rate metrics', async () => { + [httpBackendPort, tlsBackendPort, httpProxyPort, tlsProxyPort] = await findFreePorts(4); + + httpBackend = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(`ok:${body}`); + }); + }); + await new Promise((resolve) => { + httpBackend.listen(httpBackendPort, () => resolve()); + }); + + tlsBackend = tls.createServer({ cert: CERT_PEM, key: KEY_PEM }, (socket) => { + socket.on('data', (data) => { + socket.write(data); + }); + socket.on('error', () => {}); + }); + await new Promise((resolve) => { + tlsBackend.listen(tlsBackendPort, () => resolve()); + }); +}); + +tap.test('setup - start proxy with HTTP and TLS passthrough routes', async () => { + proxy = new SmartProxy({ + routes: [ + { + id: 'http-domain-rates', + name: 'http-domain-rates', + match: { ports: httpProxyPort, domains: 'example.com' }, + action: { + type: 'forward', + targets: [{ host: 'localhost', port: httpBackendPort }], + }, + }, + { + id: 'tls-passthrough-domain-rates', + name: 'tls-passthrough-domain-rates', + match: { ports: tlsProxyPort, domains: 'passthrough.example.com' }, + action: { + type: 'forward', + tls: { mode: 'passthrough' }, + targets: [{ host: 'localhost', port: tlsBackendPort }], + }, + }, + ], + metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 }, + }); + + await proxy.start(); + await new Promise((resolve) => setTimeout(resolve, 300)); +}); + +tap.test('HTTP requests populate per-domain HTTP request rates', async () => { + for (let i = 0; i < 3; i++) { + await new Promise((resolve, reject) => { + const body = `payload-${i}`; + const req = http.request( + { + hostname: 'localhost', + port: httpProxyPort, + path: '/echo', + method: 'POST', + headers: { + Host: 'Example.COM', + 'Content-Type': 'text/plain', + 'Content-Length': String(body.length), + }, + }, + (res) => { + res.resume(); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(body); + }); + } + + await waitForCondition(async () => { + await pollMetrics(proxy); + const domainMetrics = proxy.getMetrics().requests.byDomain().get('example.com'); + return (domainMetrics?.lastMinute ?? 0) >= 3 && (domainMetrics?.perSecond ?? 0) > 0; + }); + + const exampleMetrics = proxy.getMetrics().requests.byDomain().get('example.com'); + expect(exampleMetrics).toBeTruthy(); + expect(exampleMetrics?.lastMinute).toEqual(3); + expect(exampleMetrics?.perSecond).toBeGreaterThan(0); +}); + +tap.test('TLS passthrough SNI does not inflate HTTP domain request rates', async () => { + const tlsClient = tls.connect({ + host: 'localhost', + port: tlsProxyPort, + servername: 'passthrough.example.com', + rejectUnauthorized: false, + }); + + await new Promise((resolve, reject) => { + tlsClient.once('secureConnect', () => resolve()); + tlsClient.once('error', reject); + }); + + const echoPromise = new Promise((resolve, reject) => { + tlsClient.once('data', () => resolve()); + tlsClient.once('error', reject); + }); + tlsClient.write(Buffer.from('hello over tls passthrough')); + await echoPromise; + + await waitForCondition(async () => { + await pollMetrics(proxy); + return hasIpDomainRequest('passthrough.example.com'); + }); + + const requestRates = proxy.getMetrics().requests.byDomain(); + expect(requestRates.has('passthrough.example.com')).toBeFalse(); + expect(requestRates.get('example.com')?.lastMinute).toEqual(3); + expect(hasIpDomainRequest('passthrough.example.com')).toBeTrue(); + + tlsClient.destroy(); +}); + +tap.test('cleanup - stop proxy and close backend servers', async () => { + await proxy.stop(); + await new Promise((resolve) => httpBackend.close(() => resolve())); + await new Promise((resolve) => tlsBackend.close(() => resolve())); + await assertPortsFree([httpBackendPort, tlsBackendPort, httpProxyPort, tlsProxyPort]); +}); + +export default tap.start() diff --git a/test/test.metrics-new.ts b/test/test.metrics-new.ts index 9ca15fa..e83416e 100644 --- a/test/test.metrics-new.ts +++ b/test/test.metrics-new.ts @@ -83,6 +83,9 @@ tap.test('should verify new metrics API structure', async () => { expect(metrics.throughput).toHaveProperty('history'); expect(metrics.throughput).toHaveProperty('byRoute'); expect(metrics.throughput).toHaveProperty('byIP'); + + // Check request methods + expect(metrics.requests).toHaveProperty('byDomain'); }); tap.test('should track active connections', async (tools) => { @@ -273,4 +276,4 @@ tap.test('should clean up resources', async () => { await assertPortsFree([echoServerPort, proxyPort]); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e2a80f9..db9b86a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '27.7.4', + version: '27.8.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/proxies/smart-proxy/models/metrics-types.ts b/ts/proxies/smart-proxy/models/metrics-types.ts index b847a90..562dadb 100644 --- a/ts/proxies/smart-proxy/models/metrics-types.ts +++ b/ts/proxies/smart-proxy/models/metrics-types.ts @@ -29,6 +29,11 @@ export interface IThroughputHistoryPoint { out: number; } +export interface IRequestRateMetrics { + perSecond: number; + lastMinute: number; +} + /** * Main metrics interface with clean, grouped API */ @@ -81,6 +86,7 @@ export interface IMetrics { perSecond(): number; perMinute(): number; total(): number; + byDomain(): Map; }; // Cumulative totals @@ -185,4 +191,4 @@ export interface IByteTracker { bytesOut: number; startTime: number; lastUpdate: number; -} \ No newline at end of file +} diff --git a/ts/proxies/smart-proxy/models/rust-types.ts b/ts/proxies/smart-proxy/models/rust-types.ts index 774a5c9..1fc0eb5 100644 --- a/ts/proxies/smart-proxy/models/rust-types.ts +++ b/ts/proxies/smart-proxy/models/rust-types.ts @@ -134,6 +134,11 @@ export interface IRustBackendMetrics { h2Failures: number; } +export interface IRustHttpDomainRequestMetrics { + requestsPerSecond: number; + requestsLastMinute: number; +} + export interface IRustMetricsSnapshot { activeConnections: number; totalConnections: number; @@ -150,6 +155,7 @@ export interface IRustMetricsSnapshot { totalHttpRequests: number; httpRequestsPerSec: number; httpRequestsPerSecRecent: number; + httpDomainRequests: Record; activeUdpSessions: number; totalUdpSessions: number; totalDatagramsIn: number; diff --git a/ts/proxies/smart-proxy/rust-metrics-adapter.ts b/ts/proxies/smart-proxy/rust-metrics-adapter.ts index ca726e2..2a692d5 100644 --- a/ts/proxies/smart-proxy/rust-metrics-adapter.ts +++ b/ts/proxies/smart-proxy/rust-metrics-adapter.ts @@ -1,6 +1,6 @@ -import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js'; +import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IRequestRateMetrics, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js'; import type { RustProxyBridge } from './rust-proxy-bridge.js'; -import type { IRustBackendMetrics, IRustIpMetrics, IRustMetricsSnapshot, IRustRouteMetrics } from './models/rust-types.js'; +import type { IRustBackendMetrics, IRustHttpDomainRequestMetrics, IRustIpMetrics, IRustMetricsSnapshot, IRustRouteMetrics } from './models/rust-types.js'; /** * Adapts Rust JSON metrics to the IMetrics interface. @@ -219,6 +219,18 @@ export class RustMetricsAdapter implements IMetrics { total: (): number => { return this.cache?.totalHttpRequests ?? this.cache?.totalConnections ?? 0; }, + byDomain: (): Map => { + const result = new Map(); + if (this.cache?.httpDomainRequests) { + for (const [domain, metrics] of Object.entries(this.cache.httpDomainRequests) as Array<[string, IRustHttpDomainRequestMetrics]>) { + result.set(domain, { + perSecond: metrics.requestsPerSecond ?? 0, + lastMinute: metrics.requestsLastMinute ?? 0, + }); + } + } + return result; + }, }; public totals = {