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-client`, a Rust library for running client sessions and consuming events
- 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
Server support is not implemented.
@@ -76,13 +76,14 @@ Run continuously:
irtt-cli <server> --duration 0
```
Use a specific output mode:
Use a specific output format or column selection:
```sh
irtt-cli <server> --output human
irtt-cli <server> --output simple
irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us
irtt-cli <server> --format table
irtt-cli <server> --format jsonl
irtt-cli <server> --format csv --columns event,seq,remote,effective_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
@@ -103,36 +104,71 @@ irtt-cli --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:
```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
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 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
```json
{"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}
```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
RTT, send-call, and timer-error fields are reported in microseconds.
Consumers should match on the `event` property and read the columns they need.
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
`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
one-way delay fields are present, `client_to_server_us` and
`server_to_client_us` are also signed microseconds and may be negative due to
wall-clock skew.
one-way delay fields are present, `sd_us` and `rd_us` are also signed
microseconds and may be negative due to wall-clock skew.
For consumers that only need RTT values, use:
For consumers that only need effective RTT values in microseconds, use:
```sh
irtt-cli <server> --output rtt-us
irtt-cli <server> --format tsv --columns effective_rtt_us --header never
```
## 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")]
pub struct ClientArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(required_unless_present = "list_columns")]
pub server: Option<String>,
#[arg(
long,
@@ -25,18 +26,35 @@ pub struct ClientArgs {
#[command(flatten)]
pub common: CommonClientArgs,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
pub output: OutputMode,
/// Event row renderer format.
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
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)]
pub verbose: bool,
}
impl ClientArgs {
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 {
@@ -57,23 +75,33 @@ impl std::ops::Deref for ClientArgs {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
/// Readable terminal output with a final summary.
Human,
/// Parseable full event fields.
Machine,
/// Simple key=value-ish event stream.
Simple,
/// RTT microseconds only.
RttUs,
pub enum OutputFormat {
/// Readable terminal table output.
Table,
/// Comma-separated event rows.
Csv,
/// Tab-separated event rows.
Tsv,
/// One JSON object per event row.
Jsonl,
}
impl OutputMode {
impl OutputFormat {
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)]
mod tests {
use super::*;
@@ -91,10 +119,12 @@ mod tests {
fn client_parser_keeps_finite_default_duration() {
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.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_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -107,17 +137,27 @@ mod tests {
}
#[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for format in ["table", "csv", "tsv", "jsonl"] {
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]
fn client_help_lists_shared_protocol_options() {
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("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
@@ -127,10 +167,10 @@ mod tests {
}
#[test]
fn output_mode_summary_policy_is_human_only() {
assert!(OutputMode::Human.prints_summary());
assert!(!OutputMode::Simple.prints_summary());
assert!(!OutputMode::Machine.prints_summary());
assert!(!OutputMode::RttUs.prints_summary());
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputFormat::Jsonl.prints_summary());
}
}
+1 -5
View File
@@ -4,9 +4,5 @@ pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, OutputMode};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use args::{ClientArgs, HeaderMode, OutputFormat};
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 super::{
args::{ClientArgs, OutputMode},
output::{format_event_with_context, HumanEventStats, HumanOutputOptions, RenderContext},
args::ClientArgs,
output::{EventRenderStats, OutputConfig},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
@@ -37,7 +37,18 @@ pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> 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();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
@@ -48,10 +59,8 @@ pub fn run_stream(
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
@@ -110,8 +119,8 @@ pub fn run_stream(
}
struct StreamOutput<'a, W: Write> {
mode: OutputMode,
human_options: HumanOutputOptions,
config: OutputConfig,
header_printed: bool,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
@@ -121,6 +130,7 @@ struct StreamOutput<'a, W: Write> {
impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
self.print_header()?;
for event in events {
self.print_event(event)?;
}
@@ -132,30 +142,32 @@ impl<W: Write> StreamOutput<'_, W> {
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = HumanEventStats::default();
let stats_update = EventRenderStats::default();
#[cfg(feature = "stats")]
let human_stats = HumanEventStats::from(stats_update);
let event_stats = EventRenderStats::from(stats_update);
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
let event_stats = stats_update;
let line = format_event_with_context(
event,
RenderContext {
mode: self.mode,
human_stats: Some(&human_stats),
human_options: self.human_options,
},
);
if let Some(line) = line {
if let Some(line) = self.config.render_event(event, Some(&event_stats)) {
writeln!(self.out, "{line}")?;
}
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<()> {
if !self.print_final_summary || !self.mode.prints_summary() {
if !self.print_final_summary || !self.config.prints_summary() {
return Ok(());
}
@@ -167,7 +179,7 @@ impl<W: Write> StreamOutput<'_, W> {
crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(),
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,
},
)
@@ -276,6 +288,15 @@ mod tests {
<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]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
@@ -312,8 +333,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Tsv,
Some("effective_rtt_us"),
crate::cmd::client::HeaderMode::Never,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
@@ -325,7 +351,7 @@ mod tests {
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert!(!rendered.is_empty());
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
@@ -336,8 +362,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
@@ -358,8 +389,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
@@ -371,29 +407,6 @@ mod tests {
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]
fn continuous_mode_uses_continuous_stats_config() {
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!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--output <OUTPUT>"));
assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(unix)]
@@ -65,7 +66,8 @@ fn client_applet_name_dispatches_to_client() {
assert!(output.status.success(), "{text}");
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"))]