1use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Instant;
11
12use chrono::{Timelike, Utc};
13use parking_lot::RwLock;
14
15mod prometheus;
16pub use prometheus::install_prometheus_extension;
17
18#[derive(Debug)]
23pub struct HourBreakdown {
24 counters: [AtomicU64; 24],
25}
26
27impl HourBreakdown {
28 pub const fn new() -> Self {
29 Self {
30 counters: [const { AtomicU64::new(0) }; 24],
31 }
32 }
33
34 pub fn bump_now(&self) {
36 let hour = Utc::now().hour() as usize;
37 if hour < 24 {
38 self.counters[hour].fetch_add(1, Ordering::Relaxed);
39 }
40 }
41
42 pub fn get(&self, hour: usize) -> u64 {
44 self.counters
45 .get(hour)
46 .map(|a| a.load(Ordering::Relaxed))
47 .unwrap_or(0)
48 }
49
50 pub fn snapshot(&self) -> [u64; 24] {
52 let mut out = [0u64; 24];
53 for (i, c) in self.counters.iter().enumerate() {
54 out[i] = c.load(Ordering::Relaxed);
55 }
56 out
57 }
58}
59
60impl Default for HourBreakdown {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66pub struct GatewayMetrics {
68 pub start_time: Instant,
70
71 pub total_connections: AtomicU64,
74 pub total_disconnections: AtomicU64,
76 pub rejected_connections: AtomicU64,
78
79 pub total_requests: AtomicU64,
82 pub total_request_errors: AtomicU64,
84 pub total_response_bytes: AtomicU64,
86
87 pub backend_reconnects: AtomicU64,
90 pub backend_reconnect_failures: AtomicU64,
92 pub last_reconnect_ms: AtomicU64,
94 pub backend_online: AtomicU64,
96
97 pub backend_pushes_received: AtomicU64,
100 pub client_pushes_sent: AtomicU64,
102 pub client_push_send_failures: AtomicU64,
104 pub qot_client_push_backpressure_drops: AtomicU64,
109 pub qot_client_push_backpressure_drops_by_sub_type: [AtomicU64; 18],
112 pub backend_pushes_cmd_quote: AtomicU64,
115 pub backend_pushes_cmd_trade_legacy: AtomicU64,
117 pub backend_pushes_cmd_trade_new: AtomicU64,
119 pub backend_pushes_cmd_msg_center: AtomicU64,
121 pub backend_pushes_cmd_other: AtomicU64,
123
124 pub backend_pushes_cmd_quote_by_hour: HourBreakdown,
128 pub backend_pushes_cmd_trade_legacy_by_hour: HourBreakdown,
130 pub backend_pushes_cmd_trade_new_by_hour: HourBreakdown,
132 pub backend_pushes_cmd_msg_center_by_hour: HourBreakdown,
134
135 pub qot_subscribe_ops: AtomicU64,
138 pub qot_unsubscribe_ops: AtomicU64,
140
141 pub cold_cache_wait_total: AtomicU64,
148 pub cold_cache_wait_hit: AtomicU64,
150 pub cold_cache_wait_timeout: AtomicU64,
152 pub resubscribe_ops: AtomicU64,
155
156 pub resubscribe_attempts: AtomicU64,
170 pub resubscribe_applied_keys: AtomicU64,
173
174 pub qot_push_dropped_total: AtomicU64,
182 pub qot_push_dropped_by_sub_type: [AtomicU64; 18],
186
187 pub keepalive_timeouts: AtomicU64,
190
191 latency_ring: RwLock<LatencyRing>,
194}
195
196struct LatencyRing {
198 buf: Vec<u64>,
199 pos: usize,
200 count: u64,
201 total_ns: u64,
202}
203
204const LATENCY_RING_SIZE: usize = 1000;
205
206impl LatencyRing {
207 fn new() -> Self {
208 Self {
209 buf: vec![0u64; LATENCY_RING_SIZE],
210 pos: 0,
211 count: 0,
212 total_ns: 0,
213 }
214 }
215
216 fn push(&mut self, ns: u64) {
217 if self.count >= LATENCY_RING_SIZE as u64 {
219 self.total_ns = self.total_ns.saturating_sub(self.buf[self.pos]);
220 }
221 self.buf[self.pos] = ns;
222 self.total_ns += ns;
223 self.pos = (self.pos + 1) % LATENCY_RING_SIZE;
224 self.count += 1;
225 }
226
227 fn stats(&self) -> LatencyStats {
228 let n = self.count.min(LATENCY_RING_SIZE as u64) as usize;
229 if n == 0 {
230 return LatencyStats::default();
231 }
232
233 let mut samples: Vec<u64> = if self.count >= LATENCY_RING_SIZE as u64 {
234 self.buf.clone()
235 } else {
236 self.buf[..n].to_vec()
237 };
238 samples.sort_unstable();
239
240 LatencyStats {
241 count: self.count,
242 avg_us: (self.total_ns / n as u64) / 1000,
243 p50_us: samples[n / 2] / 1000,
244 p95_us: samples[(n as f64 * 0.95) as usize] / 1000,
245 p99_us: samples[(n as f64 * 0.99).min((n - 1) as f64) as usize] / 1000,
246 max_us: samples[n - 1] / 1000,
247 }
248 }
249}
250
251#[derive(Default)]
253pub struct LatencyStats {
254 pub count: u64,
256 pub avg_us: u64,
258 pub p50_us: u64,
260 pub p95_us: u64,
262 pub p99_us: u64,
264 pub max_us: u64,
266}
267
268fn format_hour_row(hb: &HourBreakdown) -> String {
273 let snap = hb.snapshot();
274 let mut out = String::with_capacity(24 * 10);
275 for (i, v) in snap.iter().enumerate() {
276 if i > 0 {
277 out.push(' ');
278 }
279 out.push_str(&format!("h{:02}={}", i, v));
280 }
281 out
282}
283
284fn qot_sub_type_bucket(sub_type: i32) -> usize {
285 if (0..18).contains(&sub_type) {
286 sub_type as usize
287 } else {
288 0
289 }
290}
291
292impl GatewayMetrics {
293 pub fn new() -> Self {
294 Self {
295 start_time: Instant::now(),
296 total_connections: AtomicU64::new(0),
297 total_disconnections: AtomicU64::new(0),
298 rejected_connections: AtomicU64::new(0),
299 total_requests: AtomicU64::new(0),
300 total_request_errors: AtomicU64::new(0),
301 total_response_bytes: AtomicU64::new(0),
302 backend_reconnects: AtomicU64::new(0),
303 backend_reconnect_failures: AtomicU64::new(0),
304 last_reconnect_ms: AtomicU64::new(0),
305 backend_online: AtomicU64::new(1),
306 backend_pushes_received: AtomicU64::new(0),
307 client_pushes_sent: AtomicU64::new(0),
308 client_push_send_failures: AtomicU64::new(0),
309 qot_client_push_backpressure_drops: AtomicU64::new(0),
310 qot_client_push_backpressure_drops_by_sub_type: [const { AtomicU64::new(0) }; 18],
311 backend_pushes_cmd_quote: AtomicU64::new(0),
312 backend_pushes_cmd_trade_legacy: AtomicU64::new(0),
313 backend_pushes_cmd_trade_new: AtomicU64::new(0),
314 backend_pushes_cmd_msg_center: AtomicU64::new(0),
315 backend_pushes_cmd_other: AtomicU64::new(0),
316 backend_pushes_cmd_quote_by_hour: HourBreakdown::new(),
317 backend_pushes_cmd_trade_legacy_by_hour: HourBreakdown::new(),
318 backend_pushes_cmd_trade_new_by_hour: HourBreakdown::new(),
319 backend_pushes_cmd_msg_center_by_hour: HourBreakdown::new(),
320 qot_subscribe_ops: AtomicU64::new(0),
321 qot_unsubscribe_ops: AtomicU64::new(0),
322 cold_cache_wait_total: AtomicU64::new(0),
323 cold_cache_wait_hit: AtomicU64::new(0),
324 cold_cache_wait_timeout: AtomicU64::new(0),
325 resubscribe_ops: AtomicU64::new(0),
326 resubscribe_attempts: AtomicU64::new(0),
327 resubscribe_applied_keys: AtomicU64::new(0),
328 qot_push_dropped_total: AtomicU64::new(0),
330 qot_push_dropped_by_sub_type: [const { AtomicU64::new(0) }; 18],
331 keepalive_timeouts: AtomicU64::new(0),
332 latency_ring: RwLock::new(LatencyRing::new()),
333 }
334 }
335
336 pub fn record_latency_ns(&self, ns: u64) {
338 self.latency_ring.write().push(ns);
339 }
340
341 pub fn record_qot_push_dropped(&self, sub_type: i32) {
346 self.qot_push_dropped_total.fetch_add(1, Ordering::Relaxed);
347 let bucket = qot_sub_type_bucket(sub_type);
348 self.qot_push_dropped_by_sub_type[bucket].fetch_add(1, Ordering::Relaxed);
349 }
350
351 pub fn record_qot_client_push_backpressure_drop(&self, sub_type: i32) {
353 self.qot_client_push_backpressure_drops
354 .fetch_add(1, Ordering::Relaxed);
355 let bucket = qot_sub_type_bucket(sub_type);
356 self.qot_client_push_backpressure_drops_by_sub_type[bucket].fetch_add(1, Ordering::Relaxed);
357 }
358
359 pub fn qot_push_dropped_per_sub_type(&self) -> [u64; 18] {
362 let mut out = [0u64; 18];
363 for (i, slot) in self.qot_push_dropped_by_sub_type.iter().enumerate() {
364 out[i] = slot.load(Ordering::Relaxed);
365 }
366 out
367 }
368
369 pub fn qot_client_push_backpressure_drops_per_sub_type(&self) -> [u64; 18] {
370 let mut out = [0u64; 18];
371 for (i, slot) in self
372 .qot_client_push_backpressure_drops_by_sub_type
373 .iter()
374 .enumerate()
375 {
376 out[i] = slot.load(Ordering::Relaxed);
377 }
378 out
379 }
380
381 pub fn latency_stats(&self) -> LatencyStats {
383 self.latency_ring.read().stats()
384 }
385
386 pub fn uptime_str(&self) -> String {
388 let elapsed = self.start_time.elapsed();
389 let secs = elapsed.as_secs();
390 let days = secs / 86400;
391 let hours = (secs % 86400) / 3600;
392 let mins = (secs % 3600) / 60;
393 let s = secs % 60;
394 if days > 0 {
395 format!("{days}d {hours}h {mins}m {s}s")
396 } else if hours > 0 {
397 format!("{hours}h {mins}m {s}s")
398 } else {
399 format!("{mins}m {s}s")
400 }
401 }
402
403 pub fn report(&self) -> String {
405 let lat = self.latency_stats();
406 let backend_status = if self.backend_online.load(Ordering::Relaxed) == 1 {
407 "ONLINE"
408 } else {
409 "OFFLINE"
410 };
411
412 let total_req = self.total_requests.load(Ordering::Relaxed);
413 let uptime_secs = self.start_time.elapsed().as_secs_f64();
414 let avg_rps = if uptime_secs > 0.0 {
415 total_req as f64 / uptime_secs
416 } else {
417 0.0
418 };
419
420 format!(
421 "=== Gateway Metrics ===\r\n\
422 Uptime: {uptime}\r\n\
423 \r\n\
424 [Connections]\r\n\
425 total_accepted: {total_conn}\r\n\
426 total_disconnected: {total_disconn}\r\n\
427 rejected (limit): {rejected}\r\n\
428 keepalive_timeouts: {ka_timeout}\r\n\
429 \r\n\
430 [Requests]\r\n\
431 total_requests: {total_req}\r\n\
432 total_errors: {total_err}\r\n\
433 avg_rps: {avg_rps:.1}\r\n\
434 response_bytes: {resp_bytes}\r\n\
435 \r\n\
436 [Latency (recent {lat_count} samples)]\r\n\
437 avg: {lat_avg}us p50: {lat_p50}us p95: {lat_p95}us p99: {lat_p99}us max: {lat_max}us\r\n\
438 \r\n\
439 [Backend]\r\n\
440 status: {backend_status}\r\n\
441 reconnects: {reconnects}\r\n\
442 reconnect_failures: {reconnect_fail}\r\n\
443 pushes_received: {push_recv}\r\n\
444 pushes_sent_to_clients: {push_sent}\r\n\
445 push_send_failures_to_clients: {push_send_failures}\r\n\
446 qot_client_push_backpressure_drops: {qot_client_backpressure_drops}\r\n\
447 \r\n\
448 [Pushes by CMD (v1.4.83 §14)]\r\n\
449 cmd_6212_quote: {push_cmd_quote}\r\n\
450 cmd_4716_trade_legacy: {push_cmd_trade_legacy}\r\n\
451 cmd_14716_trade_new: {push_cmd_trade_new}\r\n\
452 cmd_5300_msg_center: {push_cmd_msg_center}\r\n\
453 cmd_other: {push_cmd_other}\r\n\
454 \r\n\
455 [Pushes by CMD × UTC hour (v1.4.84 §14)]\r\n\
456 cmd_14716_trade_new_hour_0..23: {hour_trade_new}\r\n\
457 cmd_6212_quote_hour_0..23: {hour_quote}\r\n\
458 cmd_4716_trade_legacy_hour_0..23: {hour_trade_legacy}\r\n\
459 cmd_5300_msg_center_hour_0..23: {hour_msg_center}\r\n\
460 \r\n\
461 [Subscriptions]\r\n\
462 subscribe_ops: {sub_ops}\r\n\
463 unsubscribe_ops: {unsub_ops}\r\n\
464 resubscribe_ops: {resub_ops}\r\n\
465 \r\n\
466 [Cold-cache wait (v1.4.110 §P3 #19)]\r\n\
467 total: {cc_total} hit: {cc_hit} timeout: {cc_timeout}\r\n",
468 uptime = self.uptime_str(),
469 total_conn = self.total_connections.load(Ordering::Relaxed),
470 total_disconn = self.total_disconnections.load(Ordering::Relaxed),
471 rejected = self.rejected_connections.load(Ordering::Relaxed),
472 ka_timeout = self.keepalive_timeouts.load(Ordering::Relaxed),
473 total_req = total_req,
474 total_err = self.total_request_errors.load(Ordering::Relaxed),
475 resp_bytes = self.total_response_bytes.load(Ordering::Relaxed),
476 lat_count = lat.count.min(LATENCY_RING_SIZE as u64),
477 lat_avg = lat.avg_us,
478 lat_p50 = lat.p50_us,
479 lat_p95 = lat.p95_us,
480 lat_p99 = lat.p99_us,
481 lat_max = lat.max_us,
482 reconnects = self.backend_reconnects.load(Ordering::Relaxed),
483 reconnect_fail = self.backend_reconnect_failures.load(Ordering::Relaxed),
484 push_recv = self.backend_pushes_received.load(Ordering::Relaxed),
485 push_sent = self.client_pushes_sent.load(Ordering::Relaxed),
486 push_send_failures = self.client_push_send_failures.load(Ordering::Relaxed),
487 qot_client_backpressure_drops = self
488 .qot_client_push_backpressure_drops
489 .load(Ordering::Relaxed),
490 push_cmd_quote = self.backend_pushes_cmd_quote.load(Ordering::Relaxed),
491 push_cmd_trade_legacy = self.backend_pushes_cmd_trade_legacy.load(Ordering::Relaxed),
492 push_cmd_trade_new = self.backend_pushes_cmd_trade_new.load(Ordering::Relaxed),
493 push_cmd_msg_center = self.backend_pushes_cmd_msg_center.load(Ordering::Relaxed),
494 push_cmd_other = self.backend_pushes_cmd_other.load(Ordering::Relaxed),
495 hour_trade_new = format_hour_row(&self.backend_pushes_cmd_trade_new_by_hour),
496 hour_quote = format_hour_row(&self.backend_pushes_cmd_quote_by_hour),
497 hour_trade_legacy = format_hour_row(&self.backend_pushes_cmd_trade_legacy_by_hour),
498 hour_msg_center = format_hour_row(&self.backend_pushes_cmd_msg_center_by_hour),
499 sub_ops = self.qot_subscribe_ops.load(Ordering::Relaxed),
500 unsub_ops = self.qot_unsubscribe_ops.load(Ordering::Relaxed),
501 resub_ops = self.resubscribe_ops.load(Ordering::Relaxed),
502 cc_total = self.cold_cache_wait_total.load(Ordering::Relaxed),
503 cc_hit = self.cold_cache_wait_hit.load(Ordering::Relaxed),
504 cc_timeout = self.cold_cache_wait_timeout.load(Ordering::Relaxed),
505 )
506 }
507}
508
509impl Default for GatewayMetrics {
510 fn default() -> Self {
511 Self::new()
512 }
513}
514
515#[cfg(test)]
516mod tests;