1use crate::i18n::Lang;
19use crate::tcp_info::{TcpInfo, TcpInfoDelta};
20use bytes::Bytes;
21use bytesize::ByteSize;
22use chrono::{Local, TimeZone};
23use heck::ToTrainCase;
24use http::HeaderMap;
25use http::HeaderValue;
26use http::StatusCode;
27use nu_ansi_term::Color::{LightCyan, LightGreen, LightRed, LightYellow};
28use serde_json::{json, Map, Value};
29use std::fmt;
30use std::io::Write;
31use std::time::Duration;
32use tempfile::NamedTempFile;
33use unicode_truncate::Alignment;
34use unicode_truncate::UnicodeTruncateStr;
35
36pub static ALPN_HTTP2: &str = "h2";
37pub static ALPN_HTTP1: &str = "http/1.1";
38pub static ALPN_HTTP3: &str = "h3";
39
40pub(crate) fn format_time(timestamp_seconds: i64) -> String {
42 Local
43 .timestamp_nanos(timestamp_seconds * 1_000_000_000)
44 .to_string()
45}
46
47pub fn format_duration(duration: Duration) -> String {
48 if duration > Duration::from_secs(1) {
49 return format!("{:.2}s", duration.as_secs_f64());
50 }
51 if duration > Duration::from_millis(1) {
52 return format!("{}ms", duration.as_millis());
53 }
54 format!("{}µs", duration.as_micros())
55}
56
57pub fn format_throughput(bytes_per_sec: f64) -> String {
61 if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
62 return "-".to_string();
63 }
64 if bytes_per_sec >= 1_000_000.0 {
65 format!("{:.1} MB/s", bytes_per_sec / 1_000_000.0)
66 } else if bytes_per_sec >= 1_000.0 {
67 format!("{:.1} KB/s", bytes_per_sec / 1_000.0)
68 } else {
69 format!("{bytes_per_sec:.0} B/s")
70 }
71}
72
73pub const FIRST_CHUNK_BYTES: usize = 100 * 1024;
75pub const THROUGHPUT_DISPLAY_THRESHOLD: usize = 1024 * 1024;
77
78struct Timeline {
79 name: String,
80 duration: Duration,
81}
82
83#[derive(Default, Debug, Clone)]
110pub struct HttpStat {
111 pub is_grpc: bool,
112 pub request_headers: HeaderMap<HeaderValue>,
113 pub dns_lookup: Option<Duration>,
114 pub dns_connect: Option<Duration>,
120 pub quic_connect: Option<Duration>,
121 pub tcp_connect: Option<Duration>,
122 pub tcp_info_post_connect: Option<TcpInfo>,
126 pub tcp_info_final: Option<TcpInfo>,
131 pub tls_handshake: Option<Duration>,
132 pub request_send: Option<Duration>,
133 pub server_processing: Option<Duration>,
134 pub content_transfer: Option<Duration>,
135 pub wire_body_size: Option<usize>,
139 pub time_to_first_100k: Option<Duration>,
145 pub server_timing: Option<Vec<ServerTiming>>,
146 pub total: Option<Duration>,
147 pub addr: Option<String>,
148 pub grpc_status: Option<String>,
149 pub status: Option<StatusCode>,
150 pub tls: Option<String>,
151 pub tls_resumed: Option<bool>,
152 pub tls_early_data_accepted: Option<bool>,
153 pub tls_ocsp_stapled: Option<bool>,
154 pub alpn: Option<String>,
155 pub subject: Option<String>,
156 pub issuer: Option<String>,
157 pub cert_not_before: Option<String>,
158 pub cert_not_after: Option<String>,
159 pub cert_cipher: Option<String>,
160 pub cert_domains: Option<Vec<String>>,
161 pub certificates: Option<Vec<Certificate>>,
162 pub body: Option<Bytes>,
163 pub body_size: Option<usize>,
164 pub headers: Option<HeaderMap<HeaderValue>>,
165 pub error: Option<String>,
166 pub silent: bool,
167 pub verbose: bool,
168 pub pretty: bool,
169 pub include_headers: Option<Vec<String>>,
170 pub exclude_headers: Option<Vec<String>>,
171 pub waterfall: bool,
172 pub jq_filter: Option<String>,
173 pub show_tcp_info: bool,
176 pub lang: Lang,
180}
181
182#[derive(Debug, Clone)]
183pub struct Certificate {
184 pub subject: String,
185 pub issuer: String,
186 pub not_before: String,
187 pub not_after: String,
188}
189
190#[derive(Debug, Clone)]
195pub struct ServerTiming {
196 pub name: String,
197 pub duration: Option<Duration>,
198 pub description: Option<String>,
199}
200
201pub fn parse_server_timing<'a, I>(values: I) -> Option<Vec<ServerTiming>>
204where
205 I: IntoIterator<Item = &'a str>,
206{
207 let mut out = Vec::new();
208 for raw in values {
209 for part in split_top_level_commas(raw) {
210 let mut subparts = part.split(';').map(str::trim);
211 let name = match subparts.next() {
212 Some(n) if !n.is_empty() => n.to_string(),
213 _ => continue,
214 };
215 let mut entry = ServerTiming {
216 name,
217 duration: None,
218 description: None,
219 };
220 for kv in subparts {
221 let Some(eq) = kv.find('=') else { continue };
222 let key = kv[..eq].trim().to_ascii_lowercase();
223 let mut val = kv[eq + 1..].trim();
224 if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
225 val = &val[1..val.len() - 1];
226 }
227 match key.as_str() {
228 "dur" => {
229 if let Ok(ms) = val.parse::<f64>() {
230 if ms.is_finite() && ms >= 0.0 {
231 entry.duration = Some(Duration::from_secs_f64(ms / 1000.0));
232 }
233 }
234 }
235 "desc" => entry.description = Some(val.to_string()),
236 _ => {}
237 }
238 }
239 out.push(entry);
240 }
241 }
242 if out.is_empty() {
243 None
244 } else {
245 Some(out)
246 }
247}
248
249fn split_top_level_commas(s: &str) -> Vec<&str> {
251 let mut parts = Vec::new();
252 let mut start = 0usize;
253 let mut in_quotes = false;
254 let bytes = s.as_bytes();
255 let mut i = 0;
256 while i < bytes.len() {
257 match bytes[i] {
258 b'"' => in_quotes = !in_quotes,
259 b',' if !in_quotes => {
260 parts.push(s[start..i].trim());
261 start = i + 1;
262 }
263 _ => {}
264 }
265 i += 1;
266 }
267 parts.push(s[start..].trim());
268 parts
269}
270
271fn apply_jq_filter(body: &str, filter: &str) -> Option<String> {
280 let root: serde_json::Value = serde_json::from_str(body).ok()?;
281 let filter = filter.trim();
282 let owned;
284 let filter = if !filter.starts_with('.') {
285 owned = format!(".{filter}");
286 owned.as_str()
287 } else {
288 filter
289 };
290
291 #[derive(Debug)]
293 enum Step {
294 Key(String),
295 Index(usize),
296 Iter,
297 }
298
299 fn tokenize(s: &str) -> Option<Vec<Step>> {
300 let s = s.strip_prefix('.')?;
301 if s.is_empty() {
302 return Some(vec![]);
303 }
304 let mut steps = Vec::new();
305 let mut remaining = s;
308 while !remaining.is_empty() {
309 if remaining.starts_with('[') {
310 let end = remaining.find(']')?;
312 let inner = &remaining[1..end];
313 if inner.is_empty() {
314 steps.push(Step::Iter);
315 } else {
316 let idx: usize = inner.parse().ok()?;
317 steps.push(Step::Index(idx));
318 }
319 remaining = &remaining[end + 1..];
320 if remaining.starts_with('.') {
321 remaining = &remaining[1..];
322 }
323 } else {
324 let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
326 let key = &remaining[..end];
327 if !key.is_empty() {
328 steps.push(Step::Key(key.to_string()));
329 }
330 remaining = &remaining[end..];
331 if remaining.starts_with('.') {
332 remaining = &remaining[1..];
333 }
334 }
335 }
336 Some(steps)
337 }
338
339 fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
340 if steps.is_empty() {
341 return values;
342 }
343 let mut current = values;
344 for step in steps {
345 current = match step {
346 Step::Key(k) => current
347 .into_iter()
348 .filter_map(|v| v.get(k).cloned())
349 .collect(),
350 Step::Index(i) => current
351 .into_iter()
352 .filter_map(|v| v.get(i).cloned())
353 .collect(),
354 Step::Iter => current
355 .into_iter()
356 .flat_map(|v| match v {
357 serde_json::Value::Array(arr) => arr,
358 serde_json::Value::Object(map) => map.into_values().collect(),
359 other => vec![other],
360 })
361 .collect(),
362 };
363 }
364 current
365 }
366
367 let steps = tokenize(filter)?;
368 let results = apply_steps(vec![root], &steps);
369
370 if results.len() == 1 {
371 serde_json::to_string_pretty(&results[0]).ok()
372 } else {
373 Some(
374 results
375 .iter()
376 .filter_map(|v| serde_json::to_string_pretty(v).ok())
377 .collect::<Vec<_>>()
378 .join("\n"),
379 )
380 }
381}
382
383impl HttpStat {
384 pub fn exit_code(&self) -> i32 {
394 if self.is_success() {
395 return 0;
396 }
397 if self.error.is_none() {
399 if let Some(status) = &self.status {
400 let code = status.as_u16();
401 if code >= 500 {
402 return 7;
403 }
404 if code >= 400 {
405 return 6;
406 }
407 }
408 return 1;
409 }
410 let err = self.error.as_deref().unwrap_or_default().to_lowercase();
411 if err.contains("timeout") || err.contains("elapsed") {
413 return 5;
414 }
415 if self.dns_lookup.is_none() {
417 return 2;
418 }
419 if self.tcp_connect.is_none() && self.quic_connect.is_none() {
421 return 3;
422 }
423 if err.contains("rustls")
425 || err.contains("tls")
426 || err.contains("certificate")
427 || err.contains("invalid dns name")
428 {
429 return 4;
430 }
431 1
432 }
433
434 pub fn dns_query(&self) -> Option<Duration> {
439 match (self.dns_lookup, self.dns_connect) {
440 (Some(total), Some(connect)) => Some(total.saturating_sub(connect)),
441 _ => None,
442 }
443 }
444
445 pub fn throughput_bps(&self) -> Option<f64> {
449 let bytes = self.wire_body_size? as f64;
450 let secs = self.content_transfer?.as_secs_f64();
451 if secs <= 0.0 {
452 return None;
453 }
454 Some(bytes / secs)
455 }
456
457 pub fn first_chunk_throughput_bps(&self) -> Option<f64> {
461 let dur = self.time_to_first_100k?.as_secs_f64();
462 if dur <= 0.0 {
463 return None;
464 }
465 Some(FIRST_CHUNK_BYTES as f64 / dur)
466 }
467
468 pub fn tail_throughput_bps(&self) -> Option<f64> {
471 let total = self.content_transfer?;
472 let head = self.time_to_first_100k?;
473 let bytes = self.wire_body_size?;
474 if bytes <= FIRST_CHUNK_BYTES || total <= head {
475 return None;
476 }
477 let tail_bytes = (bytes - FIRST_CHUNK_BYTES) as f64;
478 let tail_secs = (total - head).as_secs_f64();
479 if tail_secs <= 0.0 {
480 return None;
481 }
482 Some(tail_bytes / tail_secs)
483 }
484
485 pub fn is_success(&self) -> bool {
486 if self.error.is_some() {
487 return false;
488 }
489 if self.is_grpc {
490 if let Some(grpc_status) = &self.grpc_status {
491 return grpc_status == "0";
492 }
493 return false;
494 }
495 let Some(status) = &self.status else {
496 return false;
497 };
498 if status.as_u16() >= 400 {
499 return false;
500 }
501 true
502 }
503
504 fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507 let total = match self.total {
508 Some(t) if t.as_nanos() > 0 => t,
509 _ => return Ok(()),
510 };
511
512 const BAR_WIDTH: usize = 50;
513 const LABEL_W: usize = 15;
514
515 let s = self.lang.strings();
516 let (dns_a, dns_b) = if self.dns_connect.is_some() {
518 (
519 (s.dns_connect, self.dns_connect),
520 (s.dns_query, self.dns_query()),
521 )
522 } else {
523 ((s.dns_lookup, self.dns_lookup), ("", None))
524 };
525 let phases_vec: Vec<(&str, Option<Duration>)> = vec![
526 dns_a,
527 dns_b,
528 (s.tcp_connect, self.tcp_connect),
529 (s.tls_handshake, self.tls_handshake),
530 (s.quic_connect, self.quic_connect),
531 (s.request_send, self.request_send),
532 (s.server_processing_short, self.server_processing),
533 (s.content_transfer_short, self.content_transfer),
534 ];
535 let phases: &[(&str, Option<Duration>)] = &phases_vec;
536
537 let total_ns = total.as_nanos() as f64;
538 let mut elapsed = Duration::ZERO;
539 let mut col_cursor: usize = 0;
540
541 for (name, dur_opt) in phases {
542 let Some(dur) = dur_opt else { continue };
543
544 let start_col = col_cursor;
545 elapsed += *dur;
546 let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
547 as usize)
548 .min(BAR_WIDTH);
549 let end_col = ideal_end.min(BAR_WIDTH);
550 if end_col > start_col {
551 col_cursor = end_col;
552 }
553
554 let bar: String = (0..BAR_WIDTH)
555 .map(|i| {
556 if i >= start_col && i < end_col {
557 '█'
558 } else {
559 '░'
560 }
561 })
562 .collect();
563
564 writeln!(
565 f,
566 " {:<LABEL_W$} [{}] {}",
567 name,
568 LightCyan.paint(bar),
569 LightCyan.paint(format_duration(*dur))
570 )?;
571 }
572
573 writeln!(f)?;
574 writeln!(
575 f,
576 " {:LABEL_W$} {:BAR_WIDTH$} {}: {}",
577 "",
578 "",
579 s.total,
580 LightCyan.paint(format_duration(total))
581 )?;
582 writeln!(f)
583 }
584
585 pub fn to_json(&self) -> Value {
586 let dur_us = |d: Option<Duration>| -> Value {
587 d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
588 };
589
590 let mut obj = Map::new();
591
592 let mut timing = Map::new();
594 timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
595 timing.insert("dns_connect_us".into(), dur_us(self.dns_connect));
596 timing.insert("dns_query_us".into(), dur_us(self.dns_query()));
597 timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
598 timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
599 timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
600 timing.insert("request_send_us".into(), dur_us(self.request_send));
601 timing.insert(
602 "server_processing_us".into(),
603 dur_us(self.server_processing),
604 );
605 timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
606 timing.insert(
607 "time_to_first_100k_us".into(),
608 dur_us(self.time_to_first_100k),
609 );
610 timing.insert("total_us".into(), dur_us(self.total));
611 obj.insert("timing".into(), Value::Object(timing));
612
613 if self.wire_body_size.is_some() && self.content_transfer.is_some() {
616 let mut t = Map::new();
617 t.insert(
618 "wire_body_size".into(),
619 self.wire_body_size.map_or(Value::Null, |v| json!(v)),
620 );
621 let opt_f = |v: Option<f64>| -> Value { v.map_or(Value::Null, |x| json!(x)) };
622 t.insert("bps_total".into(), opt_f(self.throughput_bps()));
623 t.insert(
624 "bps_first_100k".into(),
625 opt_f(self.first_chunk_throughput_bps()),
626 );
627 t.insert("bps_tail".into(), opt_f(self.tail_throughput_bps()));
628 obj.insert("throughput".into(), Value::Object(t));
629 }
630
631 let tcp_info_json = |info: Option<&TcpInfo>| -> Value {
634 let Some(info) = info else { return Value::Null };
635 let mut m = Map::new();
636 m.insert("rtt_us".into(), dur_us(info.rtt));
637 m.insert("rttvar_us".into(), dur_us(info.rttvar));
638 m.insert(
639 "retransmits".into(),
640 info.retransmits.map_or(Value::Null, |v| json!(v)),
641 );
642 m.insert("cwnd".into(), info.cwnd.map_or(Value::Null, |v| json!(v)));
643 m.insert(
644 "snd_mss".into(),
645 info.snd_mss.map_or(Value::Null, |v| json!(v)),
646 );
647 Value::Object(m)
648 };
649 if self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some() {
650 let mut block = Map::new();
651 block.insert(
652 "post_connect".into(),
653 tcp_info_json(self.tcp_info_post_connect.as_ref()),
654 );
655 block.insert("final".into(), tcp_info_json(self.tcp_info_final.as_ref()));
656 if let Some(delta) = TcpInfoDelta::compute(
657 self.tcp_info_post_connect.as_ref(),
658 self.tcp_info_final.as_ref(),
659 ) {
660 let mut d = Map::new();
661 d.insert(
662 "retransmits_during".into(),
663 delta.retransmits_during.map_or(Value::Null, |v| json!(v)),
664 );
665 d.insert("rtt_final_us".into(), dur_us(delta.rtt_final));
666 d.insert(
667 "cwnd_final".into(),
668 delta.cwnd_final.map_or(Value::Null, |v| json!(v)),
669 );
670 block.insert("delta".into(), Value::Object(d));
671 }
672 obj.insert("tcp_info".into(), Value::Object(block));
673 }
674
675 if let Some(entries) = &self.server_timing {
677 let arr: Vec<Value> = entries
678 .iter()
679 .map(|e| {
680 let mut m = Map::new();
681 m.insert("name".into(), json!(e.name));
682 m.insert(
683 "duration_us".into(),
684 e.duration
685 .map_or(Value::Null, |d| json!(d.as_micros() as u64)),
686 );
687 m.insert(
688 "description".into(),
689 e.description.as_deref().map_or(Value::Null, |s| json!(s)),
690 );
691 Value::Object(m)
692 })
693 .collect();
694 obj.insert("server_timing".into(), Value::Array(arr));
695 }
696
697 obj.insert(
699 "addr".into(),
700 self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
701 );
702 obj.insert(
703 "status".into(),
704 self.status.map_or(Value::Null, |s| json!(s.as_u16())),
705 );
706 obj.insert(
707 "alpn".into(),
708 self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
709 );
710
711 if self.tls.is_some() {
713 let mut tls = Map::new();
714 tls.insert(
715 "version".into(),
716 self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
717 );
718 tls.insert(
719 "cipher".into(),
720 self.cert_cipher
721 .as_deref()
722 .map_or(Value::Null, |s| json!(s)),
723 );
724 tls.insert(
725 "resumed".into(),
726 self.tls_resumed.map_or(Value::Null, |b| json!(b)),
727 );
728 tls.insert(
729 "early_data_accepted".into(),
730 self.tls_early_data_accepted
731 .map_or(Value::Null, |b| json!(b)),
732 );
733 tls.insert(
734 "ocsp_stapled".into(),
735 self.tls_ocsp_stapled.map_or(Value::Null, |b| json!(b)),
736 );
737 tls.insert(
738 "subject".into(),
739 self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
740 );
741 tls.insert(
742 "issuer".into(),
743 self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
744 );
745 tls.insert(
746 "not_before".into(),
747 self.cert_not_before
748 .as_deref()
749 .map_or(Value::Null, |s| json!(s)),
750 );
751 tls.insert(
752 "not_after".into(),
753 self.cert_not_after
754 .as_deref()
755 .map_or(Value::Null, |s| json!(s)),
756 );
757 tls.insert(
758 "domains".into(),
759 self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
760 );
761 obj.insert("tls".into(), Value::Object(tls));
762 }
763
764 if let Some(headers) = &self.headers {
766 let mut hdr_map = Map::new();
767 for (key, value) in headers.iter() {
768 let v = value.to_str().unwrap_or_default().to_string();
769 hdr_map.insert(key.to_string(), json!(v));
770 }
771 obj.insert("headers".into(), Value::Object(hdr_map));
772 }
773
774 obj.insert(
776 "body_size".into(),
777 self.body_size.map_or(Value::Null, |s| json!(s)),
778 );
779
780 obj.insert(
782 "error".into(),
783 self.error.as_deref().map_or(Value::Null, |e| json!(e)),
784 );
785 obj.insert("exit_code".into(), json!(self.exit_code()));
786
787 Value::Object(obj)
788 }
789}
790
791impl fmt::Display for HttpStat {
792 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793 let s = self.lang.strings();
794 if let Some(addr) = &self.addr {
795 let label = if self.tcp_connect.is_some() || self.quic_connect.is_some() {
796 LightGreen.paint(s.connected_to)
797 } else {
798 LightYellow.paint(s.resolved_to)
799 };
800 let mut text = format!("{} {}", label, LightCyan.paint(addr));
801 if self.silent {
802 if let Some(status) = &self.status {
803 let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
804 let status_code = status.as_u16();
805 let status = if status_code < 400 {
806 LightGreen.paint(status.to_string())
807 } else {
808 LightRed.paint(status.to_string())
809 };
810 text = format!(
811 "{text} --> {} {}",
812 LightCyan.paint(alpn.to_uppercase()),
813 status
814 );
815 } else {
816 text = format!("{text} --> {}", LightRed.paint(s.fail));
817 }
818 text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
819 if let Some(true) = self.tls_resumed {
823 let tag = if matches!(self.tls_early_data_accepted, Some(true)) {
824 "0-RTT"
825 } else {
826 s.handshake_resumed
827 };
828 text = format!("{text} [{}]", LightYellow.paint(tag));
829 }
830 }
831 writeln!(f, "{text}")?;
832 }
833 if let Some(error) = &self.error {
834 writeln!(f, "{}: {}", s.error_label, LightRed.paint(error))?;
835 }
836 if self.silent {
837 return Ok(());
838 }
839 if self.verbose {
840 for (key, value) in self.request_headers.iter() {
841 writeln!(
842 f,
843 "{}: {}",
844 key.to_string().to_train_case(),
845 LightCyan.paint(value.to_str().unwrap_or_default())
846 )?;
847 }
848 writeln!(f)?;
849 }
850
851 if let Some(status) = &self.status {
852 let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
853 let status_code = status.as_u16();
854 let status = if status_code < 400 {
855 LightGreen.paint(status.to_string())
856 } else {
857 LightRed.paint(status.to_string())
858 };
859 writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
860 }
861 if self.is_grpc {
862 if self.is_success() {
863 writeln!(f, "{}", LightGreen.paint(s.grpc_ok))?;
864 }
865 writeln!(f)?;
866 }
867
868 if let Some(tls) = &self.tls {
869 writeln!(f)?;
870 writeln!(f, "{}: {}", s.tls_label, LightCyan.paint(tls))?;
871 writeln!(
872 f,
873 "{}: {}",
874 s.cipher,
875 LightCyan.paint(self.cert_cipher.as_deref().unwrap_or_default())
876 )?;
877 if let Some(resumed) = self.tls_resumed {
878 let label = if resumed {
879 s.handshake_resumed
880 } else {
881 s.handshake_full
882 };
883 writeln!(f, "{}: {}", s.handshake, LightCyan.paint(label))?;
884 }
885 if let Some(accepted) = self.tls_early_data_accepted {
886 let label = if accepted {
887 s.early_data_accepted
888 } else {
889 s.early_data_not_accepted
890 };
891 writeln!(f, "{}: {}", s.early_data, LightCyan.paint(label))?;
892 }
893 if let Some(stapled) = self.tls_ocsp_stapled {
894 let label = if stapled {
895 s.ocsp_stapled
896 } else {
897 s.ocsp_not_stapled
898 };
899 writeln!(f, "{}: {}", s.ocsp, LightCyan.paint(label))?;
900 }
901 writeln!(
902 f,
903 "{}: {}",
904 s.not_before,
905 LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
906 )?;
907 writeln!(
908 f,
909 "{}: {}",
910 s.not_after,
911 LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
912 )?;
913 if self.verbose {
914 writeln!(
915 f,
916 "{}: {}",
917 s.subject,
918 LightCyan.paint(self.subject.as_deref().unwrap_or_default())
919 )?;
920 writeln!(
921 f,
922 "{}: {}",
923 s.issuer,
924 LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
925 )?;
926 writeln!(
927 f,
928 "{}: {}",
929 s.cert_domains,
930 LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
931 )?;
932 }
933 writeln!(f)?;
934
935 if self.verbose {
936 if let Some(certificates) = &self.certificates {
937 writeln!(f, "{}", s.cert_chain)?;
938 for (index, cert) in certificates.iter().enumerate() {
939 writeln!(
940 f,
941 " {index} {}: {}",
942 s.subject,
943 LightCyan.paint(cert.subject.as_str())
944 )?;
945 writeln!(
946 f,
947 " {}: {}",
948 s.issuer,
949 LightCyan.paint(cert.issuer.as_str())
950 )?;
951 writeln!(
952 f,
953 " {}: {}",
954 s.not_before,
955 LightCyan.paint(cert.not_before.as_str())
956 )?;
957 writeln!(
958 f,
959 " {}: {}",
960 s.not_after,
961 LightCyan.paint(cert.not_after.as_str())
962 )?;
963 writeln!(f)?;
964 }
965 }
966 }
967 }
968
969 if (self.verbose || self.show_tcp_info)
974 && (self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some())
975 {
976 writeln!(f, "{}", LightGreen.paint(s.kernel_tcp_heading))?;
977 let render = |label: &str, info: &TcpInfo| -> String {
980 let rtt = info.rtt.map(format_duration).unwrap_or_else(|| "-".into());
981 let rttvar = info
982 .rttvar
983 .map(format_duration)
984 .unwrap_or_else(|| "-".into());
985 let retrans = info
986 .retransmits
987 .map(|v| v.to_string())
988 .unwrap_or_else(|| "-".into());
989 let cwnd = info
990 .cwnd
991 .map(|v| v.to_string())
992 .unwrap_or_else(|| "-".into());
993 let mss = info
994 .snd_mss
995 .map(|v| v.to_string())
996 .unwrap_or_else(|| "-".into());
997 format!(
998 " {:<14} rtt {} \u{00B1} {} retrans {} cwnd {} mss {}",
999 label, rtt, rttvar, retrans, cwnd, mss,
1000 )
1001 };
1002 if let Some(info) = &self.tcp_info_post_connect {
1003 writeln!(
1004 f,
1005 "{}",
1006 LightCyan.paint(render(s.tcp_post_connect_row, info))
1007 )?;
1008 }
1009 if let Some(info) = &self.tcp_info_final {
1010 writeln!(f, "{}", LightCyan.paint(render(s.tcp_final_row, info)))?;
1011 }
1012 if let Some(delta) = TcpInfoDelta::compute(
1013 self.tcp_info_post_connect.as_ref(),
1014 self.tcp_info_final.as_ref(),
1015 ) {
1016 if let Some(n) = delta.retransmits_during {
1017 let label = format!(" {} {n} {}", s.tcp_during, s.tcp_retransmit_word);
1018 let painted = if n == 0 {
1019 LightCyan.paint(label)
1020 } else {
1021 LightYellow.paint(label)
1022 };
1023 writeln!(f, "{painted}")?;
1024 }
1025 }
1026 writeln!(f)?;
1027 }
1028
1029 let mut is_text = false;
1030 let mut is_json = false;
1031 if let Some(headers) = &self.headers {
1032 for (key, value) in headers.iter() {
1033 let value = value.to_str().unwrap_or_default();
1034 if key == http::header::CONTENT_TYPE {
1035 if value.contains("text/") || value.contains("application/json") {
1036 is_text = true;
1037 }
1038 if value.contains("application/json") {
1039 is_json = true;
1040 }
1041 }
1042 let key_lower = key.as_str();
1043 let show = if let Some(includes) = &self.include_headers {
1044 includes.iter().any(|h| h == key_lower)
1045 } else if let Some(excludes) = &self.exclude_headers {
1046 !excludes.iter().any(|h| h == key_lower)
1047 } else {
1048 true
1049 };
1050 if show {
1051 writeln!(
1052 f,
1053 "{}: {}",
1054 key.to_string().to_train_case(),
1055 LightCyan.paint(value)
1056 )?;
1057 }
1058 }
1059 writeln!(f)?;
1060 }
1061
1062 if let Some(entries) = &self.server_timing {
1067 if !entries.is_empty() {
1068 const BAR_W: usize = 34;
1069 let name_w = entries
1070 .iter()
1071 .map(|e| e.name.chars().count())
1072 .max()
1073 .unwrap_or(0)
1074 .max(4);
1075
1076 let sum: Duration = entries.iter().filter_map(|e| e.duration).sum();
1077 let total_ns = (sum.as_nanos() as f64).max(1.0);
1078 let largest_idx: Option<usize> = entries
1079 .iter()
1080 .enumerate()
1081 .filter_map(|(i, e)| e.duration.filter(|d| !d.is_zero()).map(|d| (i, d)))
1082 .max_by_key(|(_, d)| *d)
1083 .map(|(i, _)| i);
1084
1085 let summary = match self.server_processing {
1086 Some(sp) if sp > sum => format!(
1087 "(\u{03A3} {} {} {} {} \u{00B7} {} {})",
1088 format_duration(sum),
1089 s.st_sum_of,
1090 format_duration(sp),
1091 s.server_processing,
1092 format_duration(sp - sum),
1093 s.st_unaccounted,
1094 ),
1095 Some(sp) => format!(
1096 "(\u{03A3} {} {} {} {})",
1097 format_duration(sum),
1098 s.st_sum_of,
1099 format_duration(sp),
1100 s.server_processing,
1101 ),
1102 None => format!("(\u{03A3} {})", format_duration(sum)),
1103 };
1104 writeln!(
1105 f,
1106 "{} {}",
1107 LightGreen.paint(s.server_timing_heading),
1108 LightCyan.paint(&summary),
1109 )?;
1110
1111 let sum_ns_u = sum.as_nanos();
1114 let mut cum_ns: u128 = 0;
1115 for (i, entry) in entries.iter().enumerate() {
1116 let name_pad = " ".repeat(name_w.saturating_sub(entry.name.chars().count()));
1117 let dur_ns = entry.duration.map(|d| d.as_nanos()).unwrap_or(0);
1118
1119 let start_col = ((cum_ns as f64 / total_ns) * BAR_W as f64).round() as usize;
1120 let start_col = start_col.min(BAR_W);
1121 let mut end_col =
1122 (((cum_ns + dur_ns) as f64 / total_ns) * BAR_W as f64).round() as usize;
1123 end_col = end_col.min(BAR_W);
1124 if dur_ns > 0 && end_col <= start_col {
1127 end_col = (start_col + 1).min(BAR_W);
1128 }
1129
1130 let bar: String = (0..BAR_W)
1131 .map(|col| {
1132 if dur_ns == 0 {
1133 let marker = start_col.min(BAR_W - 1);
1134 if col == marker {
1135 '\u{00B7}'
1136 } else {
1137 '\u{2591}'
1138 }
1139 } else if col >= start_col && col < end_col {
1140 '\u{2588}'
1141 } else {
1142 '\u{2591}'
1143 }
1144 })
1145 .collect();
1146
1147 let (dur_str, pct_str) = if dur_ns > 0 {
1148 let pct = if sum_ns_u > 0 {
1149 (dur_ns as f64 / sum_ns_u as f64) * 100.0
1150 } else {
1151 0.0
1152 };
1153 (
1154 format_duration(entry.duration.unwrap_or_default()),
1155 format!("{pct:>5.1}%"),
1156 )
1157 } else {
1158 ("\u{2014}".to_string(), "\u{2013}".to_string())
1159 };
1160
1161 let is_largest = Some(i) == largest_idx;
1162 let bar_painted = if is_largest {
1163 LightYellow.paint(&bar).to_string()
1164 } else {
1165 LightCyan.paint(&bar).to_string()
1166 };
1167 let dur_painted = if is_largest {
1168 LightYellow.paint(format!("{dur_str:>8}")).to_string()
1169 } else {
1170 LightCyan.paint(format!("{dur_str:>8}")).to_string()
1171 };
1172 let pct_painted = LightCyan.paint(format!("{pct_str:>6}")).to_string();
1173 let desc = entry
1174 .description
1175 .as_deref()
1176 .map(|d| format!(" ({d})"))
1177 .unwrap_or_default();
1178
1179 writeln!(
1180 f,
1181 " {}{} {} {} {}{}",
1182 LightCyan.paint(&entry.name),
1183 name_pad,
1184 bar_painted,
1185 dur_painted,
1186 pct_painted,
1187 desc,
1188 )?;
1189
1190 cum_ns += dur_ns;
1191 }
1192 writeln!(f)?;
1193 }
1194 }
1195
1196 if self.waterfall {
1197 self.fmt_waterfall(f)?;
1198 } else {
1199 let width = 20;
1200
1201 let mut timelines = vec![];
1202 if let Some(connect) = self.dns_connect {
1205 timelines.push(Timeline {
1206 name: s.dns_connect.to_string(),
1207 duration: connect,
1208 });
1209 if let Some(query) = self.dns_query() {
1210 timelines.push(Timeline {
1211 name: s.dns_query.to_string(),
1212 duration: query,
1213 });
1214 }
1215 } else if let Some(value) = self.dns_lookup {
1216 timelines.push(Timeline {
1217 name: s.dns_lookup.to_string(),
1218 duration: value,
1219 });
1220 }
1221 if let Some(value) = self.tcp_connect {
1222 timelines.push(Timeline {
1223 name: s.tcp_connect.to_string(),
1224 duration: value,
1225 });
1226 }
1227 if let Some(value) = self.tls_handshake {
1228 timelines.push(Timeline {
1229 name: s.tls_handshake.to_string(),
1230 duration: value,
1231 });
1232 }
1233 if let Some(value) = self.quic_connect {
1234 timelines.push(Timeline {
1235 name: s.quic_connect.to_string(),
1236 duration: value,
1237 });
1238 }
1239 if let Some(value) = self.request_send {
1240 timelines.push(Timeline {
1241 name: s.request_send.to_string(),
1242 duration: value,
1243 });
1244 }
1245 if let Some(value) = self.server_processing {
1246 timelines.push(Timeline {
1247 name: s.server_processing.to_string(),
1248 duration: value,
1249 });
1250 }
1251 if let Some(value) = self.content_transfer {
1252 timelines.push(Timeline {
1253 name: s.content_transfer.to_string(),
1254 duration: value,
1255 });
1256 }
1257
1258 if !timelines.is_empty() {
1259 write!(f, " ")?;
1260 for (i, timeline) in timelines.iter().enumerate() {
1261 write!(
1262 f,
1263 "{}",
1264 timeline.name.unicode_pad(width, Alignment::Center, true)
1265 )?;
1266 if i < timelines.len() - 1 {
1267 write!(f, " ")?;
1268 }
1269 }
1270 writeln!(f)?;
1271
1272 write!(f, "[")?;
1273 for (i, timeline) in timelines.iter().enumerate() {
1274 write!(
1275 f,
1276 "{}",
1277 LightCyan.paint(
1278 format_duration(timeline.duration)
1279 .unicode_pad(width, Alignment::Center, true)
1280 .to_string(),
1281 )
1282 )?;
1283 if i < timelines.len() - 1 {
1284 write!(f, "|")?;
1285 }
1286 }
1287 writeln!(f, "]")?;
1288 }
1289
1290 write!(f, " ")?;
1291 for _ in 0..timelines.len() {
1292 write!(f, "{}", " ".repeat(width))?;
1293 write!(f, "|")?;
1294 }
1295 writeln!(f)?;
1296 write!(f, "{}", " ".repeat(width * timelines.len()))?;
1297 write!(
1298 f,
1299 "{}:{}\n\n",
1300 s.total,
1301 LightCyan.paint(format_duration(self.total.unwrap_or_default()))
1302 )?;
1303 }
1304
1305 if let Some(body) = &self.body {
1306 let status = self.status.unwrap_or(StatusCode::OK).as_u16();
1307 let mut body = std::str::from_utf8(body.as_ref())
1308 .unwrap_or_default()
1309 .to_string();
1310 if let Some(filter) = &self.jq_filter {
1311 if let Some(filtered) = apply_jq_filter(&body, filter) {
1312 body = filtered;
1313 }
1314 } else if self.pretty && is_json {
1315 if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
1316 if let Ok(value) = serde_json::to_string_pretty(&json_body) {
1317 body = value;
1318 }
1319 }
1320 }
1321 if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
1322 let text = format!(
1323 "{}: {}",
1324 s.body_size,
1325 ByteSize(self.body_size.unwrap_or(0) as u64)
1326 );
1327 writeln!(f, "{}", LightCyan.paint(text))?;
1328 self.fmt_throughput(f)?;
1329 writeln!(f)?;
1330 if status >= 400 {
1331 writeln!(f, "{}", LightRed.paint(body))?;
1332 } else {
1333 writeln!(f, "{body}")?;
1334 }
1335 } else {
1336 let mut save_tips = "".to_string();
1337 if let Ok(mut file) = NamedTempFile::new() {
1338 if let Ok(()) = file.write_all(body.as_bytes()) {
1339 save_tips = format!("{}: {}", s.saved_to, file.path().display());
1340 let _ = file.keep();
1341 }
1342 }
1343 let text = format!(
1344 "{} {}",
1345 s.body_discarded,
1346 ByteSize(self.body_size.unwrap_or(0) as u64)
1347 );
1348 writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
1349 self.fmt_throughput(f)?;
1350 }
1351 }
1352
1353 Ok(())
1354 }
1355}
1356
1357impl HttpStat {
1358 fn fmt_throughput(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1363 let Some(wire) = self.wire_body_size else {
1364 return Ok(());
1365 };
1366 if wire < THROUGHPUT_DISPLAY_THRESHOLD {
1367 return Ok(());
1368 }
1369 let Some(total) = self.throughput_bps() else {
1370 return Ok(());
1371 };
1372 let s = self.lang.strings();
1373 writeln!(
1374 f,
1375 "{} {}",
1376 LightCyan.paint(s.throughput),
1377 LightCyan.paint(format_throughput(total)),
1378 )?;
1379 if self.verbose {
1380 if let (Some(first), Some(tail)) = (
1381 self.first_chunk_throughput_bps(),
1382 self.tail_throughput_bps(),
1383 ) {
1384 writeln!(
1385 f,
1386 " {} {} {} {} {}",
1387 LightCyan.paint(s.throughput_first_100k),
1388 LightCyan.paint(format_throughput(first)),
1389 LightCyan.paint("·"),
1390 LightCyan.paint(s.throughput_then),
1391 LightCyan.paint(format_throughput(tail)),
1392 )?;
1393 }
1394 }
1395 Ok(())
1396 }
1397}
1398
1399pub struct BenchmarkSummary {
1400 pub stats: Vec<HttpStat>,
1401 pub lang: Lang,
1402}
1403
1404impl BenchmarkSummary {
1405 fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
1406 let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
1407 v.sort();
1408 v
1409 }
1410
1411 fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
1412 if sorted.is_empty() {
1413 return None;
1414 }
1415 let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
1416 Some(sorted[idx.min(sorted.len() - 1)])
1417 }
1418}
1419
1420impl fmt::Display for BenchmarkSummary {
1421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422 let total = self.stats.len();
1423 if total == 0 {
1424 return Ok(());
1425 }
1426
1427 let strs = self.lang.strings();
1428 let has_dns_connect = self.stats.iter().any(|s| s.dns_connect.is_some());
1431 let dns_cols: Vec<(&str, Vec<Duration>)> = if has_dns_connect {
1432 vec![
1433 (strs.dns_connect, self.collect_sorted(|s| s.dns_connect)),
1434 (strs.dns_query, self.collect_sorted(|s| s.dns_query())),
1435 ]
1436 } else {
1437 vec![(strs.dns_lookup, self.collect_sorted(|s| s.dns_lookup))]
1438 };
1439 let phases: Vec<(&str, Vec<Duration>)> = dns_cols
1440 .into_iter()
1441 .chain([
1442 (strs.tcp_connect, self.collect_sorted(|s| s.tcp_connect)),
1443 (strs.tls_handshake, self.collect_sorted(|s| s.tls_handshake)),
1444 (strs.quic_connect, self.collect_sorted(|s| s.quic_connect)),
1445 (strs.request_send, self.collect_sorted(|s| s.request_send)),
1446 (
1447 strs.server_processing_short,
1448 self.collect_sorted(|s| s.server_processing),
1449 ),
1450 (
1451 strs.content_transfer_short,
1452 self.collect_sorted(|s| s.content_transfer),
1453 ),
1454 (strs.total, self.collect_sorted(|s| s.total)),
1455 ])
1456 .filter(|(_, v)| !v.is_empty())
1457 .collect();
1458
1459 if phases.is_empty() {
1460 return Ok(());
1461 }
1462
1463 writeln!(f)?;
1464 writeln!(
1465 f,
1466 "{}",
1467 LightGreen.paint(format!(
1468 "{} ({total} {}) ---",
1469 strs.benchmark_results_prefix, strs.benchmark_results_requests,
1470 ))
1471 )?;
1472 writeln!(f)?;
1473
1474 let col_w = 18;
1475 let label_w = 6;
1476
1477 write!(f, "{:>label_w$} ", "")?;
1479 for (name, _) in &phases {
1480 write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
1481 }
1482 writeln!(f)?;
1483
1484 let rows: [(&str, f64); 6] = [
1487 (strs.min, 0.0),
1488 (strs.max, f64::INFINITY),
1489 (strs.avg, f64::NAN),
1490 ("p50", 0.5),
1491 ("p95", 0.95),
1492 ("p99", 0.99),
1493 ];
1494
1495 for (label, p) in &rows {
1496 write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
1497 for (_, sorted) in &phases {
1498 let val = if p.is_nan() {
1499 if sorted.is_empty() {
1501 None
1502 } else {
1503 let sum: Duration = sorted.iter().sum();
1504 Some(sum / sorted.len() as u32)
1505 }
1506 } else if *p == 0.0 {
1507 sorted.first().copied()
1508 } else if p.is_infinite() {
1509 sorted.last().copied()
1510 } else {
1511 Self::percentile(sorted, *p)
1512 };
1513 let text = match val {
1514 Some(d) => format_duration(d),
1515 None => "-".to_string(),
1516 };
1517 write!(
1518 f,
1519 "{}",
1520 LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
1521 )?;
1522 }
1523 writeln!(f)?;
1524 }
1525
1526 writeln!(f)?;
1527 let success = self.stats.iter().filter(|s| s.is_success()).count();
1528 let pct = (success as f64 / total as f64) * 100.0;
1529 let success_text = format!("{}: {success}/{total} ({pct:.1}%)", strs.success);
1530 if success == total {
1531 writeln!(f, " {}", LightGreen.paint(success_text))?;
1532 } else {
1533 writeln!(f, " {}", LightRed.paint(success_text))?;
1534 }
1535
1536 Ok(())
1537 }
1538}