Skip to main content

myko_server/
ws_timing.rs

1//! Lightweight WS message-throughput instrumentation.
2//!
3//! Counts inbound (client → server) and outbound (server → client) WS messages
4//! per kind into atomic counters, and a single dedicated thread emits a
5//! summary log line every `WINDOW_MS`. No per-message log I/O, no allocations
6//! on the hot path, no work when no messages flowed.
7//!
8//! Used to diagnose "server CPU is idle but loads are slow" — comparing the
9//! inbound and outbound rates against the client-side equivalents tells us
10//! whether time is in server-reply latency, client-send pacing, or round-trip.
11
12use std::{
13    sync::{
14        OnceLock,
15        atomic::{AtomicU64, Ordering},
16    },
17    thread,
18    time::Duration,
19};
20
21use dashmap::DashMap;
22use myko::wire::MykoMessage;
23use opentelemetry::{KeyValue, metrics::Counter};
24
25const WINDOW_MS: u64 = 250;
26
27fn per_client_counter() -> &'static Counter<u64> {
28    static COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
29    COUNTER.get_or_init(|| {
30        opentelemetry::global::meter("myko-server")
31            .u64_counter("myko.ws.message.count")
32            .with_description("WS messages by direction, kind, and client")
33            .build()
34    })
35}
36
37fn in_counts() -> &'static DashMap<&'static str, AtomicU64> {
38    static C: OnceLock<DashMap<&'static str, AtomicU64>> = OnceLock::new();
39    C.get_or_init(DashMap::new)
40}
41
42fn out_counts() -> &'static DashMap<&'static str, AtomicU64> {
43    static C: OnceLock<DashMap<&'static str, AtomicU64>> = OnceLock::new();
44    C.get_or_init(DashMap::new)
45}
46
47/// Record an inbound WS message (already parsed). `kind` should be the
48/// `'static` discriminant string from `message_kind`.
49pub fn record_inbound(kind: &'static str) {
50    in_counts()
51        .entry(kind)
52        .or_insert_with(|| AtomicU64::new(0))
53        .fetch_add(1, Ordering::Relaxed);
54}
55
56/// Record an outbound WS message about to be serialized to the wire.
57pub fn record_outbound(kind: &'static str) {
58    out_counts()
59        .entry(kind)
60        .or_insert_with(|| AtomicU64::new(0))
61        .fetch_add(1, Ordering::Relaxed);
62}
63
64/// Same as [`record_inbound`], plus an OTLP counter tagged by `client_id` and
65/// `tag` (the specific command/query/report/view id, from [`message_tag`],
66/// when the message carries one) — the aggregate `DashMap` counters above
67/// are cheap enough for an unbounded number of message kinds, but tagging
68/// *those* by client_id/tag too would put an OTel series per (kind × client
69/// × tag) into the periodic in-process log line, which is the wrong place
70/// for this breakdown. This is that breakdown, exported as a proper metric
71/// instead (cardinality bounded by concurrent connections × distinct
72/// command/query/report/view ids, which is the norm for these tags).
73pub fn record_inbound_for_client(kind: &'static str, client_id: &str, tag: Option<&str>) {
74    record_inbound(kind);
75    let mut attrs = vec![
76        KeyValue::new("direction", "in"),
77        KeyValue::new("kind", kind),
78        KeyValue::new("client_id", client_id.to_string()),
79    ];
80    if let Some(tag) = tag {
81        attrs.push(KeyValue::new("tag", tag.to_string()));
82    }
83    per_client_counter().add(1, &attrs);
84}
85
86/// Same as [`record_outbound`], plus a per-client/per-tag OTLP counter — see
87/// [`record_inbound_for_client`].
88pub fn record_outbound_for_client(kind: &'static str, client_id: &str, tag: Option<&str>) {
89    record_outbound(kind);
90    let mut attrs = vec![
91        KeyValue::new("direction", "out"),
92        KeyValue::new("kind", kind),
93        KeyValue::new("client_id", client_id.to_string()),
94    ];
95    if let Some(tag) = tag {
96        attrs.push(KeyValue::new("tag", tag.to_string()));
97    }
98    per_client_counter().add(1, &attrs);
99}
100
101/// The specific command/query/report/view id carried by a message, when the
102/// wire type carries one directly. `*Request` and `*Error` variants carry
103/// their id inline; `*Response`/`*Cancel`/`*Window` variants only carry
104/// `tx` (the id lives in a tx→id side table the caller already tracks per
105/// subscription) — those return `None` here rather than duplicating that
106/// lookup.
107pub fn message_tag(msg: &MykoMessage) -> Option<&str> {
108    match msg {
109        MykoMessage::Query(w) => Some(&w.query_id),
110        MykoMessage::QueryError(e) => Some(&e.query_id),
111        MykoMessage::View(w) => Some(&w.view_id),
112        MykoMessage::ViewError(e) => Some(&e.view_id),
113        MykoMessage::Report(w) => Some(&w.report_id),
114        MykoMessage::ReportError(e) => Some(&e.report_id),
115        MykoMessage::Command(w) => Some(&w.command_id),
116        MykoMessage::CommandError(e) => Some(&e.command_id),
117        _ => None,
118    }
119}
120
121/// Stable `'static` kind tag for a message. Must match what the TS client
122/// emits for symmetric cross-side correlation.
123pub fn message_kind(msg: &MykoMessage) -> &'static str {
124    match msg {
125        MykoMessage::Query(_) => "Query",
126        MykoMessage::QueryResponse(_) => "QueryResponse",
127        MykoMessage::QueryCancel(_) => "QueryCancel",
128        MykoMessage::QueryWindow(_) => "QueryWindow",
129        MykoMessage::QueryError(_) => "QueryError",
130        MykoMessage::View(_) => "View",
131        MykoMessage::ViewResponse(_) => "ViewResponse",
132        MykoMessage::ViewCancel(_) => "ViewCancel",
133        MykoMessage::ViewWindow(_) => "ViewWindow",
134        MykoMessage::ViewError(_) => "ViewError",
135        MykoMessage::Report(_) => "Report",
136        MykoMessage::ReportResponse(_) => "ReportResponse",
137        MykoMessage::ReportCancel(_) => "ReportCancel",
138        MykoMessage::ReportError(_) => "ReportError",
139        MykoMessage::Event(_) => "Event",
140        MykoMessage::EventBatch(_) => "EventBatch",
141        MykoMessage::Command(_) => "Command",
142        MykoMessage::CommandResponse(_) => "CommandResponse",
143        MykoMessage::CommandError(_) => "CommandError",
144        MykoMessage::Ping(_) => "Ping",
145        MykoMessage::Benchmark(_) => "Benchmark",
146    }
147}
148
149/// Spawn the dedicated summary thread. Idempotent — safe to call from any
150/// number of `CellServerCtx::new` invocations.
151pub fn start_periodic_logger() {
152    static STARTED: OnceLock<()> = OnceLock::new();
153    if STARTED.set(()).is_err() {
154        return;
155    }
156
157    let _ = thread::Builder::new()
158        .name("myko-ws-timing".to_string())
159        .spawn(run_logger_loop)
160        .map_err(|e| {
161            tracing::warn!(
162                target: "myko_server::ws_timing",
163                "Failed to spawn ws_timing thread: {}", e
164            )
165        });
166}
167
168fn run_logger_loop() {
169    loop {
170        thread::sleep(Duration::from_millis(WINDOW_MS));
171        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(emit_window));
172    }
173}
174
175fn emit_window() {
176    let in_snap = drain_counts(in_counts());
177    let out_snap = drain_counts(out_counts());
178    if in_snap.is_empty() && out_snap.is_empty() {
179        return;
180    }
181    let in_total: u64 = in_snap.iter().map(|(_, n)| *n).sum();
182    let out_total: u64 = out_snap.iter().map(|(_, n)| *n).sum();
183    tracing::info!(
184        target: "myko_server::ws_timing",
185        "[ws_timing window={}ms] in={} [{}] out={} [{}]",
186        WINDOW_MS,
187        in_total,
188        format_kinds(&in_snap),
189        out_total,
190        format_kinds(&out_snap),
191    );
192}
193
194fn drain_counts(counts: &DashMap<&'static str, AtomicU64>) -> Vec<(&'static str, u64)> {
195    let mut out: Vec<(&'static str, u64)> = counts
196        .iter()
197        .filter_map(|e| {
198            let n = e.value().swap(0, Ordering::Relaxed);
199            if n == 0 { None } else { Some((*e.key(), n)) }
200        })
201        .collect();
202    out.sort_by_key(|b| std::cmp::Reverse(b.1));
203    out
204}
205
206fn format_kinds(snap: &[(&'static str, u64)]) -> String {
207    snap.iter()
208        .map(|(k, n)| format!("{}={}", k, n))
209        .collect::<Vec<_>>()
210        .join(", ")
211}