use crate::i18n::Lang;
use crate::tcp_info::{TcpInfo, TcpInfoDelta};
use bytes::Bytes;
use bytesize::ByteSize;
use chrono::{Local, TimeZone};
use heck::ToTrainCase;
use http::HeaderMap;
use http::HeaderValue;
use http::StatusCode;
use nu_ansi_term::Color::{LightCyan, LightGreen, LightRed, LightYellow};
use serde_json::{json, Map, Value};
use std::fmt;
use std::io::Write;
use std::time::Duration;
use tempfile::NamedTempFile;
use unicode_truncate::Alignment;
use unicode_truncate::UnicodeTruncateStr;
pub static ALPN_HTTP2: &str = "h2";
pub static ALPN_HTTP1: &str = "http/1.1";
pub static ALPN_HTTP3: &str = "h3";
pub(crate) fn format_time(timestamp_seconds: i64) -> String {
Local
.timestamp_nanos(timestamp_seconds * 1_000_000_000)
.to_string()
}
pub fn format_duration(duration: Duration) -> String {
if duration > Duration::from_secs(1) {
return format!("{:.2}s", duration.as_secs_f64());
}
if duration > Duration::from_millis(1) {
return format!("{}ms", duration.as_millis());
}
format!("{}µs", duration.as_micros())
}
pub fn format_throughput(bytes_per_sec: f64) -> String {
if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
return "-".to_string();
}
if bytes_per_sec >= 1_000_000.0 {
format!("{:.1} MB/s", bytes_per_sec / 1_000_000.0)
} else if bytes_per_sec >= 1_000.0 {
format!("{:.1} KB/s", bytes_per_sec / 1_000.0)
} else {
format!("{bytes_per_sec:.0} B/s")
}
}
pub const FIRST_CHUNK_BYTES: usize = 100 * 1024;
pub const THROUGHPUT_DISPLAY_THRESHOLD: usize = 1024 * 1024;
struct Timeline {
name: String,
duration: Duration,
}
#[derive(Default, Debug, Clone)]
pub struct HttpStat {
pub is_grpc: bool,
pub request_headers: HeaderMap<HeaderValue>,
pub dns_lookup: Option<Duration>,
pub dns_connect: Option<Duration>,
pub quic_connect: Option<Duration>,
pub tcp_connect: Option<Duration>,
pub tcp_info_post_connect: Option<TcpInfo>,
pub tcp_info_final: Option<TcpInfo>,
pub tls_handshake: Option<Duration>,
pub request_send: Option<Duration>,
pub server_processing: Option<Duration>,
pub content_transfer: Option<Duration>,
pub wire_body_size: Option<usize>,
pub time_to_first_100k: Option<Duration>,
pub server_timing: Option<Vec<ServerTiming>>,
pub total: Option<Duration>,
pub addr: Option<String>,
pub grpc_status: Option<String>,
pub status: Option<StatusCode>,
pub tls: Option<String>,
pub tls_resumed: Option<bool>,
pub tls_early_data_accepted: Option<bool>,
pub tls_ocsp_stapled: Option<bool>,
pub alpn: Option<String>,
pub subject: Option<String>,
pub issuer: Option<String>,
pub cert_not_before: Option<String>,
pub cert_not_after: Option<String>,
pub cert_cipher: Option<String>,
pub cert_domains: Option<Vec<String>>,
pub certificates: Option<Vec<Certificate>>,
pub body: Option<Bytes>,
pub body_size: Option<usize>,
pub headers: Option<HeaderMap<HeaderValue>>,
pub error: Option<String>,
pub silent: bool,
pub verbose: bool,
pub pretty: bool,
pub include_headers: Option<Vec<String>>,
pub exclude_headers: Option<Vec<String>>,
pub waterfall: bool,
pub jq_filter: Option<String>,
pub show_tcp_info: bool,
pub lang: Lang,
}
#[derive(Debug, Clone)]
pub struct Certificate {
pub subject: String,
pub issuer: String,
pub not_before: String,
pub not_after: String,
}
#[derive(Debug, Clone)]
pub struct ServerTiming {
pub name: String,
pub duration: Option<Duration>,
pub description: Option<String>,
}
pub fn parse_server_timing<'a, I>(values: I) -> Option<Vec<ServerTiming>>
where
I: IntoIterator<Item = &'a str>,
{
let mut out = Vec::new();
for raw in values {
for part in split_top_level_commas(raw) {
let mut subparts = part.split(';').map(str::trim);
let name = match subparts.next() {
Some(n) if !n.is_empty() => n.to_string(),
_ => continue,
};
let mut entry = ServerTiming {
name,
duration: None,
description: None,
};
for kv in subparts {
let Some(eq) = kv.find('=') else { continue };
let key = kv[..eq].trim().to_ascii_lowercase();
let mut val = kv[eq + 1..].trim();
if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
val = &val[1..val.len() - 1];
}
match key.as_str() {
"dur" => {
if let Ok(ms) = val.parse::<f64>() {
if ms.is_finite() && ms >= 0.0 {
entry.duration = Some(Duration::from_secs_f64(ms / 1000.0));
}
}
}
"desc" => entry.description = Some(val.to_string()),
_ => {}
}
}
out.push(entry);
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn split_top_level_commas(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut in_quotes = false;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'"' => in_quotes = !in_quotes,
b',' if !in_quotes => {
parts.push(s[start..i].trim());
start = i + 1;
}
_ => {}
}
i += 1;
}
parts.push(s[start..].trim());
parts
}
fn apply_jq_filter(body: &str, filter: &str) -> Option<String> {
let root: serde_json::Value = serde_json::from_str(body).ok()?;
let filter = filter.trim();
let owned;
let filter = if !filter.starts_with('.') {
owned = format!(".{filter}");
owned.as_str()
} else {
filter
};
#[derive(Debug)]
enum Step {
Key(String),
Index(usize),
Iter,
}
fn tokenize(s: &str) -> Option<Vec<Step>> {
let s = s.strip_prefix('.')?;
if s.is_empty() {
return Some(vec![]);
}
let mut steps = Vec::new();
let mut remaining = s;
while !remaining.is_empty() {
if remaining.starts_with('[') {
let end = remaining.find(']')?;
let inner = &remaining[1..end];
if inner.is_empty() {
steps.push(Step::Iter);
} else {
let idx: usize = inner.parse().ok()?;
steps.push(Step::Index(idx));
}
remaining = &remaining[end + 1..];
if remaining.starts_with('.') {
remaining = &remaining[1..];
}
} else {
let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
let key = &remaining[..end];
if !key.is_empty() {
steps.push(Step::Key(key.to_string()));
}
remaining = &remaining[end..];
if remaining.starts_with('.') {
remaining = &remaining[1..];
}
}
}
Some(steps)
}
fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
if steps.is_empty() {
return values;
}
let mut current = values;
for step in steps {
current = match step {
Step::Key(k) => current
.into_iter()
.filter_map(|v| v.get(k).cloned())
.collect(),
Step::Index(i) => current
.into_iter()
.filter_map(|v| v.get(i).cloned())
.collect(),
Step::Iter => current
.into_iter()
.flat_map(|v| match v {
serde_json::Value::Array(arr) => arr,
serde_json::Value::Object(map) => map.into_values().collect(),
other => vec![other],
})
.collect(),
};
}
current
}
let steps = tokenize(filter)?;
let results = apply_steps(vec![root], &steps);
if results.len() == 1 {
serde_json::to_string_pretty(&results[0]).ok()
} else {
Some(
results
.iter()
.filter_map(|v| serde_json::to_string_pretty(v).ok())
.collect::<Vec<_>>()
.join("\n"),
)
}
}
impl HttpStat {
pub fn exit_code(&self) -> i32 {
if self.is_success() {
return 0;
}
if self.error.is_none() {
if let Some(status) = &self.status {
let code = status.as_u16();
if code >= 500 {
return 7;
}
if code >= 400 {
return 6;
}
}
return 1;
}
let err = self.error.as_deref().unwrap_or_default().to_lowercase();
if err.contains("timeout") || err.contains("elapsed") {
return 5;
}
if self.dns_lookup.is_none() {
return 2;
}
if self.tcp_connect.is_none() && self.quic_connect.is_none() {
return 3;
}
if err.contains("rustls")
|| err.contains("tls")
|| err.contains("certificate")
|| err.contains("invalid dns name")
{
return 4;
}
1
}
pub fn dns_query(&self) -> Option<Duration> {
match (self.dns_lookup, self.dns_connect) {
(Some(total), Some(connect)) => Some(total.saturating_sub(connect)),
_ => None,
}
}
pub fn throughput_bps(&self) -> Option<f64> {
let bytes = self.wire_body_size? as f64;
let secs = self.content_transfer?.as_secs_f64();
if secs <= 0.0 {
return None;
}
Some(bytes / secs)
}
pub fn first_chunk_throughput_bps(&self) -> Option<f64> {
let dur = self.time_to_first_100k?.as_secs_f64();
if dur <= 0.0 {
return None;
}
Some(FIRST_CHUNK_BYTES as f64 / dur)
}
pub fn tail_throughput_bps(&self) -> Option<f64> {
let total = self.content_transfer?;
let head = self.time_to_first_100k?;
let bytes = self.wire_body_size?;
if bytes <= FIRST_CHUNK_BYTES || total <= head {
return None;
}
let tail_bytes = (bytes - FIRST_CHUNK_BYTES) as f64;
let tail_secs = (total - head).as_secs_f64();
if tail_secs <= 0.0 {
return None;
}
Some(tail_bytes / tail_secs)
}
pub fn is_success(&self) -> bool {
if self.error.is_some() {
return false;
}
if self.is_grpc {
if let Some(grpc_status) = &self.grpc_status {
return grpc_status == "0";
}
return false;
}
let Some(status) = &self.status else {
return false;
};
if status.as_u16() >= 400 {
return false;
}
true
}
fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total = match self.total {
Some(t) if t.as_nanos() > 0 => t,
_ => return Ok(()),
};
const BAR_WIDTH: usize = 50;
const LABEL_W: usize = 15;
let s = self.lang.strings();
let (dns_a, dns_b) = if self.dns_connect.is_some() {
(
(s.dns_connect, self.dns_connect),
(s.dns_query, self.dns_query()),
)
} else {
((s.dns_lookup, self.dns_lookup), ("", None))
};
let phases_vec: Vec<(&str, Option<Duration>)> = vec![
dns_a,
dns_b,
(s.tcp_connect, self.tcp_connect),
(s.tls_handshake, self.tls_handshake),
(s.quic_connect, self.quic_connect),
(s.request_send, self.request_send),
(s.server_processing_short, self.server_processing),
(s.content_transfer_short, self.content_transfer),
];
let phases: &[(&str, Option<Duration>)] = &phases_vec;
let total_ns = total.as_nanos() as f64;
let mut elapsed = Duration::ZERO;
let mut col_cursor: usize = 0;
for (name, dur_opt) in phases {
let Some(dur) = dur_opt else { continue };
let start_col = col_cursor;
elapsed += *dur;
let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
as usize)
.min(BAR_WIDTH);
let end_col = ideal_end.min(BAR_WIDTH);
if end_col > start_col {
col_cursor = end_col;
}
let bar: String = (0..BAR_WIDTH)
.map(|i| {
if i >= start_col && i < end_col {
'█'
} else {
'░'
}
})
.collect();
writeln!(
f,
" {:<LABEL_W$} [{}] {}",
name,
LightCyan.paint(bar),
LightCyan.paint(format_duration(*dur))
)?;
}
writeln!(f)?;
writeln!(
f,
" {:LABEL_W$} {:BAR_WIDTH$} {}: {}",
"",
"",
s.total,
LightCyan.paint(format_duration(total))
)?;
writeln!(f)
}
pub fn to_json(&self) -> Value {
let dur_us = |d: Option<Duration>| -> Value {
d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
};
let mut obj = Map::new();
let mut timing = Map::new();
timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
timing.insert("dns_connect_us".into(), dur_us(self.dns_connect));
timing.insert("dns_query_us".into(), dur_us(self.dns_query()));
timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
timing.insert("request_send_us".into(), dur_us(self.request_send));
timing.insert(
"server_processing_us".into(),
dur_us(self.server_processing),
);
timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
timing.insert(
"time_to_first_100k_us".into(),
dur_us(self.time_to_first_100k),
);
timing.insert("total_us".into(), dur_us(self.total));
obj.insert("timing".into(), Value::Object(timing));
if self.wire_body_size.is_some() && self.content_transfer.is_some() {
let mut t = Map::new();
t.insert(
"wire_body_size".into(),
self.wire_body_size.map_or(Value::Null, |v| json!(v)),
);
let opt_f = |v: Option<f64>| -> Value { v.map_or(Value::Null, |x| json!(x)) };
t.insert("bps_total".into(), opt_f(self.throughput_bps()));
t.insert(
"bps_first_100k".into(),
opt_f(self.first_chunk_throughput_bps()),
);
t.insert("bps_tail".into(), opt_f(self.tail_throughput_bps()));
obj.insert("throughput".into(), Value::Object(t));
}
let tcp_info_json = |info: Option<&TcpInfo>| -> Value {
let Some(info) = info else { return Value::Null };
let mut m = Map::new();
m.insert("rtt_us".into(), dur_us(info.rtt));
m.insert("rttvar_us".into(), dur_us(info.rttvar));
m.insert(
"retransmits".into(),
info.retransmits.map_or(Value::Null, |v| json!(v)),
);
m.insert("cwnd".into(), info.cwnd.map_or(Value::Null, |v| json!(v)));
m.insert(
"snd_mss".into(),
info.snd_mss.map_or(Value::Null, |v| json!(v)),
);
Value::Object(m)
};
if self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some() {
let mut block = Map::new();
block.insert(
"post_connect".into(),
tcp_info_json(self.tcp_info_post_connect.as_ref()),
);
block.insert("final".into(), tcp_info_json(self.tcp_info_final.as_ref()));
if let Some(delta) = TcpInfoDelta::compute(
self.tcp_info_post_connect.as_ref(),
self.tcp_info_final.as_ref(),
) {
let mut d = Map::new();
d.insert(
"retransmits_during".into(),
delta.retransmits_during.map_or(Value::Null, |v| json!(v)),
);
d.insert("rtt_final_us".into(), dur_us(delta.rtt_final));
d.insert(
"cwnd_final".into(),
delta.cwnd_final.map_or(Value::Null, |v| json!(v)),
);
block.insert("delta".into(), Value::Object(d));
}
obj.insert("tcp_info".into(), Value::Object(block));
}
if let Some(entries) = &self.server_timing {
let arr: Vec<Value> = entries
.iter()
.map(|e| {
let mut m = Map::new();
m.insert("name".into(), json!(e.name));
m.insert(
"duration_us".into(),
e.duration
.map_or(Value::Null, |d| json!(d.as_micros() as u64)),
);
m.insert(
"description".into(),
e.description.as_deref().map_or(Value::Null, |s| json!(s)),
);
Value::Object(m)
})
.collect();
obj.insert("server_timing".into(), Value::Array(arr));
}
obj.insert(
"addr".into(),
self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
);
obj.insert(
"status".into(),
self.status.map_or(Value::Null, |s| json!(s.as_u16())),
);
obj.insert(
"alpn".into(),
self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
);
if self.tls.is_some() {
let mut tls = Map::new();
tls.insert(
"version".into(),
self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"cipher".into(),
self.cert_cipher
.as_deref()
.map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"resumed".into(),
self.tls_resumed.map_or(Value::Null, |b| json!(b)),
);
tls.insert(
"early_data_accepted".into(),
self.tls_early_data_accepted
.map_or(Value::Null, |b| json!(b)),
);
tls.insert(
"ocsp_stapled".into(),
self.tls_ocsp_stapled.map_or(Value::Null, |b| json!(b)),
);
tls.insert(
"subject".into(),
self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"issuer".into(),
self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"not_before".into(),
self.cert_not_before
.as_deref()
.map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"not_after".into(),
self.cert_not_after
.as_deref()
.map_or(Value::Null, |s| json!(s)),
);
tls.insert(
"domains".into(),
self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
);
obj.insert("tls".into(), Value::Object(tls));
}
if let Some(headers) = &self.headers {
let mut hdr_map = Map::new();
for (key, value) in headers.iter() {
let v = value.to_str().unwrap_or_default().to_string();
hdr_map.insert(key.to_string(), json!(v));
}
obj.insert("headers".into(), Value::Object(hdr_map));
}
obj.insert(
"body_size".into(),
self.body_size.map_or(Value::Null, |s| json!(s)),
);
obj.insert(
"error".into(),
self.error.as_deref().map_or(Value::Null, |e| json!(e)),
);
obj.insert("exit_code".into(), json!(self.exit_code()));
Value::Object(obj)
}
}
impl fmt::Display for HttpStat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = self.lang.strings();
if let Some(addr) = &self.addr {
let label = if self.tcp_connect.is_some() || self.quic_connect.is_some() {
LightGreen.paint(s.connected_to)
} else {
LightYellow.paint(s.resolved_to)
};
let mut text = format!("{} {}", label, LightCyan.paint(addr));
if self.silent {
if let Some(status) = &self.status {
let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
let status_code = status.as_u16();
let status = if status_code < 400 {
LightGreen.paint(status.to_string())
} else {
LightRed.paint(status.to_string())
};
text = format!(
"{text} --> {} {}",
LightCyan.paint(alpn.to_uppercase()),
status
);
} else {
text = format!("{text} --> {}", LightRed.paint(s.fail));
}
text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
if let Some(true) = self.tls_resumed {
let tag = if matches!(self.tls_early_data_accepted, Some(true)) {
"0-RTT"
} else {
s.handshake_resumed
};
text = format!("{text} [{}]", LightYellow.paint(tag));
}
}
writeln!(f, "{text}")?;
}
if let Some(error) = &self.error {
writeln!(f, "{}: {}", s.error_label, LightRed.paint(error))?;
}
if self.silent {
return Ok(());
}
if self.verbose {
for (key, value) in self.request_headers.iter() {
writeln!(
f,
"{}: {}",
key.to_string().to_train_case(),
LightCyan.paint(value.to_str().unwrap_or_default())
)?;
}
writeln!(f)?;
}
if let Some(status) = &self.status {
let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
let status_code = status.as_u16();
let status = if status_code < 400 {
LightGreen.paint(status.to_string())
} else {
LightRed.paint(status.to_string())
};
writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
}
if self.is_grpc {
if self.is_success() {
writeln!(f, "{}", LightGreen.paint(s.grpc_ok))?;
}
writeln!(f)?;
}
if let Some(tls) = &self.tls {
writeln!(f)?;
writeln!(f, "{}: {}", s.tls_label, LightCyan.paint(tls))?;
writeln!(
f,
"{}: {}",
s.cipher,
LightCyan.paint(self.cert_cipher.as_deref().unwrap_or_default())
)?;
if let Some(resumed) = self.tls_resumed {
let label = if resumed {
s.handshake_resumed
} else {
s.handshake_full
};
writeln!(f, "{}: {}", s.handshake, LightCyan.paint(label))?;
}
if let Some(accepted) = self.tls_early_data_accepted {
let label = if accepted {
s.early_data_accepted
} else {
s.early_data_not_accepted
};
writeln!(f, "{}: {}", s.early_data, LightCyan.paint(label))?;
}
if let Some(stapled) = self.tls_ocsp_stapled {
let label = if stapled {
s.ocsp_stapled
} else {
s.ocsp_not_stapled
};
writeln!(f, "{}: {}", s.ocsp, LightCyan.paint(label))?;
}
writeln!(
f,
"{}: {}",
s.not_before,
LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
)?;
writeln!(
f,
"{}: {}",
s.not_after,
LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
)?;
if self.verbose {
writeln!(
f,
"{}: {}",
s.subject,
LightCyan.paint(self.subject.as_deref().unwrap_or_default())
)?;
writeln!(
f,
"{}: {}",
s.issuer,
LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
)?;
writeln!(
f,
"{}: {}",
s.cert_domains,
LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
)?;
}
writeln!(f)?;
if self.verbose {
if let Some(certificates) = &self.certificates {
writeln!(f, "{}", s.cert_chain)?;
for (index, cert) in certificates.iter().enumerate() {
writeln!(
f,
" {index} {}: {}",
s.subject,
LightCyan.paint(cert.subject.as_str())
)?;
writeln!(
f,
" {}: {}",
s.issuer,
LightCyan.paint(cert.issuer.as_str())
)?;
writeln!(
f,
" {}: {}",
s.not_before,
LightCyan.paint(cert.not_before.as_str())
)?;
writeln!(
f,
" {}: {}",
s.not_after,
LightCyan.paint(cert.not_after.as_str())
)?;
writeln!(f)?;
}
}
}
}
if (self.verbose || self.show_tcp_info)
&& (self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some())
{
writeln!(f, "{}", LightGreen.paint(s.kernel_tcp_heading))?;
let render = |label: &str, info: &TcpInfo| -> String {
let rtt = info.rtt.map(format_duration).unwrap_or_else(|| "-".into());
let rttvar = info
.rttvar
.map(format_duration)
.unwrap_or_else(|| "-".into());
let retrans = info
.retransmits
.map(|v| v.to_string())
.unwrap_or_else(|| "-".into());
let cwnd = info
.cwnd
.map(|v| v.to_string())
.unwrap_or_else(|| "-".into());
let mss = info
.snd_mss
.map(|v| v.to_string())
.unwrap_or_else(|| "-".into());
format!(
" {:<14} rtt {} \u{00B1} {} retrans {} cwnd {} mss {}",
label, rtt, rttvar, retrans, cwnd, mss,
)
};
if let Some(info) = &self.tcp_info_post_connect {
writeln!(
f,
"{}",
LightCyan.paint(render(s.tcp_post_connect_row, info))
)?;
}
if let Some(info) = &self.tcp_info_final {
writeln!(f, "{}", LightCyan.paint(render(s.tcp_final_row, info)))?;
}
if let Some(delta) = TcpInfoDelta::compute(
self.tcp_info_post_connect.as_ref(),
self.tcp_info_final.as_ref(),
) {
if let Some(n) = delta.retransmits_during {
let label = format!(" {} {n} {}", s.tcp_during, s.tcp_retransmit_word);
let painted = if n == 0 {
LightCyan.paint(label)
} else {
LightYellow.paint(label)
};
writeln!(f, "{painted}")?;
}
}
writeln!(f)?;
}
let mut is_text = false;
let mut is_json = false;
if let Some(headers) = &self.headers {
for (key, value) in headers.iter() {
let value = value.to_str().unwrap_or_default();
if key == http::header::CONTENT_TYPE {
if value.contains("text/") || value.contains("application/json") {
is_text = true;
}
if value.contains("application/json") {
is_json = true;
}
}
let key_lower = key.as_str();
let show = if let Some(includes) = &self.include_headers {
includes.iter().any(|h| h == key_lower)
} else if let Some(excludes) = &self.exclude_headers {
!excludes.iter().any(|h| h == key_lower)
} else {
true
};
if show {
writeln!(
f,
"{}: {}",
key.to_string().to_train_case(),
LightCyan.paint(value)
)?;
}
}
writeln!(f)?;
}
if let Some(entries) = &self.server_timing {
if !entries.is_empty() {
const BAR_W: usize = 34;
let name_w = entries
.iter()
.map(|e| e.name.chars().count())
.max()
.unwrap_or(0)
.max(4);
let sum: Duration = entries.iter().filter_map(|e| e.duration).sum();
let total_ns = (sum.as_nanos() as f64).max(1.0);
let largest_idx: Option<usize> = entries
.iter()
.enumerate()
.filter_map(|(i, e)| e.duration.filter(|d| !d.is_zero()).map(|d| (i, d)))
.max_by_key(|(_, d)| *d)
.map(|(i, _)| i);
let summary = match self.server_processing {
Some(sp) if sp > sum => format!(
"(\u{03A3} {} {} {} {} \u{00B7} {} {})",
format_duration(sum),
s.st_sum_of,
format_duration(sp),
s.server_processing,
format_duration(sp - sum),
s.st_unaccounted,
),
Some(sp) => format!(
"(\u{03A3} {} {} {} {})",
format_duration(sum),
s.st_sum_of,
format_duration(sp),
s.server_processing,
),
None => format!("(\u{03A3} {})", format_duration(sum)),
};
writeln!(
f,
"{} {}",
LightGreen.paint(s.server_timing_heading),
LightCyan.paint(&summary),
)?;
let sum_ns_u = sum.as_nanos();
let mut cum_ns: u128 = 0;
for (i, entry) in entries.iter().enumerate() {
let name_pad = " ".repeat(name_w.saturating_sub(entry.name.chars().count()));
let dur_ns = entry.duration.map(|d| d.as_nanos()).unwrap_or(0);
let start_col = ((cum_ns as f64 / total_ns) * BAR_W as f64).round() as usize;
let start_col = start_col.min(BAR_W);
let mut end_col =
(((cum_ns + dur_ns) as f64 / total_ns) * BAR_W as f64).round() as usize;
end_col = end_col.min(BAR_W);
if dur_ns > 0 && end_col <= start_col {
end_col = (start_col + 1).min(BAR_W);
}
let bar: String = (0..BAR_W)
.map(|col| {
if dur_ns == 0 {
let marker = start_col.min(BAR_W - 1);
if col == marker {
'\u{00B7}'
} else {
'\u{2591}'
}
} else if col >= start_col && col < end_col {
'\u{2588}'
} else {
'\u{2591}'
}
})
.collect();
let (dur_str, pct_str) = if dur_ns > 0 {
let pct = if sum_ns_u > 0 {
(dur_ns as f64 / sum_ns_u as f64) * 100.0
} else {
0.0
};
(
format_duration(entry.duration.unwrap_or_default()),
format!("{pct:>5.1}%"),
)
} else {
("\u{2014}".to_string(), "\u{2013}".to_string())
};
let is_largest = Some(i) == largest_idx;
let bar_painted = if is_largest {
LightYellow.paint(&bar).to_string()
} else {
LightCyan.paint(&bar).to_string()
};
let dur_painted = if is_largest {
LightYellow.paint(format!("{dur_str:>8}")).to_string()
} else {
LightCyan.paint(format!("{dur_str:>8}")).to_string()
};
let pct_painted = LightCyan.paint(format!("{pct_str:>6}")).to_string();
let desc = entry
.description
.as_deref()
.map(|d| format!(" ({d})"))
.unwrap_or_default();
writeln!(
f,
" {}{} {} {} {}{}",
LightCyan.paint(&entry.name),
name_pad,
bar_painted,
dur_painted,
pct_painted,
desc,
)?;
cum_ns += dur_ns;
}
writeln!(f)?;
}
}
if self.waterfall {
self.fmt_waterfall(f)?;
} else {
let width = 20;
let mut timelines = vec![];
if let Some(connect) = self.dns_connect {
timelines.push(Timeline {
name: s.dns_connect.to_string(),
duration: connect,
});
if let Some(query) = self.dns_query() {
timelines.push(Timeline {
name: s.dns_query.to_string(),
duration: query,
});
}
} else if let Some(value) = self.dns_lookup {
timelines.push(Timeline {
name: s.dns_lookup.to_string(),
duration: value,
});
}
if let Some(value) = self.tcp_connect {
timelines.push(Timeline {
name: s.tcp_connect.to_string(),
duration: value,
});
}
if let Some(value) = self.tls_handshake {
timelines.push(Timeline {
name: s.tls_handshake.to_string(),
duration: value,
});
}
if let Some(value) = self.quic_connect {
timelines.push(Timeline {
name: s.quic_connect.to_string(),
duration: value,
});
}
if let Some(value) = self.request_send {
timelines.push(Timeline {
name: s.request_send.to_string(),
duration: value,
});
}
if let Some(value) = self.server_processing {
timelines.push(Timeline {
name: s.server_processing.to_string(),
duration: value,
});
}
if let Some(value) = self.content_transfer {
timelines.push(Timeline {
name: s.content_transfer.to_string(),
duration: value,
});
}
if !timelines.is_empty() {
write!(f, " ")?;
for (i, timeline) in timelines.iter().enumerate() {
write!(
f,
"{}",
timeline.name.unicode_pad(width, Alignment::Center, true)
)?;
if i < timelines.len() - 1 {
write!(f, " ")?;
}
}
writeln!(f)?;
write!(f, "[")?;
for (i, timeline) in timelines.iter().enumerate() {
write!(
f,
"{}",
LightCyan.paint(
format_duration(timeline.duration)
.unicode_pad(width, Alignment::Center, true)
.to_string(),
)
)?;
if i < timelines.len() - 1 {
write!(f, "|")?;
}
}
writeln!(f, "]")?;
}
write!(f, " ")?;
for _ in 0..timelines.len() {
write!(f, "{}", " ".repeat(width))?;
write!(f, "|")?;
}
writeln!(f)?;
write!(f, "{}", " ".repeat(width * timelines.len()))?;
write!(
f,
"{}:{}\n\n",
s.total,
LightCyan.paint(format_duration(self.total.unwrap_or_default()))
)?;
}
if let Some(body) = &self.body {
let status = self.status.unwrap_or(StatusCode::OK).as_u16();
let mut body = std::str::from_utf8(body.as_ref())
.unwrap_or_default()
.to_string();
if let Some(filter) = &self.jq_filter {
if let Some(filtered) = apply_jq_filter(&body, filter) {
body = filtered;
}
} else if self.pretty && is_json {
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
if let Ok(value) = serde_json::to_string_pretty(&json_body) {
body = value;
}
}
}
if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
let text = format!(
"{}: {}",
s.body_size,
ByteSize(self.body_size.unwrap_or(0) as u64)
);
writeln!(f, "{}", LightCyan.paint(text))?;
self.fmt_throughput(f)?;
writeln!(f)?;
if status >= 400 {
writeln!(f, "{}", LightRed.paint(body))?;
} else {
writeln!(f, "{body}")?;
}
} else {
let mut save_tips = "".to_string();
if let Ok(mut file) = NamedTempFile::new() {
if let Ok(()) = file.write_all(body.as_bytes()) {
save_tips = format!("{}: {}", s.saved_to, file.path().display());
let _ = file.keep();
}
}
let text = format!(
"{} {}",
s.body_discarded,
ByteSize(self.body_size.unwrap_or(0) as u64)
);
writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
self.fmt_throughput(f)?;
}
}
Ok(())
}
}
impl HttpStat {
fn fmt_throughput(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(wire) = self.wire_body_size else {
return Ok(());
};
if wire < THROUGHPUT_DISPLAY_THRESHOLD {
return Ok(());
}
let Some(total) = self.throughput_bps() else {
return Ok(());
};
let s = self.lang.strings();
writeln!(
f,
"{} {}",
LightCyan.paint(s.throughput),
LightCyan.paint(format_throughput(total)),
)?;
if self.verbose {
if let (Some(first), Some(tail)) = (
self.first_chunk_throughput_bps(),
self.tail_throughput_bps(),
) {
writeln!(
f,
" {} {} {} {} {}",
LightCyan.paint(s.throughput_first_100k),
LightCyan.paint(format_throughput(first)),
LightCyan.paint("·"),
LightCyan.paint(s.throughput_then),
LightCyan.paint(format_throughput(tail)),
)?;
}
}
Ok(())
}
}
pub struct BenchmarkSummary {
pub stats: Vec<HttpStat>,
pub lang: Lang,
}
impl BenchmarkSummary {
fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
v.sort();
v
}
fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
if sorted.is_empty() {
return None;
}
let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
Some(sorted[idx.min(sorted.len() - 1)])
}
}
impl fmt::Display for BenchmarkSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total = self.stats.len();
if total == 0 {
return Ok(());
}
let strs = self.lang.strings();
let has_dns_connect = self.stats.iter().any(|s| s.dns_connect.is_some());
let dns_cols: Vec<(&str, Vec<Duration>)> = if has_dns_connect {
vec![
(strs.dns_connect, self.collect_sorted(|s| s.dns_connect)),
(strs.dns_query, self.collect_sorted(|s| s.dns_query())),
]
} else {
vec![(strs.dns_lookup, self.collect_sorted(|s| s.dns_lookup))]
};
let phases: Vec<(&str, Vec<Duration>)> = dns_cols
.into_iter()
.chain([
(strs.tcp_connect, self.collect_sorted(|s| s.tcp_connect)),
(strs.tls_handshake, self.collect_sorted(|s| s.tls_handshake)),
(strs.quic_connect, self.collect_sorted(|s| s.quic_connect)),
(strs.request_send, self.collect_sorted(|s| s.request_send)),
(
strs.server_processing_short,
self.collect_sorted(|s| s.server_processing),
),
(
strs.content_transfer_short,
self.collect_sorted(|s| s.content_transfer),
),
(strs.total, self.collect_sorted(|s| s.total)),
])
.filter(|(_, v)| !v.is_empty())
.collect();
if phases.is_empty() {
return Ok(());
}
writeln!(f)?;
writeln!(
f,
"{}",
LightGreen.paint(format!(
"{} ({total} {}) ---",
strs.benchmark_results_prefix, strs.benchmark_results_requests,
))
)?;
writeln!(f)?;
let col_w = 18;
let label_w = 6;
write!(f, "{:>label_w$} ", "")?;
for (name, _) in &phases {
write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
}
writeln!(f)?;
let rows: [(&str, f64); 6] = [
(strs.min, 0.0),
(strs.max, f64::INFINITY),
(strs.avg, f64::NAN),
("p50", 0.5),
("p95", 0.95),
("p99", 0.99),
];
for (label, p) in &rows {
write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
for (_, sorted) in &phases {
let val = if p.is_nan() {
if sorted.is_empty() {
None
} else {
let sum: Duration = sorted.iter().sum();
Some(sum / sorted.len() as u32)
}
} else if *p == 0.0 {
sorted.first().copied()
} else if p.is_infinite() {
sorted.last().copied()
} else {
Self::percentile(sorted, *p)
};
let text = match val {
Some(d) => format_duration(d),
None => "-".to_string(),
};
write!(
f,
"{}",
LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
)?;
}
writeln!(f)?;
}
writeln!(f)?;
let success = self.stats.iter().filter(|s| s.is_success()).count();
let pct = (success as f64 / total as f64) * 100.0;
let success_text = format!("{}: {success}/{total} ({pct:.1}%)", strs.success);
if success == total {
writeln!(f, " {}", LightGreen.paint(success_text))?;
} else {
writeln!(f, " {}", LightRed.paint(success_text))?;
}
Ok(())
}
}