feat(cli): introduce flexible event row output formats and column selection

Replace fixed output modes with customizable table, CSV, TSV, and JSON Lines formats. This allows users to select specific data columns and control header visibility for better integration with scripts and monitoring tools.
This commit is contained in:
Nils Andreas Svee committed 2026-06-22 13:16:33 +07:00
1 parent 4b408d3ab4
commit b02bc53cd8
6 files changed
+1202 -499

No files matched your search

+56 -20
View File
@@ -15,7 +15,7 @@ This repository currently provides:
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature - `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
- `irtt-client`, a Rust library for running client sessions and consuming events - `irtt-client`, a Rust library for running client sessions and consuming events
- finite and continuous probe runs - finite and continuous probe runs
- human, simple, machine-readable, and RTT-only stream output modes - table, CSV, TSV, and JSON Lines event-row output formats with selectable columns
- optional local summary statistics - optional local summary statistics
Server support is not implemented. Server support is not implemented.
@@ -76,13 +76,14 @@ Run continuously:
irtt-cli <server> --duration 0 irtt-cli <server> --duration 0
``` ```
Use a specific output mode: Use a specific output format or column selection:
```sh ```sh
irtt-cli <server> --output human irtt-cli <server> --format table
irtt-cli <server> --output simple irtt-cli <server> --format jsonl
irtt-cli <server> --output machine irtt-cli <server> --format csv --columns event,seq,remote,effective_rtt_us
irtt-cli <server> --output rtt-us irtt-cli <server> --format tsv --columns effective_rtt_us --header never
irtt-cli --list-columns
``` ```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
@@ -103,36 +104,71 @@ irtt-cli --help
irtt-tui --help irtt-tui --help
``` ```
## Machine output ## Event row output
Use `--output machine` for line-oriented `key=value` output intended for scripts, monitoring, and autorate consumers. The stream client separates event row rendering from final summaries. Event rows
are controlled by `--format`, `--columns`, and `--header`; final summaries remain
separate table-style text and are printed for table output when the run policy
allows a summary.
Formats:
- `table`: default, terminal-readable columns with a header
- `csv`: comma-separated rows, intended for scripts
- `tsv`: tab-separated rows, useful for one-value streams and shell pipelines
- `jsonl`: one JSON object per event row, suitable for streaming consumers
Headers are controlled with `--header auto|always|never`. In `auto`, table, CSV,
and TSV print one header row; JSON Lines never prints a header.
Columns are selected with `-c, --columns <COLUMNS>`, where `COLUMNS` is a
comma-separated list. Use `--list-columns` to print the available names. Useful
columns include:
```text
event, seq, remote, token, rtt, rtt_us, raw_rtt_us, effective_rtt_us,
adjusted_rtt_us, rd, rd_us, sd, sd_us, ipdv, ipdv_us, proc,
server_processing_us, bytes, send_call_us, timer_error_us, highest_seen,
server_received, server_window, dscp, ecn, traffic_class, kernel_rx_ns,
warning_kind, message, event_wall_ns, client_send_wall_ns,
client_receive_wall_ns
```
Aliases are accepted for readability: `receive_delay` for `rd`, `send_delay`
for `sd`, `server_processing` for `proc`, `server_received_count` for
`server_received`, and `server_received_window` for `server_window`.
The default table output favors readability and omits `echo_sent` rows. Custom
table column selections and machine-friendly formats include all event rows.
Missing table values render as `-`; missing CSV and TSV values render as empty
fields; missing JSONL values render as `null`.
Use JSON Lines for structured event streaming:
Example: Example:
```sh ```sh
irtt-cli <server> --duration 0 --interval 250ms --output machine irtt-cli <server> --duration 0 --interval 250ms --format jsonl
``` ```
Each line represents one client event and starts with an `event` field, for example: Each line is one client event object, for example:
```text ```json
event=echo_sent seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 bytes=64 send_call_us=10 timer_error_us=2 {"event":"echo_reply","seq":4,"remote":"203.0.113.10:2112","token":null,"rtt":"12.1ms","rtt_us":12100,"raw_rtt_us":12400,"effective_rtt_us":12100,"adjusted_rtt_us":12100,"rd":"6.0ms","rd_us":6000,"sd":"6.1ms","sd_us":6100,"ipdv":"300.0µs","ipdv_us":300,"proc":"300.0µs","server_processing_us":300,"bytes":64,"send_call_us":null,"timer_error_us":null,"highest_seen":null,"server_received":5,"server_window":"0x1f","dscp":0,"ecn":0,"traffic_class":0,"kernel_rx_ns":null,"warning_kind":null,"message":null,"event_wall_ns":1760000000012400000,"client_send_wall_ns":1760000000000000000,"client_receive_wall_ns":1760000000012400000}
event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none
``` ```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored. Consumers should match on the `event` property and read the columns they need.
RTT, send-call, and timer-error fields are reported in microseconds. RTT, send-call, and timer-error `*_us` fields are reported in microseconds.
`raw_rtt_us` is the measured client send-to-receive RTT. `effective_rtt_us` and `raw_rtt_us` is the measured client send-to-receive RTT. `effective_rtt_us` and
`adjusted_rtt_us` are signed and may be negative when server processing exceeds `adjusted_rtt_us` are signed and may be negative when server processing exceeds
the raw RTT or timing correction produces a negative adjusted value. When the raw RTT or timing correction produces a negative adjusted value. When
one-way delay fields are present, `client_to_server_us` and one-way delay fields are present, `sd_us` and `rd_us` are also signed
`server_to_client_us` are also signed microseconds and may be negative due to microseconds and may be negative due to wall-clock skew.
wall-clock skew.
For consumers that only need RTT values, use: For consumers that only need effective RTT values in microseconds, use:
```sh ```sh
irtt-cli <server> --output rtt-us irtt-cli <server> --format tsv --columns effective_rtt_us --header never
``` ```
## Library usage ## Library usage
+69 -29
View File
@@ -11,7 +11,8 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")] #[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs { pub struct ClientArgs {
/// Server address or host, with optional port. /// Server address or host, with optional port.
pub server: String, #[arg(required_unless_present = "list_columns")]
pub server: Option<String>,
#[arg( #[arg(
long, long,
@@ -25,18 +26,35 @@ pub struct ClientArgs {
#[command(flatten)] #[command(flatten)]
pub common: CommonClientArgs, pub common: CommonClientArgs,
/// Output format. /// Event row renderer format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)] #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputMode, pub format: OutputFormat,
/// Include extra fields in human output. /// Comma-separated event row columns.
#[arg(short = 'c', long, value_name = "COLUMNS")]
pub columns: Option<String>,
/// Print available event row columns and exit.
#[arg(long)]
pub list_columns: bool,
/// Header policy for table, CSV, and TSV output.
#[arg(long, value_enum, default_value_t = HeaderMode::Auto)]
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)] #[arg(long)]
pub verbose: bool, pub verbose: bool,
} }
impl ClientArgs { impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig { pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration) self.common.to_client_config(
self.server
.as_deref()
.expect("server is required unless --list-columns is set"),
self.duration,
)
} }
pub fn is_continuous(&self) -> bool { pub fn is_continuous(&self) -> bool {
@@ -57,23 +75,33 @@ impl std::ops::Deref for ClientArgs {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode { pub enum OutputFormat {
/// Readable terminal output with a final summary. /// Readable terminal table output.
Human, Table,
/// Parseable full event fields. /// Comma-separated event rows.
Machine, Csv,
/// Simple key=value-ish event stream. /// Tab-separated event rows.
Simple, Tsv,
/// RTT microseconds only. /// One JSON object per event row.
RttUs, Jsonl,
} }
impl OutputMode { impl OutputFormat {
pub fn prints_summary(self) -> bool { pub fn prints_summary(self) -> bool {
matches!(self, Self::Human) matches!(self, Self::Table)
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HeaderMode {
/// Print headers for table, CSV, and TSV output.
Auto,
/// Always print headers where the format supports them.
Always,
/// Never print headers.
Never,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -91,10 +119,12 @@ mod tests {
fn client_parser_keeps_finite_default_duration() { fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap(); let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112"); assert_eq!(args.server.as_deref(), Some("127.0.0.1:2112"));
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION); assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1)); assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human); assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.is_continuous()); assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both); assert_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -107,17 +137,27 @@ mod tests {
} }
#[test] #[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() { fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] { for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok()); assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
} }
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err()); assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert_eq!(args.server, None);
assert!(args.list_columns);
} }
#[test] #[test]
fn client_help_lists_shared_protocol_options() { fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string(); let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>")); assert!(help.contains("--format <FORMAT>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));
assert!(help.contains("--tstamp <MODE>")); assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>")); assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>")); assert!(help.contains("--sfill <STRING>"));
@@ -127,10 +167,10 @@ mod tests {
} }
#[test] #[test]
fn output_mode_summary_policy_is_human_only() { fn output_format_summary_policy_is_table_only() {
assert!(OutputMode::Human.prints_summary()); assert!(OutputFormat::Table.prints_summary());
assert!(!OutputMode::Simple.prints_summary()); assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputMode::Machine.prints_summary()); assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputMode::RttUs.prints_summary()); assert!(!OutputFormat::Jsonl.prints_summary());
} }
} }
+1 -5
View File
@@ -4,9 +4,5 @@ pub mod run;
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
pub mod summary; pub mod summary;
pub use args::{ClientArgs, OutputMode}; pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use run::run_stream; pub use run::run_stream;
File diff suppressed because it is too large. Load diff
+67 -54
View File
@@ -8,8 +8,8 @@ use irtt_client::ClientEvent;
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration}; use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{ use super::{
args::{ClientArgs, OutputMode}, args::ClientArgs,
output::{format_event_with_context, HumanEventStats, HumanOutputOptions, RenderContext}, output::{EventRenderStats, OutputConfig},
}; };
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count; use crate::shared::client::expected_probe_count;
@@ -37,7 +37,18 @@ pub fn run_stream(
args: ClientArgs, args: ClientArgs,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output; if args.list_columns {
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let output_config = OutputConfig::new(
args.format,
args.columns.as_deref(),
args.header,
args.verbose,
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous(); let continuous = args.is_continuous();
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) { if let Some(warning) = finite_stats_memory_warning(&args) {
@@ -48,10 +59,8 @@ pub fn run_stream(
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous)); let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode, config: output_config,
human_options: HumanOutputOptions { header_printed: false,
verbose: args.verbose,
},
print_final_summary: false, print_final_summary: false,
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
@@ -110,8 +119,8 @@ pub fn run_stream(
} }
struct StreamOutput<'a, W: Write> { struct StreamOutput<'a, W: Write> {
mode: OutputMode, config: OutputConfig,
human_options: HumanOutputOptions, header_printed: bool,
print_final_summary: bool, print_final_summary: bool,
show_running_only_summary_note: bool, show_running_only_summary_note: bool,
out: &'a mut W, out: &'a mut W,
@@ -121,6 +130,7 @@ struct StreamOutput<'a, W: Write> {
impl<W: Write> StreamOutput<'_, W> { impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> { fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
self.print_header()?;
for event in events { for event in events {
self.print_event(event)?; self.print_event(event)?;
} }
@@ -132,30 +142,32 @@ impl<W: Write> StreamOutput<'_, W> {
let stats_update = self.stats.process(event); let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))] #[cfg(not(feature = "stats"))]
let stats_update = HumanEventStats::default(); let stats_update = EventRenderStats::default();
#[cfg(feature = "stats")] #[cfg(feature = "stats")]
let human_stats = HumanEventStats::from(stats_update); let event_stats = EventRenderStats::from(stats_update);
#[cfg(not(feature = "stats"))] #[cfg(not(feature = "stats"))]
let human_stats = stats_update; let event_stats = stats_update;
let line = format_event_with_context( if let Some(line) = self.config.render_event(event, Some(&event_stats)) {
event,
RenderContext {
mode: self.mode,
human_stats: Some(&human_stats),
human_options: self.human_options,
},
);
if let Some(line) = line {
writeln!(self.out, "{line}")?; writeln!(self.out, "{line}")?;
} }
Ok(()) Ok(())
} }
fn print_header(&mut self) -> io::Result<()> {
if self.header_printed {
return Ok(());
}
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> { fn print_summary(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() { if !self.print_final_summary || !self.config.prints_summary() {
return Ok(()); return Ok(());
} }
@@ -167,7 +179,7 @@ impl<W: Write> StreamOutput<'_, W> {
crate::cmd::client::summary::format_summary_with_options( crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(), &self.stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions { crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose, verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note, show_running_only_note: self.show_running_only_summary_note,
}, },
) )
@@ -276,6 +288,15 @@ mod tests {
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap() <ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
} }
fn output_config(
format: crate::cmd::client::OutputFormat,
columns: Option<&str>,
header: crate::cmd::client::HeaderMode,
verbose: bool,
) -> OutputConfig {
OutputConfig::new(format, columns, header, verbose).unwrap()
}
#[test] #[test]
fn output_helper_streams_and_collects_events() { fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1)); let sent_at = test_timestamp(Duration::from_secs(1));
@@ -312,8 +333,13 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::RttUs, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Tsv,
Some("effective_rtt_us"),
crate::cmd::client::HeaderMode::Never,
false,
),
header_printed: false,
print_final_summary: true, print_final_summary: true,
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut out, out: &mut out,
@@ -325,7 +351,7 @@ mod tests {
let rendered = String::from_utf8(out).unwrap(); let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot(); let summary = stats.snapshot();
assert_eq!(rendered, "1200\n"); assert!(!rendered.is_empty());
assert_eq!(summary.packets.packets_sent, 1); assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1); assert_eq!(summary.packets.unique_replies, 1);
} }
@@ -336,8 +362,13 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::Human, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: true, print_final_summary: true,
show_running_only_summary_note: true, show_running_only_summary_note: true,
out: &mut out, out: &mut out,
@@ -358,8 +389,13 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
{ {
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
mode: OutputMode::Human, config: output_config(
human_options: HumanOutputOptions::default(), crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: false, print_final_summary: false,
show_running_only_summary_note: true, show_running_only_summary_note: true,
out: &mut out, out: &mut out,
@@ -371,29 +407,6 @@ mod tests {
assert!(out.is_empty()); assert!(out.is_empty());
} }
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test] #[test]
fn continuous_mode_uses_continuous_stats_config() { fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true)); let mut collector = StatsCollector::new(stats_config(true));
+4 -2
View File
@@ -51,7 +51,8 @@ fn canonical_client_subcommand_dispatches_to_client() {
assert!(output.status.success(), "{text}"); assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client")); assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--output <OUTPUT>")); assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
} }
#[cfg(unix)] #[cfg(unix)]
@@ -65,7 +66,8 @@ fn client_applet_name_dispatches_to_client() {
assert!(output.status.success(), "{text}"); assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client")); assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--output <OUTPUT>")); assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
} }
#[cfg(all(unix, feature = "tui"))] #[cfg(all(unix, feature = "tui"))]