feat: Prometheus metrics on relay + web bridge, client JSONL export (T5-S1/S3/S4)

WZP-P2-T5-S1: Relay Prometheus /metrics
- RelayMetrics: active_sessions, active_rooms, packets/bytes_forwarded,
  auth_attempts (ok/fail), handshake_duration histogram
- --metrics-port flag spawns HTTP server
- Wired into auth, handshake, session, and packet forwarding paths
- 2 tests

WZP-P2-T5-S3: Web bridge Prometheus /metrics
- WebMetrics: active_connections, frames_bridged (up/down),
  auth_failures, handshake_latency histogram
- Added /metrics route to existing axum app
- Wired into WS connect/disconnect, auth, handshake, send/recv loops
- 2 tests

WZP-P2-T5-S4: Client --metrics-file JSONL
- ClientMetricsSnapshot with all telemetry fields
- MetricsWriter: writes one JSON line per second to file
- snapshot_from_stats() converts JitterStats to snapshot
- --metrics-file <path> flag
- 3 tests

223 tests passing across all crates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 12:44:57 +04:00
parent 3f813cd510
commit 39f6908478
14 changed files with 645 additions and 12 deletions

View File

@@ -46,6 +46,7 @@ struct CliArgs {
mnemonic: Option<String>,
room: Option<String>,
token: Option<String>,
metrics_file: Option<String>,
}
impl CliArgs {
@@ -86,6 +87,7 @@ fn parse_args() -> CliArgs {
let mut mnemonic = None;
let mut room = None;
let mut token = None;
let mut metrics_file = None;
let mut relay_str = None;
let mut i = 1;
@@ -132,6 +134,14 @@ fn parse_args() -> CliArgs {
i += 1;
token = Some(args.get(i).expect("--token requires a value").to_string());
}
"--metrics-file" => {
i += 1;
metrics_file = Some(
args.get(i)
.expect("--metrics-file requires a path")
.to_string(),
);
}
"--record" => {
i += 1;
record_file = Some(
@@ -174,6 +184,7 @@ fn parse_args() -> CliArgs {
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
eprintln!(" --token <token> featherChat bearer token for relay auth");
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
eprintln!();
eprintln!("Default relay: 127.0.0.1:4433");
@@ -209,6 +220,7 @@ fn parse_args() -> CliArgs {
mnemonic,
room,
token,
metrics_file,
}
}

View File

@@ -14,6 +14,7 @@ pub mod drift_test;
pub mod echo_test;
pub mod featherchat;
pub mod handshake;
pub mod metrics;
pub mod sweep;
#[cfg(feature = "audio")]

View File

@@ -0,0 +1,186 @@
//! Client-side JSONL metrics export.
//!
//! When `--metrics-file <path>` is passed, the client writes one JSON object
//! per second to the specified file. Each line is a self-contained JSON object
//! (JSONL format) containing jitter buffer stats, loss, and quality profile.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::time::{Duration, Instant};
use serde::Serialize;
use wzp_proto::jitter::JitterStats;
/// A single metrics snapshot written as one JSONL line.
#[derive(Serialize)]
pub struct ClientMetricsSnapshot {
pub ts: String,
pub buffer_depth: usize,
pub underruns: u64,
pub overruns: u64,
pub loss_pct: f64,
pub rtt_ms: u64,
pub jitter_ms: u64,
pub frames_sent: u64,
pub frames_received: u64,
pub quality_profile: String,
}
/// Periodic JSONL writer that respects a configurable interval.
pub struct MetricsWriter {
file: File,
interval: Duration,
last_write: Instant,
}
impl MetricsWriter {
/// Create a new `MetricsWriter` that appends JSONL to the given path.
///
/// The file is created (or truncated) immediately.
pub fn new(path: &str, interval_secs: u64) -> Result<Self, anyhow::Error> {
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
Ok(Self {
file,
interval: Duration::from_secs(interval_secs),
// Set last_write far in the past so the first call writes immediately.
last_write: Instant::now() - Duration::from_secs(interval_secs + 1),
})
}
/// Write a JSONL line if the interval has elapsed since the last write.
///
/// Returns `Ok(true)` when a line was written, `Ok(false)` when skipped.
pub fn maybe_write(&mut self, snapshot: &ClientMetricsSnapshot) -> Result<bool, anyhow::Error> {
let now = Instant::now();
if now.duration_since(self.last_write) >= self.interval {
let line = serde_json::to_string(snapshot)?;
writeln!(self.file, "{}", line)?;
self.file.flush()?;
self.last_write = now;
Ok(true)
} else {
Ok(false)
}
}
}
/// Build a `ClientMetricsSnapshot` from jitter buffer stats and a quality profile name.
///
/// Fields not available from `JitterStats` alone (rtt_ms, jitter_ms, frames_sent)
/// are set to zero — the caller can override them if the data is available.
pub fn snapshot_from_stats(stats: &JitterStats, profile: &str) -> ClientMetricsSnapshot {
let loss_pct = if stats.packets_received > 0 {
(stats.packets_lost as f64 / stats.packets_received as f64) * 100.0
} else {
0.0
};
ClientMetricsSnapshot {
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
buffer_depth: stats.current_depth,
underruns: stats.underruns,
overruns: stats.overruns,
loss_pct,
rtt_ms: 0,
jitter_ms: 0,
frames_sent: 0,
frames_received: stats.total_decoded,
quality_profile: profile.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_stats() -> JitterStats {
JitterStats {
packets_received: 100,
packets_played: 95,
packets_lost: 5,
packets_late: 2,
packets_duplicate: 0,
current_depth: 8,
total_decoded: 93,
underruns: 1,
overruns: 0,
max_depth_seen: 12,
}
}
#[test]
fn snapshot_serializes_to_json() {
let stats = make_test_stats();
let snap = snapshot_from_stats(&stats, "GOOD");
let json = serde_json::to_string(&snap).unwrap();
// Verify expected fields are present in the JSON string.
assert!(json.contains("\"ts\""));
assert!(json.contains("\"buffer_depth\":8"));
assert!(json.contains("\"underruns\":1"));
assert!(json.contains("\"overruns\":0"));
assert!(json.contains("\"loss_pct\":5."));
assert!(json.contains("\"rtt_ms\":0"));
assert!(json.contains("\"jitter_ms\":0"));
assert!(json.contains("\"frames_sent\":0"));
assert!(json.contains("\"frames_received\":93"));
assert!(json.contains("\"quality_profile\":\"GOOD\""));
// Verify it round-trips as valid JSON.
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["buffer_depth"], 8);
assert_eq!(value["quality_profile"], "GOOD");
}
#[test]
fn metrics_writer_creates_file() {
let dir = std::env::temp_dir();
let path = dir.join("wzp_metrics_test.jsonl");
let path_str = path.to_str().unwrap();
let mut writer = MetricsWriter::new(path_str, 1).unwrap();
let stats = make_test_stats();
let snap = snapshot_from_stats(&stats, "DEGRADED");
let wrote = writer.maybe_write(&snap).unwrap();
assert!(wrote, "first write should succeed immediately");
// Read the file back and verify it contains valid JSONL.
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 1, "should have exactly one JSONL line");
let value: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(value["quality_profile"], "DEGRADED");
assert_eq!(value["buffer_depth"], 8);
// Clean up.
let _ = std::fs::remove_file(&path);
}
#[test]
fn metrics_writer_respects_interval() {
let dir = std::env::temp_dir();
let path = dir.join("wzp_metrics_interval_test.jsonl");
let path_str = path.to_str().unwrap();
let mut writer = MetricsWriter::new(path_str, 60).unwrap();
let stats = make_test_stats();
let snap = snapshot_from_stats(&stats, "GOOD");
// First write succeeds (last_write is set far in the past).
let first = writer.maybe_write(&snap).unwrap();
assert!(first, "first write should succeed");
// Immediate second write should be skipped (60s interval).
let second = writer.maybe_write(&snap).unwrap();
assert!(!second, "second write should be skipped — interval not elapsed");
// Clean up.
let _ = std::fs::remove_file(&path);
}
}