Skip to content

Commit 215a21f

Browse files
committed
standardize the --text output to match that of the tui
1 parent 7d9f4fe commit 215a21f

File tree

10 files changed

+199
-117
lines changed

10 files changed

+199
-117
lines changed

src/cli.rs

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -171,27 +171,68 @@ async fn run_text(args: Cli) -> Result<()> {
171171
let engine = TestEngine::new(cfg);
172172
let handle = tokio::spawn(async move { engine.run(evt_tx, ctrl_rx).await });
173173

174+
// Collect raw samples for metric computation (same as TUI)
175+
let run_start = std::time::Instant::now();
176+
let mut idle_latency_samples: Vec<f64> = Vec::new();
177+
let mut loaded_dl_latency_samples: Vec<f64> = Vec::new();
178+
let mut loaded_ul_latency_samples: Vec<f64> = Vec::new();
179+
let mut dl_points: Vec<(f64, f64)> = Vec::new();
180+
let mut ul_points: Vec<(f64, f64)> = Vec::new();
181+
174182
while let Some(ev) = evt_rx.recv().await {
175183
match ev {
176184
TestEvent::PhaseStarted { phase } => {
177185
eprintln!("== {phase:?} ==");
178186
}
179187
TestEvent::ThroughputTick {
180-
phase, bps_instant, ..
188+
phase,
189+
bps_instant,
190+
bytes_total: _,
181191
} => {
182192
if matches!(
183193
phase,
184194
crate::model::Phase::Download | crate::model::Phase::Upload
185195
) {
186-
eprintln!("{phase:?}: {:.2} Mbps", (bps_instant * 8.0) / 1_000_000.0);
196+
let elapsed = run_start.elapsed().as_secs_f64();
197+
let mbps = (bps_instant * 8.0) / 1_000_000.0;
198+
eprintln!("{phase:?}: {:.2} Mbps", mbps);
199+
200+
// Collect throughput points for metrics
201+
match phase {
202+
crate::model::Phase::Download => {
203+
dl_points.push((elapsed, mbps));
204+
}
205+
crate::model::Phase::Upload => {
206+
ul_points.push((elapsed, mbps));
207+
}
208+
_ => {}
209+
}
187210
}
188211
}
189212
TestEvent::LatencySample {
190-
phase, ok, rtt_ms, ..
213+
phase,
214+
ok,
215+
rtt_ms,
216+
during,
191217
} => {
192-
if phase == crate::model::Phase::IdleLatency && ok {
218+
if ok {
193219
if let Some(ms) = rtt_ms {
194-
eprintln!("Idle latency: {:.1} ms", ms);
220+
match (phase, during) {
221+
(crate::model::Phase::IdleLatency, None) => {
222+
eprintln!("Idle latency: {:.1} ms", ms);
223+
idle_latency_samples.push(ms);
224+
}
225+
(
226+
crate::model::Phase::Download,
227+
Some(crate::model::Phase::Download),
228+
) => {
229+
loaded_dl_latency_samples.push(ms);
230+
}
231+
(crate::model::Phase::Upload, Some(crate::model::Phase::Upload)) => {
232+
loaded_ul_latency_samples.push(ms);
233+
}
234+
_ => {}
235+
}
195236
}
196237
}
197238
}
@@ -226,29 +267,60 @@ async fn run_text(args: Cli) -> Result<()> {
226267
if let Some(server) = enriched.server.as_deref() {
227268
println!("Server: {server}");
228269
}
229-
println!("Download: {:.2} Mbps", enriched.download.mbps);
230-
println!("Upload: {:.2} Mbps", enriched.upload.mbps);
270+
271+
// Compute and display throughput metrics (mean, median, p25, p75)
272+
let (dl_mean, dl_median, dl_p25, dl_p75) =
273+
crate::metrics::compute_throughput_metrics(&dl_points)
274+
.context("insufficient download throughput data to compute metrics")?;
275+
println!(
276+
"Download: avg {:.2} med {:.2} p25 {:.2} p75 {:.2}",
277+
dl_mean, dl_median, dl_p25, dl_p75
278+
);
279+
280+
let (ul_mean, ul_median, ul_p25, ul_p75) =
281+
crate::metrics::compute_throughput_metrics(&ul_points)
282+
.context("insufficient upload throughput data to compute metrics")?;
231283
println!(
232-
"Idle latency p50/p90/p99: {:.1}/{:.1}/{:.1} ms (loss {:.1}%, jitter {:.1} ms)",
233-
enriched.idle_latency.p50_ms.unwrap_or(f64::NAN),
234-
enriched.idle_latency.p90_ms.unwrap_or(f64::NAN),
235-
enriched.idle_latency.p99_ms.unwrap_or(f64::NAN),
284+
"Upload: avg {:.2} med {:.2} p25 {:.2} p75 {:.2}",
285+
ul_mean, ul_median, ul_p25, ul_p75
286+
);
287+
288+
// Compute and display latency metrics (mean, median, p25, p75)
289+
let (idle_mean, idle_median, idle_p25, idle_p75) =
290+
crate::metrics::compute_latency_metrics(&idle_latency_samples)
291+
.context("insufficient idle latency data to compute metrics")?;
292+
println!(
293+
"Idle latency: avg {:.1} med {:.1} p25 {:.1} p75 {:.1} ms (loss {:.1}%, jitter {:.1} ms)",
294+
idle_mean,
295+
idle_median,
296+
idle_p25,
297+
idle_p75,
236298
enriched.idle_latency.loss * 100.0,
237299
enriched.idle_latency.jitter_ms.unwrap_or(f64::NAN)
238300
);
301+
302+
let (dl_lat_mean, dl_lat_median, dl_lat_p25, dl_lat_p75) =
303+
crate::metrics::compute_latency_metrics(&loaded_dl_latency_samples)
304+
.context("insufficient loaded download latency data to compute metrics")?;
239305
println!(
240-
"Loaded latency (download) p50/p90/p99: {:.1}/{:.1}/{:.1} ms (loss {:.1}%, jitter {:.1} ms)",
241-
enriched.loaded_latency_download.p50_ms.unwrap_or(f64::NAN),
242-
enriched.loaded_latency_download.p90_ms.unwrap_or(f64::NAN),
243-
enriched.loaded_latency_download.p99_ms.unwrap_or(f64::NAN),
306+
"Loaded latency (download): avg {:.1} med {:.1} p25 {:.1} p75 {:.1} ms (loss {:.1}%, jitter {:.1} ms)",
307+
dl_lat_mean,
308+
dl_lat_median,
309+
dl_lat_p25,
310+
dl_lat_p75,
244311
enriched.loaded_latency_download.loss * 100.0,
245312
enriched.loaded_latency_download.jitter_ms.unwrap_or(f64::NAN)
246313
);
314+
315+
let (ul_lat_mean, ul_lat_median, ul_lat_p25, ul_lat_p75) =
316+
crate::metrics::compute_latency_metrics(&loaded_ul_latency_samples)
317+
.context("insufficient loaded upload latency data to compute metrics")?;
247318
println!(
248-
"Loaded latency (upload) p50/p90/p99: {:.1}/{:.1}/{:.1} ms (loss {:.1}%, jitter {:.1} ms)",
249-
enriched.loaded_latency_upload.p50_ms.unwrap_or(f64::NAN),
250-
enriched.loaded_latency_upload.p90_ms.unwrap_or(f64::NAN),
251-
enriched.loaded_latency_upload.p99_ms.unwrap_or(f64::NAN),
319+
"Loaded latency (upload): avg {:.1} med {:.1} p25 {:.1} p75 {:.1} ms (loss {:.1}%, jitter {:.1} ms)",
320+
ul_lat_mean,
321+
ul_lat_median,
322+
ul_lat_p25,
323+
ul_lat_p75,
252324
enriched.loaded_latency_upload.loss * 100.0,
253325
enriched.loaded_latency_upload.jitter_ms.unwrap_or(f64::NAN)
254326
);

src/engine/cloudflare.rs

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,22 @@ pub struct CloudflareClient {
1616
impl CloudflareClient {
1717
pub fn new(cfg: &RunConfig) -> Result<Self> {
1818
let base_url = Url::parse(&cfg.base_url).context("invalid base_url")?;
19-
19+
2020
let mut builder = reqwest::Client::builder()
2121
.user_agent(cfg.user_agent.clone())
2222
.timeout(Duration::from_secs(30))
2323
.tcp_keepalive(Duration::from_secs(15));
24-
24+
2525
// Configure binding to interface or source IP if specified
2626
if let Some(ref iface) = cfg.interface {
2727
use crate::engine::network_bind;
2828
match network_bind::get_interface_ip(iface) {
2929
Ok(ip) => {
3030
builder = builder.local_address(ip);
31-
eprintln!("Binding HTTP connections to interface {} (IP: {})", iface, ip);
31+
eprintln!(
32+
"Binding HTTP connections to interface {} (IP: {})",
33+
iface, ip
34+
);
3235
}
3336
Err(e) => {
3437
return Err(anyhow::anyhow!(
@@ -54,14 +57,15 @@ impl CloudflareClient {
5457
}
5558
}
5659
}
57-
60+
5861
// Load custom certificate if provided
5962
if let Some(ref cert_path) = cfg.certificate_path {
6063
// Check file extension
61-
let ext = cert_path.extension()
64+
let ext = cert_path
65+
.extension()
6266
.and_then(|e| e.to_str())
6367
.map(|e| e.to_lowercase());
64-
68+
6569
let valid_extensions = ["pem", "crt", "cer", "der"];
6670
if let Some(ref ext) = ext {
6771
if !valid_extensions.contains(&ext.as_str()) {
@@ -77,25 +81,32 @@ impl CloudflareClient {
7781
valid_extensions.join(", ")
7882
));
7983
}
80-
81-
let cert_data = std::fs::read(cert_path)
82-
.with_context(|| format!("failed to read certificate from {}", cert_path.display()))?;
83-
84+
85+
let cert_data = std::fs::read(cert_path).with_context(|| {
86+
format!("failed to read certificate from {}", cert_path.display())
87+
})?;
88+
8489
// Parse based on file extension
8590
let cert = match ext.as_deref() {
86-
Some("der") => reqwest::Certificate::from_der(&cert_data)
87-
.with_context(|| format!("failed to parse DER certificate from {}", cert_path.display()))?,
88-
_ => reqwest::Certificate::from_pem(&cert_data)
89-
.with_context(|| format!("failed to parse PEM certificate from {}", cert_path.display()))?,
91+
Some("der") => reqwest::Certificate::from_der(&cert_data).with_context(|| {
92+
format!(
93+
"failed to parse DER certificate from {}",
94+
cert_path.display()
95+
)
96+
})?,
97+
_ => reqwest::Certificate::from_pem(&cert_data).with_context(|| {
98+
format!(
99+
"failed to parse PEM certificate from {}",
100+
cert_path.display()
101+
)
102+
})?,
90103
};
91-
104+
92105
builder = builder.add_root_certificate(cert);
93106
}
94-
95-
let http = builder
96-
.build()
97-
.context("failed to build http client")?;
98-
107+
108+
let http = builder.build().context("failed to build http client")?;
109+
99110
Ok(Self {
100111
base_url,
101112
meas_id: cfg.meas_id.clone(),
@@ -390,4 +401,3 @@ pub fn map_colo_to_server(locations: &serde_json::Value, colo: &str) -> Option<S
390401
Some(colo.to_string())
391402
}
392403
}
393-

src/engine/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ impl TestEngine {
146146
let mut experimental_udp = None;
147147
if self.cfg.experimental {
148148
if let Ok(info) = cloudflare::fetch_turn(&client).await {
149-
experimental_udp = turn_udp::run_udp_like_loss_probe(&info, &self.cfg).await.ok();
149+
experimental_udp = turn_udp::run_udp_like_loss_probe(&info, &self.cfg)
150+
.await
151+
.ok();
150152
turn = Some(info);
151153
}
152154
}

src/engine/network_bind.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ use std::net::{IpAddr, SocketAddr};
44
/// Get the IP address of a network interface using the `if-addrs` crate
55
pub fn get_interface_ip(interface: &str) -> Result<IpAddr> {
66
use if_addrs::get_if_addrs;
7-
7+
88
let addrs = get_if_addrs().context("Failed to enumerate network interfaces")?;
9-
9+
1010
// Prefer IPv4 addresses
1111
for addr in &addrs {
1212
if addr.name == interface {
@@ -15,7 +15,7 @@ pub fn get_interface_ip(interface: &str) -> Result<IpAddr> {
1515
}
1616
}
1717
}
18-
18+
1919
// Fallback to IPv6 if no IPv4 found
2020
for addr in &addrs {
2121
if addr.name == interface {
@@ -24,7 +24,7 @@ pub fn get_interface_ip(interface: &str) -> Result<IpAddr> {
2424
}
2525
}
2626
}
27-
27+
2828
Err(anyhow::anyhow!(
2929
"Interface {} not found or has no IP address assigned",
3030
interface
@@ -37,18 +37,15 @@ pub fn resolve_bind_address(
3737
source_ip: Option<&String>,
3838
) -> Result<Option<SocketAddr>> {
3939
if let Some(ip_str) = source_ip {
40-
let ip: IpAddr = ip_str
41-
.parse()
42-
.context("Invalid source IP address format")?;
40+
let ip: IpAddr = ip_str.parse().context("Invalid source IP address format")?;
4341
return Ok(Some(SocketAddr::new(ip, 0)));
4442
}
45-
43+
4644
if let Some(iface) = interface {
4745
let ip = get_interface_ip(iface)
4846
.with_context(|| format!("Failed to get IP for interface {}", iface))?;
4947
return Ok(Some(SocketAddr::new(ip, 0)));
5048
}
51-
49+
5250
Ok(None)
5351
}
54-

src/engine/turn_udp.rs

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ fn parse_host_port(url: &str) -> Result<(String, u16)> {
8080
Ok((host.to_string(), port))
8181
}
8282

83-
pub async fn run_udp_like_loss_probe(turn: &TurnInfo, cfg: &RunConfig) -> Result<ExperimentalUdpSummary> {
83+
pub async fn run_udp_like_loss_probe(
84+
turn: &TurnInfo,
85+
cfg: &RunConfig,
86+
) -> Result<ExperimentalUdpSummary> {
8487
let target_url = pick_stun_target(turn).context("no stun/turn url in /__turn")?;
8588
let (host, port) = parse_host_port(&target_url)?;
8689

@@ -89,33 +92,28 @@ pub async fn run_udp_like_loss_probe(turn: &TurnInfo, cfg: &RunConfig) -> Result
8992

9093
// Bind UDP socket to interface or source IP if specified
9194
let sock = if cfg.interface.is_some() || cfg.source_ip.is_some() {
92-
let bind_addr = network_bind::resolve_bind_address(
93-
cfg.interface.as_ref(),
94-
cfg.source_ip.as_ref(),
95-
)?;
96-
95+
let bind_addr =
96+
network_bind::resolve_bind_address(cfg.interface.as_ref(), cfg.source_ip.as_ref())?;
97+
9798
if let Some(addr) = bind_addr {
9899
// Create socket using socket2 for binding
99100
let domain = socket2::Domain::for_address(addr);
100-
let socket = socket2::Socket::new(
101-
domain,
102-
socket2::Type::DGRAM,
103-
Some(socket2::Protocol::UDP),
104-
)?;
105-
101+
let socket =
102+
socket2::Socket::new(domain, socket2::Type::DGRAM, Some(socket2::Protocol::UDP))?;
103+
106104
// Bind to the specified address
107105
socket.bind(&socket2::SockAddr::from(addr))?;
108-
106+
109107
// Bind to interface if specified (Linux only)
110108
#[cfg(target_os = "linux")]
111109
if let Some(ref iface) = cfg.interface {
112110
use std::ffi::CString;
113111
use std::os::unix::io::AsRawFd;
114-
112+
115113
let ifname = CString::new(iface.as_str()).map_err(|_| {
116114
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid interface name")
117115
})?;
118-
116+
119117
unsafe {
120118
if libc::setsockopt(
121119
socket.as_raw_fd(),
@@ -133,7 +131,7 @@ pub async fn run_udp_like_loss_probe(turn: &TurnInfo, cfg: &RunConfig) -> Result
133131
}
134132
}
135133
}
136-
134+
137135
// Convert to tokio UdpSocket
138136
let std_socket: std::net::UdpSocket = socket.into();
139137
std_socket.set_nonblocking(true)?;
@@ -145,7 +143,7 @@ pub async fn run_udp_like_loss_probe(turn: &TurnInfo, cfg: &RunConfig) -> Result
145143
// Bind ephemeral UDP (default behavior)
146144
UdpSocket::bind("0.0.0.0:0").await?
147145
};
148-
146+
149147
sock.connect(addr).await?;
150148

151149
let timeout = Duration::from_millis(600);

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod cli;
22
mod engine;
3+
mod metrics;
34
mod model;
45
mod network;
56
mod stats;

0 commit comments

Comments
 (0)