[Fix] Syncronized Webserver & Host behaviour, Fixed the 10 sec default wait on auth
This commit is contained in:
parent
bcf8aee371
commit
cab2cd7a52
22 changed files with 2912 additions and 1011 deletions
1
example/.gitignore
vendored
1
example/.gitignore
vendored
|
|
@ -14,3 +14,4 @@ web-client/dist/
|
|||
client.id
|
||||
*.mk
|
||||
*.mpkb
|
||||
metrics/
|
||||
|
|
|
|||
4
example/Cargo.lock
generated
4
example/Cargo.lock
generated
|
|
@ -237,6 +237,8 @@ version = "0.2.0"
|
|||
dependencies = [
|
||||
"mtp",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
|
@ -1988,6 +1990,8 @@ dependencies = [
|
|||
"hex",
|
||||
"http",
|
||||
"mtp",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "fil
|
|||
tokio = { version = "1", features = ["full"] }
|
||||
rand = "0.10.1"
|
||||
tracing-subscriber = "0.3.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ pub async fn connect_or_register(
|
|||
mut config: ClientConfig,
|
||||
host_public_key: PublicKeyBundle,
|
||||
key_prefix: &str,
|
||||
) -> Result<(MTPConnection, Keyring), Box<dyn std::error::Error>> {
|
||||
) -> Result<(MTPConnection, Keyring, String, Duration), Box<dyn std::error::Error>> {
|
||||
let keyring_path = format!("{key_prefix}.mk");
|
||||
let id_path = format!("{key_prefix}.id");
|
||||
|
||||
|
|
@ -30,12 +30,12 @@ pub async fn connect_or_register(
|
|||
config.client_id = client_id;
|
||||
let auth_started = Instant::now();
|
||||
let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?;
|
||||
let auth_duration = auth_started.elapsed();
|
||||
println!(
|
||||
"Authenticated (version {}) in {:?}",
|
||||
conn.version,
|
||||
auth_started.elapsed()
|
||||
conn.version, auth_duration
|
||||
);
|
||||
return Ok((conn, keyring));
|
||||
return Ok((conn, keyring, "connect".into(), auth_duration));
|
||||
}
|
||||
|
||||
println!("No existing keys found: registering new client");
|
||||
|
|
@ -52,12 +52,14 @@ pub async fn connect_or_register(
|
|||
sig_sk,
|
||||
);
|
||||
|
||||
let reg_started = Instant::now();
|
||||
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
|
||||
println!("Registered with ID: {}", conn.client_id);
|
||||
let reg_duration = reg_started.elapsed();
|
||||
println!("Registered with ID: {} in {:?}", conn.client_id, reg_duration);
|
||||
|
||||
save_keyring_raw(&keyring, &keyring_path)?;
|
||||
fs::write(&id_path, conn.client_id.to_string()).await?;
|
||||
println!("Saved client keys -> {keyring_path}");
|
||||
|
||||
Ok((conn, keyring))
|
||||
Ok((conn, keyring, "register".into(), reg_duration))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
mod auth;
|
||||
mod metrics;
|
||||
mod messages;
|
||||
mod pipes;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use mtp::client::ClientConfig;
|
||||
use mtp::files::load_public_key_bundle;
|
||||
|
|
@ -36,6 +38,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
};
|
||||
|
||||
let mut client_metrics = metrics::ClientMetrics::load("metrics/client_sessions.json");
|
||||
|
||||
println!("Connecting to 127.0.0.1:8080 ...");
|
||||
|
||||
let config = ClientConfig::new("https://127.0.0.1:8080")
|
||||
|
|
@ -43,11 +47,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.with_description("MTP example client");
|
||||
|
||||
let server_bundle = host_public_key.clone();
|
||||
let (conn, keyring) = auth::connect_or_register(config, host_public_key, "client").await?;
|
||||
messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
||||
let (conn, keyring, auth_method, auth_duration) =
|
||||
match auth::connect_or_register(config, host_public_key, "client").await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let mut builder = metrics::SessionBuilder::new("failed", Duration::from_secs(0));
|
||||
builder.set_error(e.to_string());
|
||||
client_metrics.record_session(builder.build());
|
||||
client_metrics.save("metrics/client_sessions.json");
|
||||
client_metrics.build_overview("metrics/client_overview.json");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration);
|
||||
|
||||
let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
||||
builder.set_message_roundtrip(roundtrip);
|
||||
|
||||
println!("\n--- Pipe demo ---");
|
||||
pipes::run_pipe_demo(&conn, 1).await?;
|
||||
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
|
||||
for result in &pipe_results {
|
||||
builder.add_pipe_result(result.clone());
|
||||
}
|
||||
|
||||
let session_record = builder.build();
|
||||
println!(
|
||||
"\nSession {} complete: auth={}ms, msg_roundtrip={}ms, pipes={} results, pipe_bytes={}",
|
||||
session_record.session_id,
|
||||
session_record.auth_duration_ms,
|
||||
session_record.message_roundtrip_ms,
|
||||
session_record.pipe_results.len(),
|
||||
session_record.total_pipe_bytes,
|
||||
);
|
||||
|
||||
client_metrics.record_session(session_record);
|
||||
client_metrics.save("metrics/client_sessions.json");
|
||||
client_metrics.build_overview("metrics/client_overview.json");
|
||||
|
||||
conn.sender.close().await;
|
||||
println!("\nDone");
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use mtp::client::MTPConnection;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
|
||||
|
|
@ -89,17 +91,22 @@ pub async fn send_and_receive(
|
|||
conn: &MTPConnection,
|
||||
keyring: &Keyring,
|
||||
server_bundle: &PublicKeyBundle,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||
let msg = build_demo_message(conn.client_id, keyring, server_bundle)?;
|
||||
println!("Sending: {msg}");
|
||||
let start = Instant::now();
|
||||
conn.sender.send(&msg).await?;
|
||||
|
||||
match conn.receive().await {
|
||||
Ok(resp) => {
|
||||
let roundtrip = start.elapsed();
|
||||
println!("Received: {resp}");
|
||||
println!("Message round-trip: {:.3}ms", roundtrip.as_secs_f64() * 1000.0);
|
||||
Ok(roundtrip)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Receive error: {e}");
|
||||
Err(e.into())
|
||||
}
|
||||
Err(e) => eprintln!("Receive error: {e}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
554
example/client/src/metrics.rs
Normal file
554
example/client/src/metrics.rs
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = now_epoch_secs();
|
||||
let rand_part: u32 = rand::random();
|
||||
format!("{ts}-{rand_part:08x}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PipeResult {
|
||||
pub size: usize,
|
||||
pub iteration: usize,
|
||||
pub total_ms: f64,
|
||||
pub data_only_ms: f64,
|
||||
pub bytes_matched: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ClientSessionRecord {
|
||||
pub session_id: String,
|
||||
pub timestamp: u64,
|
||||
pub auth_method: String,
|
||||
pub auth_duration_ms: f64,
|
||||
pub error: Option<String>,
|
||||
pub message_roundtrip_ms: f64,
|
||||
pub pipe_results: Vec<PipeResult>,
|
||||
pub total_pipe_bytes: u64,
|
||||
pub overall_pipe_avg_total_ms: f64,
|
||||
pub overall_pipe_avg_data_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClientAggregateStats {
|
||||
pub total_sessions: u64,
|
||||
pub auth_failures: u64,
|
||||
pub avg_auth_duration_ms: f64,
|
||||
pub avg_message_roundtrip_ms: f64,
|
||||
pub avg_pipe_total_ms: f64,
|
||||
pub avg_pipe_data_ms: f64,
|
||||
pub total_pipe_bytes: u64,
|
||||
pub avg_pipe_throughput_mbps: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientOverview {
|
||||
pub total_sessions: u64,
|
||||
pub aggregate: ClientAggregateStats,
|
||||
pub sessions: Vec<ClientSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClientMetricsFile {
|
||||
pub sessions: Vec<ClientSessionRecord>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live metrics state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ClientMetrics {
|
||||
sessions: Vec<ClientSessionRecord>,
|
||||
}
|
||||
|
||||
impl ClientMetrics {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Self {
|
||||
let file = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<ClientMetricsFile>(&s).ok());
|
||||
|
||||
Self {
|
||||
sessions: file.map(|f| f.sessions).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &str) {
|
||||
let data = ClientMetricsFile {
|
||||
sessions: self.sessions.clone(),
|
||||
};
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
|
||||
pub fn record_session(&mut self, record: ClientSessionRecord) {
|
||||
self.sessions.push(record);
|
||||
}
|
||||
|
||||
pub fn build_overview(&self, overview_path: &str) {
|
||||
let total = self.sessions.len() as u64;
|
||||
|
||||
if total == 0 {
|
||||
let overview = ClientOverview {
|
||||
total_sessions: 0,
|
||||
aggregate: ClientAggregateStats::default(),
|
||||
sessions: Vec::new(),
|
||||
};
|
||||
if let Some(parent) = Path::new(overview_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||
let _ = std::fs::write(overview_path, json);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut auth_sum: f64 = 0.0;
|
||||
let mut msg_sum: f64 = 0.0;
|
||||
let mut pipe_total_sum: f64 = 0.0;
|
||||
let mut pipe_data_sum: f64 = 0.0;
|
||||
let mut total_pipe_bytes: u64 = 0;
|
||||
let mut total_pipe_duration_secs: f64 = 0.0;
|
||||
let mut auth_failures: u64 = 0;
|
||||
let mut success_count: u64 = 0;
|
||||
|
||||
for s in &self.sessions {
|
||||
if s.error.is_some() {
|
||||
auth_failures += 1;
|
||||
} else {
|
||||
success_count += 1;
|
||||
auth_sum += s.auth_duration_ms;
|
||||
msg_sum += s.message_roundtrip_ms;
|
||||
pipe_total_sum += s.overall_pipe_avg_total_ms;
|
||||
pipe_data_sum += s.overall_pipe_avg_data_ms;
|
||||
total_pipe_bytes += s.total_pipe_bytes;
|
||||
for pr in &s.pipe_results {
|
||||
total_pipe_duration_secs += pr.total_ms / 1000.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let divisor = if success_count > 0 { success_count } else { 1 };
|
||||
|
||||
let aggregate = ClientAggregateStats {
|
||||
total_sessions: total,
|
||||
auth_failures,
|
||||
avg_auth_duration_ms: auth_sum / divisor as f64,
|
||||
avg_message_roundtrip_ms: msg_sum / divisor as f64,
|
||||
avg_pipe_total_ms: pipe_total_sum / divisor as f64,
|
||||
avg_pipe_data_ms: pipe_data_sum / divisor as f64,
|
||||
total_pipe_bytes,
|
||||
avg_pipe_throughput_mbps: if total_pipe_duration_secs > 0.0 {
|
||||
(total_pipe_bytes as f64 / 1_048_576.0) / total_pipe_duration_secs
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
};
|
||||
|
||||
let overview = ClientOverview {
|
||||
total_sessions: total,
|
||||
aggregate,
|
||||
sessions: self.sessions.clone(),
|
||||
};
|
||||
|
||||
if let Some(parent) = Path::new(overview_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||
let _ = std::fs::write(overview_path, json);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder for constructing a session record piece by piece
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct SessionBuilder {
|
||||
session_id: String,
|
||||
timestamp: u64,
|
||||
auth_method: String,
|
||||
auth_duration_ms: f64,
|
||||
error: Option<String>,
|
||||
message_roundtrip_ms: f64,
|
||||
pipe_results: Vec<PipeResult>,
|
||||
}
|
||||
|
||||
impl SessionBuilder {
|
||||
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
|
||||
Self {
|
||||
session_id: generate_session_id(),
|
||||
timestamp: now_epoch_secs(),
|
||||
auth_method: auth_method.to_string(),
|
||||
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
|
||||
error: None,
|
||||
message_roundtrip_ms: 0.0,
|
||||
pipe_results: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_error(&mut self, error: String) {
|
||||
self.error = Some(error);
|
||||
}
|
||||
|
||||
pub fn set_message_roundtrip(&mut self, duration: Duration) {
|
||||
self.message_roundtrip_ms = duration.as_secs_f64() * 1000.0;
|
||||
}
|
||||
|
||||
pub fn add_pipe_result(&mut self, result: PipeResult) {
|
||||
self.pipe_results.push(result);
|
||||
}
|
||||
|
||||
pub fn build(self) -> ClientSessionRecord {
|
||||
let total_pipe_bytes: u64 = self
|
||||
.pipe_results
|
||||
.iter()
|
||||
.map(|r| r.size as u64)
|
||||
.sum();
|
||||
|
||||
let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.pipe_results.iter().map(|r| r.total_ms).sum::<f64>()
|
||||
/ self.pipe_results.len() as f64
|
||||
};
|
||||
|
||||
let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.pipe_results.iter().map(|r| r.data_only_ms).sum::<f64>()
|
||||
/ self.pipe_results.len() as f64
|
||||
};
|
||||
|
||||
ClientSessionRecord {
|
||||
session_id: self.session_id,
|
||||
timestamp: self.timestamp,
|
||||
auth_method: self.auth_method,
|
||||
auth_duration_ms: self.auth_duration_ms,
|
||||
error: self.error,
|
||||
message_roundtrip_ms: self.message_roundtrip_ms,
|
||||
pipe_results: self.pipe_results,
|
||||
total_pipe_bytes,
|
||||
overall_pipe_avg_total_ms,
|
||||
overall_pipe_avg_data_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn tmp_path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join("mtp_client_metrics_test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join(name).to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipe_result_roundtrip() {
|
||||
let pr = PipeResult {
|
||||
size: 1024,
|
||||
iteration: 0,
|
||||
total_ms: 5.5,
|
||||
data_only_ms: 3.2,
|
||||
bytes_matched: true,
|
||||
};
|
||||
let json = serde_json::to_string(&pr).unwrap();
|
||||
let decoded: PipeResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(pr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_session_roundtrip() {
|
||||
let record = ClientSessionRecord {
|
||||
session_id: "test-session".into(),
|
||||
timestamp: 12345,
|
||||
auth_method: "connect".into(),
|
||||
auth_duration_ms: 42.5,
|
||||
error: None,
|
||||
message_roundtrip_ms: 10.3,
|
||||
pipe_results: vec![
|
||||
PipeResult {
|
||||
size: 64,
|
||||
iteration: 0,
|
||||
total_ms: 1.0,
|
||||
data_only_ms: 0.5,
|
||||
bytes_matched: true,
|
||||
},
|
||||
PipeResult {
|
||||
size: 256,
|
||||
iteration: 0,
|
||||
total_ms: 2.0,
|
||||
data_only_ms: 1.0,
|
||||
bytes_matched: true,
|
||||
},
|
||||
],
|
||||
total_pipe_bytes: 320,
|
||||
overall_pipe_avg_total_ms: 1.5,
|
||||
overall_pipe_avg_data_ms: 0.75,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&record).unwrap();
|
||||
let decoded: ClientSessionRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(record, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_metrics_load_missing() {
|
||||
let metrics = ClientMetrics::load("/nonexistent/path.json");
|
||||
assert!(metrics.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_client_sessions() {
|
||||
let path = tmp_path("multi_session.json");
|
||||
let mut metrics = ClientMetrics::load(&path);
|
||||
|
||||
for i in 0..3 {
|
||||
let mut builder = SessionBuilder::new("connect", Duration::from_millis(10 + i));
|
||||
builder.set_message_roundtrip(Duration::from_millis(5 + i));
|
||||
builder.add_pipe_result(PipeResult {
|
||||
size: 64,
|
||||
iteration: 0,
|
||||
total_ms: 1.0 + i as f64,
|
||||
data_only_ms: 0.5 + i as f64 * 0.5,
|
||||
bytes_matched: true,
|
||||
});
|
||||
metrics.record_session(builder.build());
|
||||
}
|
||||
|
||||
metrics.save(&path);
|
||||
|
||||
let metrics2 = ClientMetrics::load(&path);
|
||||
assert_eq!(metrics2.sessions.len(), 3);
|
||||
assert_eq!(metrics2.sessions[0].auth_method, "connect");
|
||||
assert_eq!(metrics2.sessions[1].pipe_results[0].size, 64);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_overview_stats() {
|
||||
let mut metrics = ClientMetrics::new();
|
||||
|
||||
for i in 0..4 {
|
||||
let mut builder = SessionBuilder::new("connect", Duration::from_millis(20));
|
||||
builder.set_message_roundtrip(Duration::from_millis(10 + i as u64));
|
||||
builder.add_pipe_result(PipeResult {
|
||||
size: 256,
|
||||
iteration: 0,
|
||||
total_ms: 2.0,
|
||||
data_only_ms: 1.0,
|
||||
bytes_matched: true,
|
||||
});
|
||||
metrics.record_session(builder.build());
|
||||
}
|
||||
|
||||
let overview_path = tmp_path("client_overview.json");
|
||||
metrics.build_overview(&overview_path);
|
||||
|
||||
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(overview.total_sessions, 4);
|
||||
assert_eq!(overview.aggregate.avg_auth_duration_ms, 20.0);
|
||||
assert_eq!(overview.aggregate.avg_message_roundtrip_ms, 11.5);
|
||||
assert_eq!(overview.aggregate.avg_pipe_total_ms, 2.0);
|
||||
assert_eq!(overview.aggregate.avg_pipe_data_ms, 1.0);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 1024);
|
||||
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
|
||||
assert_eq!(overview.sessions.len(), 4);
|
||||
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_overview_empty() {
|
||||
let metrics = ClientMetrics::new();
|
||||
let overview_path = tmp_path("client_empty_overview.json");
|
||||
metrics.build_overview(&overview_path);
|
||||
|
||||
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 0);
|
||||
assert!(overview.sessions.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_failure_recording() {
|
||||
let path = tmp_path("auth_failure.json");
|
||||
let overview_path = tmp_path("auth_failure_overview.json");
|
||||
|
||||
let mut metrics = ClientMetrics::load(&path);
|
||||
|
||||
// Successful session
|
||||
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
|
||||
b1.set_message_roundtrip(Duration::from_millis(10));
|
||||
metrics.record_session(b1.build());
|
||||
|
||||
// Failed auth session
|
||||
let mut b2 = SessionBuilder::new("connect", Duration::from_millis(5000));
|
||||
b2.set_error("authentication timed out".into());
|
||||
metrics.record_session(b2.build());
|
||||
|
||||
// Another successful session
|
||||
let mut b3 = SessionBuilder::new("register", Duration::from_millis(100));
|
||||
b3.set_message_roundtrip(Duration::from_millis(8));
|
||||
metrics.record_session(b3.build());
|
||||
|
||||
metrics.save(&path);
|
||||
let metrics2 = ClientMetrics::load(&path);
|
||||
assert_eq!(metrics2.sessions.len(), 3);
|
||||
assert!(metrics2.sessions[0].error.is_none());
|
||||
assert_eq!(
|
||||
metrics2.sessions[1].error.as_deref(),
|
||||
Some("authentication timed out")
|
||||
);
|
||||
assert!(metrics2.sessions[2].error.is_none());
|
||||
|
||||
metrics2.build_overview(&overview_path);
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||
|
||||
assert_eq!(overview.total_sessions, 3);
|
||||
assert_eq!(overview.aggregate.auth_failures, 1);
|
||||
// Averages should only count successful sessions
|
||||
assert!((overview.aggregate.avg_auth_duration_ms - 71.0).abs() < 0.01); // (42+100)/2
|
||||
assert!((overview.aggregate.avg_message_roundtrip_ms - 9.0).abs() < 0.01); // (10+8)/2
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Integration-style tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_pr(size: usize, iteration: usize, total_ms: f64, data_only_ms: f64) -> PipeResult {
|
||||
PipeResult {
|
||||
size,
|
||||
iteration,
|
||||
total_ms,
|
||||
data_only_ms,
|
||||
bytes_matched: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_client_lifecycle() {
|
||||
let path = tmp_path("client_lifecycle.json");
|
||||
let overview_path = tmp_path("client_lifecycle_overview.json");
|
||||
|
||||
let mut metrics = ClientMetrics::load(&path);
|
||||
|
||||
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
|
||||
b1.set_message_roundtrip(Duration::from_millis(10));
|
||||
b1.add_pipe_result(make_pr(64, 0, 1.5, 0.8));
|
||||
b1.add_pipe_result(make_pr(256, 0, 2.5, 1.2));
|
||||
metrics.record_session(b1.build());
|
||||
|
||||
let mut b2 = SessionBuilder::new("register", Duration::from_millis(150));
|
||||
b2.set_message_roundtrip(Duration::from_millis(15));
|
||||
b2.add_pipe_result(make_pr(64, 0, 2.0, 1.0));
|
||||
b2.add_pipe_result(make_pr(1024, 0, 5.0, 3.0));
|
||||
metrics.record_session(b2.build());
|
||||
|
||||
metrics.save(&path);
|
||||
let metrics2 = ClientMetrics::load(&path);
|
||||
assert_eq!(metrics2.sessions.len(), 2);
|
||||
|
||||
let s1 = &metrics2.sessions[0];
|
||||
assert_eq!(s1.auth_method, "connect");
|
||||
assert!((s1.auth_duration_ms - 42.0).abs() < 0.01);
|
||||
assert!((s1.message_roundtrip_ms - 10.0).abs() < 0.01);
|
||||
assert_eq!(s1.pipe_results.len(), 2);
|
||||
assert_eq!(s1.total_pipe_bytes, 320);
|
||||
assert!((s1.overall_pipe_avg_total_ms - 2.0).abs() < 0.01);
|
||||
assert!((s1.overall_pipe_avg_data_ms - 1.0).abs() < 0.01);
|
||||
|
||||
let s2 = &metrics2.sessions[1];
|
||||
assert_eq!(s2.auth_method, "register");
|
||||
assert_eq!(s2.pipe_results.len(), 2);
|
||||
assert_eq!(s2.total_pipe_bytes, 1088);
|
||||
|
||||
metrics2.build_overview(&overview_path);
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 2);
|
||||
assert!((overview.aggregate.avg_auth_duration_ms - 96.0).abs() < 0.01);
|
||||
assert!((overview.aggregate.avg_message_roundtrip_ms - 12.5).abs() < 0.01);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 1408);
|
||||
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_session_accumulation() {
|
||||
let path = tmp_path("client_accumulate.json");
|
||||
let overview_path = tmp_path("client_accumulate_overview.json");
|
||||
|
||||
{
|
||||
let mut metrics = ClientMetrics::load(&path);
|
||||
let mut b = SessionBuilder::new("connect", Duration::from_millis(30));
|
||||
b.set_message_roundtrip(Duration::from_millis(8));
|
||||
b.add_pipe_result(make_pr(64, 0, 1.0, 0.5));
|
||||
metrics.record_session(b.build());
|
||||
metrics.save(&path);
|
||||
}
|
||||
|
||||
{
|
||||
let mut metrics = ClientMetrics::load(&path);
|
||||
assert_eq!(metrics.sessions.len(), 1);
|
||||
let mut b = SessionBuilder::new("register", Duration::from_millis(200));
|
||||
b.set_message_roundtrip(Duration::from_millis(12));
|
||||
b.add_pipe_result(make_pr(1024, 0, 4.0, 2.5));
|
||||
metrics.record_session(b.build());
|
||||
metrics.save(&path);
|
||||
}
|
||||
|
||||
let metrics = ClientMetrics::load(&path);
|
||||
assert_eq!(metrics.sessions.len(), 2);
|
||||
assert_eq!(metrics.sessions[0].auth_method, "connect");
|
||||
assert_eq!(metrics.sessions[1].auth_method, "register");
|
||||
|
||||
metrics.build_overview(&overview_path);
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 2);
|
||||
assert!((overview.aggregate.avg_auth_duration_ms - 115.0).abs() < 0.01);
|
||||
assert!((overview.aggregate.avg_message_roundtrip_ms - 10.0).abs() < 0.01);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 1088);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,16 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||
use tokio::sync::oneshot;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
use crate::metrics::PipeResult;
|
||||
|
||||
pub async fn run_pipe_demo(
|
||||
conn: &MTPConnection,
|
||||
iterations: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<Vec<PipeResult>, Box<dyn std::error::Error>> {
|
||||
let sizes = [64, 256, 1024, 4096];
|
||||
let mut all_elapsed = Vec::with_capacity(sizes.len() * iterations);
|
||||
let mut all_data_only = Vec::with_capacity(sizes.len() * iterations);
|
||||
let mut pipe_results = Vec::with_capacity(sizes.len() * iterations);
|
||||
|
||||
for (i, &size) in sizes.iter().enumerate() {
|
||||
let mut size_elapsed = Vec::with_capacity(iterations);
|
||||
|
|
@ -97,6 +100,14 @@ pub async fn run_pipe_demo(
|
|||
data_only_elapsed.as_secs_f64() * 1000.0,
|
||||
);
|
||||
|
||||
pipe_results.push(PipeResult {
|
||||
size,
|
||||
iteration: run,
|
||||
total_ms: overall_elapsed.as_secs_f64() * 1000.0,
|
||||
data_only_ms: data_only_elapsed.as_secs_f64() * 1000.0,
|
||||
bytes_matched: matches,
|
||||
});
|
||||
|
||||
size_elapsed.push(overall_elapsed);
|
||||
size_data_only.push(data_only_elapsed);
|
||||
all_elapsed.push(overall_elapsed);
|
||||
|
|
@ -123,7 +134,7 @@ pub async fn run_pipe_demo(
|
|||
all_elapsed.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
Ok(pipe_results)
|
||||
}
|
||||
|
||||
/// Helper: average a slice of Durations without overflowing.
|
||||
|
|
|
|||
|
|
@ -15,3 +15,5 @@ serde_json = { version = "1" }
|
|||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
tracing-subscriber = "0.3.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rand = "0.10.1"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod clients;
|
||||
mod handlers;
|
||||
mod keys;
|
||||
mod metrics;
|
||||
mod tls;
|
||||
#[path = "web-server.rs"]
|
||||
mod web_server;
|
||||
|
|
@ -39,7 +40,7 @@ async fn handle_pipe_loopback(
|
|||
mtp::webserver::WebMtpSender,
|
||||
mtp::webserver::H3TransportReceiver,
|
||||
>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let pipe_id = request.id();
|
||||
println!(" [loopback] Accepting pipe {pipe_id} ...");
|
||||
let mut reader = request.accept().await?;
|
||||
|
|
@ -56,7 +57,7 @@ async fn handle_pipe_loopback(
|
|||
let copied = tokio::io::copy(&mut reader, &mut writer).await?;
|
||||
writer.finish_async().await?;
|
||||
println!(" [loopback] Pipe {pipe_id} complete ({copied} bytes)");
|
||||
Ok(())
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -119,6 +120,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
},
|
||||
);
|
||||
|
||||
let metrics = std::sync::Arc::new(metrics::ServerMetrics::load(
|
||||
"metrics/server_sessions.json",
|
||||
));
|
||||
|
||||
println!("Starting integrated MTP web server on port 8080 ...");
|
||||
|
||||
let config = HostConfig::new(
|
||||
|
|
@ -138,8 +143,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
println!("TCP: HTTP/1.1 and HTTP/2");
|
||||
println!("UDP: HTTP/3 and WebTransport");
|
||||
|
||||
while let Some(conn) = host.accept().await? {
|
||||
loop {
|
||||
let conn = match host.accept().await {
|
||||
Ok(Some(conn)) => conn,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
eprintln!("Accept error: {msg}");
|
||||
metrics.record_accept_error();
|
||||
metrics.save("metrics/server_sessions.json");
|
||||
metrics.build_overview("metrics/server_overview.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||
let metrics = Arc::clone(&metrics);
|
||||
metrics.record_connection_version(&conn.version.to_string());
|
||||
tokio::spawn(async move {
|
||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||
println!(
|
||||
|
|
@ -151,12 +170,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
);
|
||||
println!("Client ID: {}", conn.client_id);
|
||||
|
||||
let mut session = metrics.start_session(conn.client_id, desc.to_string());
|
||||
|
||||
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
||||
|
||||
println!("Waiting for messages / pipe requests ...");
|
||||
let mut pipe_open = true;
|
||||
let mut message_open = true;
|
||||
let mut messages_received = 0_u64;
|
||||
let mut exit_reason = "normal".to_string();
|
||||
|
||||
while pipe_open || message_open {
|
||||
let activity = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, async {
|
||||
|
|
@ -165,8 +186,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
pipe_request = conn.receive_pipe(), if pipe_open => {
|
||||
match pipe_request {
|
||||
Ok(request) => {
|
||||
if let Err(error) = handle_pipe_loopback(&conn, request).await {
|
||||
eprintln!(" [loopback] Pipe error: {error}");
|
||||
match handle_pipe_loopback(&conn, request).await {
|
||||
Ok(bytes) => {
|
||||
session.record_pipe(bytes);
|
||||
}
|
||||
Err(error) => {
|
||||
let msg = error.to_string();
|
||||
if msg.contains("denied") {
|
||||
session.record_pipe_denial();
|
||||
}
|
||||
eprintln!(" [loopback] Pipe error: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mtp::common::CommunicationError::StreamClosed)
|
||||
|
|
@ -183,18 +213,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
message = conn.receive(), if message_open => {
|
||||
match message {
|
||||
Ok(message) => {
|
||||
messages_received += 1;
|
||||
println!("Received: {message}");
|
||||
match handlers::process_and_respond(
|
||||
let msg_start = std::time::Instant::now();
|
||||
let result = handlers::process_and_respond(
|
||||
&message,
|
||||
tm,
|
||||
conn.client_public_key.as_ref(),
|
||||
&decrypt_keyring,
|
||||
) {
|
||||
);
|
||||
let latency = msg_start.elapsed();
|
||||
let ok = result.is_ok();
|
||||
session.record_message(latency, ok);
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
println!("Sending: {response}");
|
||||
if let Err(error) = conn.sender.send(&response).await {
|
||||
eprintln!("Send error: {error}");
|
||||
session.record_send_error();
|
||||
pipe_open = false;
|
||||
message_open = false;
|
||||
}
|
||||
|
|
@ -220,16 +256,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.await;
|
||||
|
||||
if activity.is_err() {
|
||||
exit_reason = "idle timeout".to_string();
|
||||
println!("Connection idle timeout reached");
|
||||
break;
|
||||
}
|
||||
if messages_received >= MAX_MESSAGES_PER_CONNECTION {
|
||||
if session.messages_received() >= MAX_MESSAGES_PER_CONNECTION {
|
||||
exit_reason = "message limit".to_string();
|
||||
println!("Connection message limit reached");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Connection closed\n");
|
||||
let record = session.finish(exit_reason);
|
||||
println!(
|
||||
"Connection closed (messages: {}, pipes: {}, duration: {:.1}s)\n",
|
||||
record.messages_received,
|
||||
record.pipes_handled,
|
||||
record.duration_secs
|
||||
);
|
||||
|
||||
metrics.save("metrics/server_sessions.json");
|
||||
metrics.build_overview("metrics/server_overview.json");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
883
example/server/src/metrics.rs
Normal file
883
example/server/src/metrics.rs
Normal file
|
|
@ -0,0 +1,883 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = now_epoch_secs();
|
||||
let rand_part: u32 = rand::random();
|
||||
format!("{ts}-{rand_part:08x}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionRecord {
|
||||
pub session_id: String,
|
||||
pub client_id: u64,
|
||||
pub description: String,
|
||||
pub start_time: u64,
|
||||
pub end_time: u64,
|
||||
pub duration_secs: f64,
|
||||
pub messages_received: u64,
|
||||
pub messages_ok: u64,
|
||||
pub messages_failed: u64,
|
||||
pub pipes_handled: u64,
|
||||
pub pipe_bytes_copied: u64,
|
||||
pub pipe_denials: u64,
|
||||
pub send_errors: u64,
|
||||
pub avg_message_latency_ms: f64,
|
||||
pub max_message_latency_ms: f64,
|
||||
pub exit_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AggregateStats {
|
||||
pub total_connections: u64,
|
||||
pub total_messages: u64,
|
||||
pub total_messages_ok: u64,
|
||||
pub total_messages_failed: u64,
|
||||
pub total_pipes: u64,
|
||||
pub total_pipe_bytes: u64,
|
||||
pub total_pipe_denials: u64,
|
||||
pub total_send_errors: u64,
|
||||
pub total_accept_errors: u64,
|
||||
pub avg_session_duration_secs: f64,
|
||||
pub avg_messages_per_session: f64,
|
||||
pub avg_pipes_per_session: f64,
|
||||
pub avg_message_latency_ms: f64,
|
||||
pub max_message_latency_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Overview {
|
||||
pub total_sessions: u64,
|
||||
pub first_session_timestamp: u64,
|
||||
pub last_session_timestamp: u64,
|
||||
pub aggregate: AggregateStats,
|
||||
pub connection_versions: HashMap<String, u64>,
|
||||
pub sessions: Vec<SessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ServerMetricsFile {
|
||||
pub total_connections: u64,
|
||||
pub total_messages: u64,
|
||||
pub total_messages_ok: u64,
|
||||
pub total_messages_failed: u64,
|
||||
pub total_pipes: u64,
|
||||
pub total_pipe_bytes: u64,
|
||||
pub total_pipe_denials: u64,
|
||||
pub total_send_errors: u64,
|
||||
pub total_accept_errors: u64,
|
||||
pub connection_versions: HashMap<String, u64>,
|
||||
pub sessions: Vec<SessionRecord>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live metrics state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Inner {
|
||||
total_connections: u64,
|
||||
total_messages: u64,
|
||||
total_messages_ok: u64,
|
||||
total_messages_failed: u64,
|
||||
total_pipes: u64,
|
||||
total_pipe_bytes: u64,
|
||||
total_pipe_denials: u64,
|
||||
total_send_errors: u64,
|
||||
total_accept_errors: u64,
|
||||
connection_versions: HashMap<String, u64>,
|
||||
active_connections: u64,
|
||||
completed_sessions: Vec<SessionRecord>,
|
||||
}
|
||||
|
||||
pub struct ServerMetrics {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl ServerMetrics {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner {
|
||||
total_connections: 0,
|
||||
total_messages: 0,
|
||||
total_messages_ok: 0,
|
||||
total_messages_failed: 0,
|
||||
total_pipes: 0,
|
||||
total_pipe_bytes: 0,
|
||||
total_pipe_denials: 0,
|
||||
total_send_errors: 0,
|
||||
total_accept_errors: 0,
|
||||
connection_versions: HashMap::new(),
|
||||
active_connections: 0,
|
||||
completed_sessions: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Self {
|
||||
let file = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<ServerMetricsFile>(&s).ok());
|
||||
|
||||
let mut inner = Inner {
|
||||
total_connections: 0,
|
||||
total_messages: 0,
|
||||
total_messages_ok: 0,
|
||||
total_messages_failed: 0,
|
||||
total_pipes: 0,
|
||||
total_pipe_bytes: 0,
|
||||
total_pipe_denials: 0,
|
||||
total_send_errors: 0,
|
||||
total_accept_errors: 0,
|
||||
connection_versions: HashMap::new(),
|
||||
active_connections: 0,
|
||||
completed_sessions: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(data) = file {
|
||||
inner.total_connections = data.total_connections;
|
||||
inner.total_messages = data.total_messages;
|
||||
inner.total_messages_ok = data.total_messages_ok;
|
||||
inner.total_messages_failed = data.total_messages_failed;
|
||||
inner.total_pipes = data.total_pipes;
|
||||
inner.total_pipe_bytes = data.total_pipe_bytes;
|
||||
inner.total_pipe_denials = data.total_pipe_denials;
|
||||
inner.total_send_errors = data.total_send_errors;
|
||||
inner.total_accept_errors = data.total_accept_errors;
|
||||
inner.connection_versions = data.connection_versions;
|
||||
inner.completed_sessions = data.sessions;
|
||||
}
|
||||
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &str) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let data = self.to_file(&inner);
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
|
||||
fn to_file(&self, inner: &Inner) -> ServerMetricsFile {
|
||||
ServerMetricsFile {
|
||||
total_connections: inner.total_connections,
|
||||
total_messages: inner.total_messages,
|
||||
total_messages_ok: inner.total_messages_ok,
|
||||
total_messages_failed: inner.total_messages_failed,
|
||||
total_pipes: inner.total_pipes,
|
||||
total_pipe_bytes: inner.total_pipe_bytes,
|
||||
total_pipe_denials: inner.total_pipe_denials,
|
||||
total_send_errors: inner.total_send_errors,
|
||||
total_accept_errors: inner.total_accept_errors,
|
||||
connection_versions: inner.connection_versions.clone(),
|
||||
sessions: inner.completed_sessions.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_session(&self, client_id: u64, description: String) -> SessionHandle<'_> {
|
||||
let session_id = generate_session_id();
|
||||
let start = Instant::now();
|
||||
let start_time = now_epoch_secs();
|
||||
|
||||
self.inner.lock().unwrap().total_connections += 1;
|
||||
self.inner.lock().unwrap().active_connections += 1;
|
||||
|
||||
SessionHandle {
|
||||
metrics: self,
|
||||
session_id,
|
||||
client_id,
|
||||
description,
|
||||
start,
|
||||
start_time,
|
||||
messages_received: 0,
|
||||
messages_ok: 0,
|
||||
messages_failed: 0,
|
||||
pipes_handled: 0,
|
||||
pipe_bytes: 0,
|
||||
pipe_denials: 0,
|
||||
send_errors: 0,
|
||||
latencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> ServerMetricsFile {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
self.to_file(&inner)
|
||||
}
|
||||
|
||||
pub fn record_accept_error(&self) {
|
||||
self.inner.lock().unwrap().total_accept_errors += 1;
|
||||
}
|
||||
|
||||
pub fn record_connection_version(&self, version: &str) {
|
||||
*self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.connection_versions
|
||||
.entry(version.to_string())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
pub fn build_overview(&self, overview_path: &str) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let sessions = &inner.completed_sessions;
|
||||
let total = sessions.len() as u64;
|
||||
|
||||
if total == 0 {
|
||||
let overview = Overview {
|
||||
total_sessions: 0,
|
||||
first_session_timestamp: 0,
|
||||
last_session_timestamp: 0,
|
||||
aggregate: AggregateStats::default(),
|
||||
connection_versions: HashMap::new(),
|
||||
sessions: Vec::new(),
|
||||
};
|
||||
if let Some(parent) = Path::new(overview_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||
let _ = std::fs::write(overview_path, json);
|
||||
return;
|
||||
}
|
||||
|
||||
let first_ts = sessions.first().map(|s| s.start_time).unwrap_or(0);
|
||||
let last_ts = sessions.last().map(|s| s.end_time).unwrap_or(0);
|
||||
|
||||
let total_duration: f64 = sessions.iter().map(|s| s.duration_secs).sum();
|
||||
let total_msgs: u64 = sessions.iter().map(|s| s.messages_received).sum();
|
||||
let total_pipes: u64 = sessions.iter().map(|s| s.pipes_handled).sum();
|
||||
|
||||
let mut max_latency: f64 = 0.0;
|
||||
let mut latency_sum: f64 = 0.0;
|
||||
let mut latency_count: u64 = 0;
|
||||
for s in sessions {
|
||||
if s.avg_message_latency_ms > 0.0 {
|
||||
latency_sum += s.avg_message_latency_ms * s.messages_ok as f64;
|
||||
latency_count += s.messages_ok;
|
||||
}
|
||||
if s.max_message_latency_ms > max_latency {
|
||||
max_latency = s.max_message_latency_ms;
|
||||
}
|
||||
}
|
||||
|
||||
let aggregate = AggregateStats {
|
||||
total_connections: inner.total_connections,
|
||||
total_messages: inner.total_messages,
|
||||
total_messages_ok: inner.total_messages_ok,
|
||||
total_messages_failed: inner.total_messages_failed,
|
||||
total_pipes: inner.total_pipes,
|
||||
total_pipe_bytes: inner.total_pipe_bytes,
|
||||
total_pipe_denials: inner.total_pipe_denials,
|
||||
total_send_errors: inner.total_send_errors,
|
||||
total_accept_errors: inner.total_accept_errors,
|
||||
avg_session_duration_secs: total_duration / total as f64,
|
||||
avg_messages_per_session: total_msgs as f64 / total as f64,
|
||||
avg_pipes_per_session: total_pipes as f64 / total as f64,
|
||||
avg_message_latency_ms: if latency_count > 0 {
|
||||
latency_sum / latency_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
max_message_latency_ms: max_latency,
|
||||
};
|
||||
|
||||
let overview = Overview {
|
||||
total_sessions: total,
|
||||
first_session_timestamp: first_ts,
|
||||
last_session_timestamp: last_ts,
|
||||
aggregate,
|
||||
connection_versions: inner.connection_versions.clone(),
|
||||
sessions: sessions.clone(),
|
||||
};
|
||||
|
||||
if let Some(parent) = Path::new(overview_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||
let _ = std::fs::write(overview_path, json);
|
||||
}
|
||||
|
||||
fn finish_session(&self, record: SessionRecord) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.active_connections -= 1;
|
||||
inner.total_messages += record.messages_received;
|
||||
inner.total_messages_ok += record.messages_ok;
|
||||
inner.total_messages_failed += record.messages_failed;
|
||||
inner.total_pipes += record.pipes_handled;
|
||||
inner.total_pipe_bytes += record.pipe_bytes_copied;
|
||||
inner.total_pipe_denials += record.pipe_denials;
|
||||
inner.total_send_errors += record.send_errors;
|
||||
inner.completed_sessions.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session handle — local accumulators, no mutex contention during connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct SessionHandle<'a> {
|
||||
metrics: &'a ServerMetrics,
|
||||
session_id: String,
|
||||
client_id: u64,
|
||||
description: String,
|
||||
start: Instant,
|
||||
start_time: u64,
|
||||
messages_received: u64,
|
||||
messages_ok: u64,
|
||||
messages_failed: u64,
|
||||
pipes_handled: u64,
|
||||
pipe_bytes: u64,
|
||||
pipe_denials: u64,
|
||||
send_errors: u64,
|
||||
latencies: Vec<f64>,
|
||||
}
|
||||
|
||||
impl<'a> SessionHandle<'a> {
|
||||
pub fn messages_received(&self) -> u64 {
|
||||
self.messages_received
|
||||
}
|
||||
|
||||
pub fn record_message(&mut self, latency: Duration, ok: bool) {
|
||||
self.messages_received += 1;
|
||||
if ok {
|
||||
self.messages_ok += 1;
|
||||
} else {
|
||||
self.messages_failed += 1;
|
||||
}
|
||||
self.latencies.push(latency.as_secs_f64() * 1000.0);
|
||||
}
|
||||
|
||||
pub fn record_pipe(&mut self, bytes: u64) {
|
||||
self.pipes_handled += 1;
|
||||
self.pipe_bytes += bytes;
|
||||
}
|
||||
|
||||
pub fn record_pipe_denial(&mut self) {
|
||||
self.pipe_denials += 1;
|
||||
}
|
||||
|
||||
pub fn record_send_error(&mut self) {
|
||||
self.send_errors += 1;
|
||||
}
|
||||
|
||||
pub fn finish(self, exit_reason: String) -> SessionRecord {
|
||||
let elapsed = self.start.elapsed();
|
||||
let end_time = self.start_time + elapsed.as_secs();
|
||||
|
||||
let avg_latency = if self.latencies.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.latencies.iter().sum::<f64>() / self.latencies.len() as f64
|
||||
};
|
||||
let max_latency = self.latencies.iter().copied().fold(0.0_f64, f64::max);
|
||||
|
||||
let record = SessionRecord {
|
||||
session_id: self.session_id,
|
||||
client_id: self.client_id,
|
||||
description: self.description,
|
||||
start_time: self.start_time,
|
||||
end_time,
|
||||
duration_secs: elapsed.as_secs_f64(),
|
||||
messages_received: self.messages_received,
|
||||
messages_ok: self.messages_ok,
|
||||
messages_failed: self.messages_failed,
|
||||
pipes_handled: self.pipes_handled,
|
||||
pipe_bytes_copied: self.pipe_bytes,
|
||||
pipe_denials: self.pipe_denials,
|
||||
send_errors: self.send_errors,
|
||||
avg_message_latency_ms: avg_latency,
|
||||
max_message_latency_ms: max_latency,
|
||||
exit_reason,
|
||||
};
|
||||
|
||||
self.metrics.finish_session(record.clone());
|
||||
record
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn tmp_path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join("mtp_server_metrics_test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join(name).to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_record_roundtrip() {
|
||||
let record = SessionRecord {
|
||||
session_id: "test-123".into(),
|
||||
client_id: 1000,
|
||||
description: "test session".into(),
|
||||
start_time: 1000,
|
||||
end_time: 1010,
|
||||
duration_secs: 10.0,
|
||||
messages_received: 5,
|
||||
messages_ok: 4,
|
||||
messages_failed: 1,
|
||||
pipes_handled: 2,
|
||||
pipe_bytes_copied: 4096,
|
||||
pipe_denials: 0,
|
||||
send_errors: 0,
|
||||
avg_message_latency_ms: 1.5,
|
||||
max_message_latency_ms: 3.0,
|
||||
exit_reason: "normal".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&record).unwrap();
|
||||
let decoded: SessionRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(record, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_file_roundtrip() {
|
||||
let file = ServerMetricsFile {
|
||||
total_connections: 10,
|
||||
total_messages: 50,
|
||||
total_messages_ok: 48,
|
||||
total_messages_failed: 2,
|
||||
total_pipes: 5,
|
||||
total_pipe_bytes: 20480,
|
||||
total_pipe_denials: 1,
|
||||
total_send_errors: 0,
|
||||
total_accept_errors: 3,
|
||||
connection_versions: HashMap::from([("2.0".into(), 8), ("1.0".into(), 2)]),
|
||||
sessions: vec![
|
||||
SessionRecord {
|
||||
session_id: "s1".into(),
|
||||
client_id: 1000,
|
||||
description: "first".into(),
|
||||
start_time: 100,
|
||||
end_time: 110,
|
||||
duration_secs: 10.0,
|
||||
messages_received: 3,
|
||||
messages_ok: 3,
|
||||
messages_failed: 0,
|
||||
pipes_handled: 1,
|
||||
pipe_bytes_copied: 1024,
|
||||
pipe_denials: 0,
|
||||
send_errors: 0,
|
||||
avg_message_latency_ms: 0.5,
|
||||
max_message_latency_ms: 1.0,
|
||||
exit_reason: "normal".into(),
|
||||
},
|
||||
SessionRecord {
|
||||
session_id: "s2".into(),
|
||||
client_id: 1001,
|
||||
description: "second".into(),
|
||||
start_time: 200,
|
||||
end_time: 230,
|
||||
duration_secs: 30.0,
|
||||
messages_received: 7,
|
||||
messages_ok: 6,
|
||||
messages_failed: 1,
|
||||
pipes_handled: 4,
|
||||
pipe_bytes_copied: 19456,
|
||||
pipe_denials: 1,
|
||||
send_errors: 0,
|
||||
avg_message_latency_ms: 2.0,
|
||||
max_message_latency_ms: 5.0,
|
||||
exit_reason: "idle timeout".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&file).unwrap();
|
||||
let decoded: ServerMetricsFile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(file.total_connections, decoded.total_connections);
|
||||
assert_eq!(file.sessions.len(), decoded.sessions.len());
|
||||
assert_eq!(file.sessions[0], decoded.sessions[0]);
|
||||
assert_eq!(file.sessions[1], decoded.sessions[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_handle_lifecycle() {
|
||||
let metrics = ServerMetrics::new();
|
||||
let mut session = metrics.start_session(1000, "test".into());
|
||||
|
||||
session.record_message(Duration::from_millis(1), true);
|
||||
session.record_message(Duration::from_millis(3), true);
|
||||
session.record_message(Duration::from_millis(2), false);
|
||||
session.record_pipe(512);
|
||||
|
||||
let record = session.finish("test exit".into());
|
||||
|
||||
assert_eq!(record.client_id, 1000);
|
||||
assert_eq!(record.messages_received, 3);
|
||||
assert_eq!(record.messages_ok, 2);
|
||||
assert_eq!(record.messages_failed, 1);
|
||||
assert_eq!(record.pipes_handled, 1);
|
||||
assert_eq!(record.pipe_bytes_copied, 512);
|
||||
assert!(record.avg_message_latency_ms > 0.0);
|
||||
assert_eq!(record.max_message_latency_ms, 3.0);
|
||||
assert_eq!(record.exit_reason, "test exit");
|
||||
|
||||
let snap = metrics.snapshot();
|
||||
assert_eq!(snap.total_connections, 1);
|
||||
assert_eq!(snap.total_messages, 3);
|
||||
assert_eq!(snap.total_messages_ok, 2);
|
||||
assert_eq!(snap.total_messages_failed, 1);
|
||||
assert_eq!(snap.total_pipes, 1);
|
||||
assert_eq!(snap.total_pipe_bytes, 512);
|
||||
assert_eq!(snap.sessions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overview_generation() {
|
||||
let metrics = ServerMetrics::new();
|
||||
|
||||
for i in 0..3 {
|
||||
let mut session = metrics.start_session(1000 + i as u64, format!("session {i}"));
|
||||
for _ in 0..(i + 1) * 2 {
|
||||
session.record_message(Duration::from_millis(1 + i), true);
|
||||
}
|
||||
session.record_pipe((i as u64 + 1) * 1000);
|
||||
session.finish(format!("exit {i}"));
|
||||
}
|
||||
|
||||
let overview_path = tmp_path("overview_test.json");
|
||||
metrics.build_overview(&overview_path);
|
||||
|
||||
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(overview.total_sessions, 3);
|
||||
assert!(overview.first_session_timestamp > 0);
|
||||
assert!(overview.last_session_timestamp >= overview.first_session_timestamp);
|
||||
assert_eq!(overview.aggregate.total_connections, 3);
|
||||
assert_eq!(overview.aggregate.total_messages, 12); // 2+4+6
|
||||
assert_eq!(overview.aggregate.total_pipes, 3);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 6000); // 1000+2000+3000
|
||||
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||
assert_eq!(overview.sessions.len(), 3);
|
||||
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_missing_file() {
|
||||
let metrics = ServerMetrics::load("/nonexistent/path/metrics.json");
|
||||
let snap = metrics.snapshot();
|
||||
assert_eq!(snap.total_connections, 0);
|
||||
assert!(snap.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_sessions_accumulate() {
|
||||
let path = tmp_path("accumulate_test.json");
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
|
||||
for i in 0..5 {
|
||||
let mut session = metrics.start_session(1000, format!("s{i}"));
|
||||
session.record_message(Duration::from_millis(1), true);
|
||||
session.record_pipe(100);
|
||||
session.finish(format!("done {i}"));
|
||||
}
|
||||
|
||||
metrics.save(&path);
|
||||
|
||||
let metrics2 = ServerMetrics::load(&path);
|
||||
let snap = metrics2.snapshot();
|
||||
assert_eq!(snap.total_connections, 5);
|
||||
assert_eq!(snap.total_messages, 5);
|
||||
assert_eq!(snap.total_messages_ok, 5);
|
||||
assert_eq!(snap.total_pipes, 5);
|
||||
assert_eq!(snap.total_pipe_bytes, 500);
|
||||
assert_eq!(snap.sessions.len(), 5);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overview_latencies() {
|
||||
let metrics = ServerMetrics::new();
|
||||
|
||||
let mut s1 = metrics.start_session(1000, "s1".into());
|
||||
s1.record_message(Duration::from_millis(2), true);
|
||||
s1.record_message(Duration::from_millis(4), true);
|
||||
s1.finish("done".into());
|
||||
|
||||
let mut s2 = metrics.start_session(1001, "s2".into());
|
||||
s2.record_message(Duration::from_millis(1), true);
|
||||
s2.finish("done".into());
|
||||
|
||||
let overview_path = tmp_path("latency_overview.json");
|
||||
metrics.build_overview(&overview_path);
|
||||
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||
|
||||
// s1 avg = 3.0, s2 avg = 1.0
|
||||
// weighted avg = (3*2 + 1*1) / 3 = 7/3 ≈ 2.333
|
||||
assert!(
|
||||
(overview.aggregate.avg_message_latency_ms - 7.0 / 3.0).abs() < 0.01,
|
||||
"avg latency: {}",
|
||||
overview.aggregate.avg_message_latency_ms
|
||||
);
|
||||
assert_eq!(overview.aggregate.max_message_latency_ms, 4.0);
|
||||
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overview_empty() {
|
||||
let metrics = ServerMetrics::new();
|
||||
let overview_path = tmp_path("empty_overview.json");
|
||||
metrics.build_overview(&overview_path);
|
||||
|
||||
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 0);
|
||||
assert!(overview.sessions.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Integration-style tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_full_session_lifecycle() {
|
||||
let path = tmp_path("lifecycle.json");
|
||||
let overview_path = tmp_path("lifecycle_overview.json");
|
||||
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
|
||||
let mut s1 = metrics.start_session(1000, "first".into());
|
||||
s1.record_message(Duration::from_millis(1), true);
|
||||
s1.record_message(Duration::from_millis(2), true);
|
||||
let r1 = s1.finish("normal".into());
|
||||
|
||||
let mut s2 = metrics.start_session(1001, "second".into());
|
||||
s2.record_message(Duration::from_millis(5), true);
|
||||
s2.record_message(Duration::from_millis(3), false);
|
||||
s2.record_pipe(2048);
|
||||
s2.record_pipe(4096);
|
||||
let r2 = s2.finish("idle timeout".into());
|
||||
|
||||
let mut s3 = metrics.start_session(1002, "third".into());
|
||||
s3.record_pipe(1024);
|
||||
let r3 = s3.finish("normal".into());
|
||||
|
||||
assert_eq!(r1.client_id, 1000);
|
||||
assert_eq!(r1.messages_received, 2);
|
||||
assert_eq!(r1.messages_ok, 2);
|
||||
assert_eq!(r1.pipes_handled, 0);
|
||||
|
||||
assert_eq!(r2.client_id, 1001);
|
||||
assert_eq!(r2.messages_received, 2);
|
||||
assert_eq!(r2.messages_ok, 1);
|
||||
assert_eq!(r2.messages_failed, 1);
|
||||
assert_eq!(r2.pipes_handled, 2);
|
||||
assert_eq!(r2.pipe_bytes_copied, 6144);
|
||||
assert_eq!(r2.exit_reason, "idle timeout");
|
||||
|
||||
assert_eq!(r3.client_id, 1002);
|
||||
assert_eq!(r3.messages_received, 0);
|
||||
assert_eq!(r3.pipes_handled, 1);
|
||||
assert_eq!(r3.pipe_bytes_copied, 1024);
|
||||
|
||||
let snap = metrics.snapshot();
|
||||
assert_eq!(snap.total_connections, 3);
|
||||
assert_eq!(snap.total_messages, 4);
|
||||
assert_eq!(snap.total_messages_ok, 3);
|
||||
assert_eq!(snap.total_messages_failed, 1);
|
||||
assert_eq!(snap.total_pipes, 3);
|
||||
assert_eq!(snap.total_pipe_bytes, 7168);
|
||||
assert_eq!(snap.sessions.len(), 3);
|
||||
|
||||
metrics.save(&path);
|
||||
let metrics2 = ServerMetrics::load(&path);
|
||||
let snap2 = metrics2.snapshot();
|
||||
assert_eq!(snap2.total_connections, 3);
|
||||
assert_eq!(snap2.total_messages, 4);
|
||||
assert_eq!(snap2.sessions.len(), 3);
|
||||
assert_eq!(snap2.sessions[1].exit_reason, "idle timeout");
|
||||
|
||||
metrics2.build_overview(&overview_path);
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 3);
|
||||
assert_eq!(overview.aggregate.total_connections, 3);
|
||||
assert_eq!(overview.aggregate.total_messages, 4);
|
||||
assert_eq!(overview.aggregate.total_messages_ok, 3);
|
||||
assert_eq!(overview.aggregate.total_messages_failed, 1);
|
||||
assert_eq!(overview.aggregate.total_pipes, 3);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 7168);
|
||||
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||
assert!(overview.aggregate.avg_messages_per_session > 0.0);
|
||||
assert_eq!(overview.sessions.len(), 3);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overview_rebuild_accuracy() {
|
||||
let path = tmp_path("accuracy.json");
|
||||
let overview_path = tmp_path("accuracy_overview.json");
|
||||
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
|
||||
for i in 0..10u32 {
|
||||
let mut session = metrics.start_session(1000 + i as u64, format!("session {i}"));
|
||||
let msg_count = (i + 1) * 2;
|
||||
for j in 0..msg_count {
|
||||
session.record_message(Duration::from_millis((j + 1) as u64), j % 3 != 0);
|
||||
}
|
||||
session.record_pipe((i as u64 + 1) * 512);
|
||||
session.finish(format!("exit {i}"));
|
||||
}
|
||||
|
||||
metrics.save(&path);
|
||||
let metrics2 = ServerMetrics::load(&path);
|
||||
metrics2.build_overview(&overview_path);
|
||||
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||
|
||||
assert_eq!(overview.total_sessions, 10);
|
||||
assert_eq!(overview.aggregate.total_connections, 10);
|
||||
assert_eq!(overview.aggregate.total_messages, 110);
|
||||
assert_eq!(overview.aggregate.total_pipes, 10);
|
||||
assert_eq!(overview.aggregate.total_pipe_bytes, 28160);
|
||||
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||
assert!((overview.aggregate.avg_messages_per_session - 11.0).abs() < 0.01);
|
||||
assert!((overview.aggregate.avg_pipes_per_session - 1.0).abs() < 0.01);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_across_instances() {
|
||||
let path = tmp_path("persistence.json");
|
||||
let overview_path = tmp_path("persistence_overview.json");
|
||||
|
||||
{
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
let mut s1 = metrics.start_session(1000, "inst1-s1".into());
|
||||
s1.record_message(Duration::from_millis(10), true);
|
||||
s1.record_pipe(100);
|
||||
s1.finish("done".into());
|
||||
|
||||
let mut s2 = metrics.start_session(1001, "inst1-s2".into());
|
||||
s2.record_message(Duration::from_millis(20), true);
|
||||
s2.finish("done".into());
|
||||
|
||||
metrics.save(&path);
|
||||
metrics.build_overview(&overview_path);
|
||||
}
|
||||
|
||||
{
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
let snap = metrics.snapshot();
|
||||
assert_eq!(snap.sessions.len(), 2);
|
||||
assert_eq!(snap.total_connections, 2);
|
||||
|
||||
let mut s3 = metrics.start_session(1002, "inst2-s1".into());
|
||||
s3.record_message(Duration::from_millis(5), true);
|
||||
s3.record_pipe(200);
|
||||
s3.record_pipe(300);
|
||||
s3.finish("done".into());
|
||||
|
||||
metrics.save(&path);
|
||||
metrics.build_overview(&overview_path);
|
||||
}
|
||||
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
let snap = metrics.snapshot();
|
||||
assert_eq!(snap.sessions.len(), 3);
|
||||
assert_eq!(snap.total_connections, 3);
|
||||
assert_eq!(snap.total_messages, 3);
|
||||
assert_eq!(snap.total_messages_ok, 3);
|
||||
assert_eq!(snap.total_pipes, 3);
|
||||
assert_eq!(snap.total_pipe_bytes, 600);
|
||||
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||
assert_eq!(overview.total_sessions, 3);
|
||||
assert_eq!(overview.sessions[0].description, "inst1-s1");
|
||||
assert_eq!(overview.sessions[1].description, "inst1-s2");
|
||||
assert_eq!(overview.sessions[2].description, "inst2-s1");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accept_errors_and_versions() {
|
||||
let path = tmp_path("accept_errors.json");
|
||||
let overview_path = tmp_path("accept_errors_overview.json");
|
||||
|
||||
let metrics = ServerMetrics::load(&path);
|
||||
|
||||
// Simulate 5 accept errors
|
||||
for _ in 0..5 {
|
||||
metrics.record_accept_error();
|
||||
}
|
||||
|
||||
// Simulate connection versions
|
||||
metrics.record_connection_version("2.0");
|
||||
metrics.record_connection_version("2.0");
|
||||
metrics.record_connection_version("1.0");
|
||||
|
||||
// A normal session with pipe denials and send errors
|
||||
let mut s1 = metrics.start_session(1000, "normal".into());
|
||||
s1.record_message(Duration::from_millis(1), true);
|
||||
s1.record_pipe_denial();
|
||||
s1.record_send_error();
|
||||
s1.record_send_error();
|
||||
s1.finish("done".into());
|
||||
|
||||
metrics.save(&path);
|
||||
let metrics2 = ServerMetrics::load(&path);
|
||||
let snap = metrics2.snapshot();
|
||||
assert_eq!(snap.total_accept_errors, 5);
|
||||
assert_eq!(snap.connection_versions["2.0"], 2);
|
||||
assert_eq!(snap.connection_versions["1.0"], 1);
|
||||
assert_eq!(snap.total_pipe_denials, 1);
|
||||
assert_eq!(snap.total_send_errors, 2);
|
||||
assert_eq!(snap.sessions.len(), 1);
|
||||
assert_eq!(snap.sessions[0].pipe_denials, 1);
|
||||
assert_eq!(snap.sessions[0].send_errors, 2);
|
||||
|
||||
metrics2.build_overview(&overview_path);
|
||||
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||
assert_eq!(overview.aggregate.total_accept_errors, 5);
|
||||
assert_eq!(overview.aggregate.total_pipe_denials, 1);
|
||||
assert_eq!(overview.aggregate.total_send_errors, 2);
|
||||
assert_eq!(overview.connection_versions["2.0"], 2);
|
||||
assert_eq!(overview.connection_versions["1.0"], 1);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&overview_path);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue