Skip to main content

mockforge_bench/
reporter.rs

1//! Result reporting and formatting
2
3use crate::executor::K6Results;
4use crate::parallel_executor::AggregatedResults;
5use colored::*;
6
7/// Terminal reporter for bench results
8pub struct TerminalReporter;
9
10impl TerminalReporter {
11    /// Print a summary of the bench results.
12    ///
13    /// `cps_mode` is `true` when the bench was invoked with `--cps`. In that
14    /// mode each request opens a fresh TCP/TLS connection, so we print an
15    /// explicit "Connection Rate" line alongside RPS — Srikanth's round-5
16    /// reply on Issue #79: "CPS without RPS Command is Working but Client
17    /// dont report CPS Counts".
18    pub fn print_summary(results: &K6Results, duration_secs: u64) {
19        Self::print_summary_with_mode(results, duration_secs, false);
20    }
21
22    /// Like [`print_summary`] but lets the caller opt into the `--cps` view.
23    ///
24    /// Issue #79 round 6 — the connection-count lines now render unconditionally
25    /// whenever k6 reported `http_req_connecting` samples (i.e. it actually
26    /// opened TCP sockets), so non-`--cps` runs also surface client-side
27    /// connection counts. Without `--cps` k6 reuses sockets, so the count
28    /// equals "distinct connections opened", which is what Srikanth wanted
29    /// alongside RPS.
30    pub fn print_summary_with_mode(results: &K6Results, duration_secs: u64, cps_mode: bool) {
31        Self::print_summary_full(results, duration_secs, cps_mode, None);
32    }
33
34    /// Like [`print_summary_with_mode`] but accepts `num_operations` (the count
35    /// of operations the bench generated from the spec). When supplied, the
36    /// summary surfaces iteration coverage: how many iterations completed and
37    /// what fraction of the spec's operations got exercised end-to-end.
38    ///
39    /// Issue #79 round 10 — Srikanth's 11422-op spec ran for 600s with only
40    /// `--vus 5`; many iterations were cancelled mid-way and the final
41    /// summary didn't reveal which slice of the spec was actually covered.
42    pub fn print_summary_full(
43        results: &K6Results,
44        duration_secs: u64,
45        cps_mode: bool,
46        num_operations: Option<u32>,
47    ) {
48        println!("\n{}", "=".repeat(60).bright_green());
49        println!("{}", "Load Test Complete! ✓".bright_green().bold());
50        println!("{}\n", "=".repeat(60).bright_green());
51
52        println!("{}", "Summary:".bold());
53        println!("  Total Requests:       {}", results.total_requests.to_string().cyan());
54        println!(
55            "  Successful:           {} ({}%)",
56            (results.total_requests - results.failed_requests).to_string().green(),
57            format!("{:.2}", results.success_rate()).green()
58        );
59        println!(
60            "  Failed:               {} ({}%)",
61            results.failed_requests.to_string().red(),
62            format!("{:.2}", results.error_rate()).red()
63        );
64
65        println!("\n{}", "Response Times:".bold());
66        println!("  Min:                  {}ms", format!("{:.2}", results.min_duration_ms).cyan());
67        println!("  Avg:                  {}ms", format!("{:.2}", results.avg_duration_ms).cyan());
68        println!("  Med:                  {}ms", format!("{:.2}", results.med_duration_ms).cyan());
69        println!("  p90:                  {}ms", format!("{:.2}", results.p90_duration_ms).cyan());
70        println!("  p95:                  {}ms", format!("{:.2}", results.p95_duration_ms).cyan());
71        println!("  p99:                  {}ms", format!("{:.2}", results.p99_duration_ms).cyan());
72        println!("  Max:                  {}ms", format!("{:.2}", results.max_duration_ms).cyan());
73
74        println!("\n{}", "Throughput:".bold());
75        if results.rps > 0.0 {
76            println!("  RPS:                  {} req/s", format!("{:.1}", results.rps).cyan());
77        } else {
78            println!(
79                "  RPS:                  {} req/s",
80                format!("{:.1}", results.total_requests as f64 / duration_secs as f64).cyan()
81            );
82        }
83        if results.vus_max > 0 {
84            println!("  Max VUs:              {}", results.vus_max.to_string().cyan());
85        }
86
87        // Issue #79 (round 5) — Connections-per-second report. When the user
88        // passed `--cps`, k6 ran with `noConnectionReuse: true` so each
89        // request opened a new TCP/TLS connection. CPS therefore equals the
90        // request rate; show it explicitly, plus connect/handshake timing.
91        if cps_mode {
92            let cps = if results.rps > 0.0 {
93                results.rps
94            } else {
95                results.total_requests as f64 / duration_secs.max(1) as f64
96            };
97            println!("  CPS:                  {} conn/s (--cps)", format!("{:.1}", cps).cyan());
98            println!("  Total Connections:    {}", results.total_requests.to_string().cyan());
99        }
100
101        // Issue #79 round 6 — Always surface client-side connection counts
102        // when k6 actually opened sockets. Helpful even without `--cps`
103        // because it lets you compare "k6 opened N connections" against
104        // the server's `connections_total_opened` and detect whether
105        // your proxy is keeping the upstream pool warm.
106        //
107        // Round 6 follow-up: `tcp_connect_samples` is now sourced from the
108        // template's `mockforge_connections_opened` Counter (incremented when
109        // `res.timings.connecting > 0`). That gives an accurate count for
110        // both `--cps` (≈ total requests) and the pooled-reuse case (≈ vus_max)
111        // — Srikanth's "Open/Closed Connection Counter not showing for RPS"
112        // report on Issue #79.
113        if results.tcp_connect_samples > 0 && !cps_mode {
114            // Surface the count in non-CPS mode too — Srikanth's "open
115            // connection on the client" ask.
116            println!(
117                "  Connections opened:   {} ({} conn/s avg)",
118                results.tcp_connect_samples.to_string().cyan(),
119                format!("{:.1}", results.tcp_connect_samples as f64 / duration_secs.max(1) as f64)
120                    .cyan(),
121            );
122
123            // Issue #79 round 8 — Srikanth saw 7425 connections opened with
124            // --vus 5, expecting ~5 (one per VU under pooled reuse). When
125            // `tcp_connect_samples` is much larger than `vus_max`, the target
126            // is closing the socket between requests (proxy upstream pool
127            // disabled, server `Connection: close`, etc). Without --cps,
128            // ratios > 5× indicate connection reuse isn't happening.
129            if results.vus_max > 0 {
130                let reuse_ratio = results.tcp_connect_samples as f64 / results.vus_max as f64;
131                if reuse_ratio > 5.0 {
132                    println!(
133                        "  {}: {:.0}× more sockets opened than concurrent VUs — \
134                         the target is closing connections (proxy pool disabled, \
135                         `Connection: close`, or short upstream idle timeout).",
136                        "Connection reuse NOT detected".yellow().bold(),
137                        reuse_ratio,
138                    );
139                }
140            }
141        }
142        // Print TCP/TLS timing whenever the avg is non-zero. Don't gate on
143        // samples count — k6's Trend metric exposes avg/max in summary.json
144        // but not count, so the count check was always false even when k6
145        // had real samples. Issue #79 round 6 follow-up.
146        if results.tcp_connect_avg_ms > 0.0 || results.tcp_connect_max_ms > 0.0 {
147            println!(
148                "  TCP connect:          avg {:.2}ms, max {:.2}ms",
149                results.tcp_connect_avg_ms, results.tcp_connect_max_ms,
150            );
151        }
152        if results.tls_handshake_avg_ms > 0.0 || results.tls_handshake_max_ms > 0.0 {
153            println!(
154                "  TLS handshake:        avg {:.2}ms, max {:.2}ms",
155                results.tls_handshake_avg_ms, results.tls_handshake_max_ms,
156            );
157        }
158        // Peak concurrent VUs — the upper bound on simultaneously-open
159        // connections from the client. For HTTP/1.1 each VU holds at
160        // most one socket; for HTTP/2 with multiplexing this is the
161        // bound on streams, not sockets. Surface it as an open-connection
162        // ceiling so users can sanity-check against the server's
163        // `connections_open` gauge.
164        if results.vus_max > 0
165            && (cps_mode || results.tcp_connect_samples > 0 || results.tcp_connect_avg_ms > 0.0)
166        {
167            println!(
168                "  Peak concurrent VUs:  {} (max open conns from client side)",
169                results.vus_max.to_string().cyan(),
170            );
171        }
172
173        // Issue #79 round 10 — iteration coverage. When --rps is supplied and
174        // the spec has many operations per iteration, k6 may cancel iterations
175        // mid-walk if the duration ends before a full pass completes. Surface
176        // "iterations completed" alongside operation count so users see what
177        // fraction of the spec was actually exercised.
178        if results.iterations_completed > 0 {
179            if let Some(num_ops) = num_operations {
180                let expected_reqs_per_iter = num_ops as u64;
181                let full_iter_reqs =
182                    results.iterations_completed.saturating_mul(expected_reqs_per_iter);
183                let partial_iter_reqs = results.total_requests.saturating_sub(full_iter_reqs);
184                println!(
185                    "  Iterations:           {} complete × {} ops = {} ops fully exercised",
186                    results.iterations_completed.to_string().cyan(),
187                    num_ops.to_string().cyan(),
188                    full_iter_reqs.to_string().cyan(),
189                );
190                if partial_iter_reqs > 0 && num_ops > 1 {
191                    println!(
192                        "                        {} extra request(s) from a partially-completed \
193                         iteration — duration ended mid-walk; not every op was hit on the last pass.",
194                        partial_iter_reqs.to_string().yellow(),
195                    );
196                }
197            } else {
198                println!(
199                    "  Iterations:           {} complete",
200                    results.iterations_completed.to_string().cyan(),
201                );
202            }
203        }
204
205        // Issue #79 — server-injected chaos signals (latency / jitter / faults)
206        // observed from MockForge response headers. Surfaces the slice of
207        // total wire time that came from the chaos middleware vs the system
208        // under test.
209        if results.server_injected_latency_samples > 0
210            || results.server_injected_jitter_samples > 0
211            || results.server_reported_faults > 0
212        {
213            println!("\n{}", "Server-Injected (chaos):".bold());
214            if results.server_injected_latency_samples > 0 {
215                println!(
216                    "  Latency samples:      {} (avg {:.2}ms, max {:.2}ms)",
217                    results.server_injected_latency_samples.to_string().cyan(),
218                    results.server_injected_latency_avg_ms,
219                    results.server_injected_latency_max_ms,
220                );
221            }
222            if results.server_injected_jitter_samples > 0 {
223                println!(
224                    "  Jitter samples:       {} (avg {:.2}ms)",
225                    results.server_injected_jitter_samples.to_string().cyan(),
226                    results.server_injected_jitter_avg_ms,
227                );
228            }
229            if results.server_reported_faults > 0 {
230                println!(
231                    "  Fault-marked resps:   {}",
232                    results.server_reported_faults.to_string().cyan(),
233                );
234            }
235        }
236
237        println!("\n{}", "=".repeat(60).bright_green());
238    }
239
240    /// Print header information
241    pub fn print_header(
242        spec_file: &str,
243        target: &str,
244        num_operations: usize,
245        scenario: &str,
246        duration_secs: u64,
247    ) {
248        println!("\n{}\n", "MockForge Bench - Load Testing Mode".bright_green().bold());
249        println!("{}", "─".repeat(60).bright_black());
250
251        println!("{}: {}", "Specification".bold(), spec_file.cyan());
252        println!("{}: {}", "Target".bold(), target.cyan());
253        // Round 18.2 — caller passes 0 before the spec is parsed
254        // (the operation count isn't known yet). Showing "Operations:
255        // 0 endpoints" was actively misleading on big specs that
256        // analysed 1000+ ops a moment later. Show a clear "TBD"
257        // marker until the parse completes; the per-spec analysis
258        // line below it carries the real count.
259        if num_operations == 0 {
260            println!("{}: {}", "Operations".bold(), "(analyzing spec…)".bright_black());
261        } else {
262            println!("{}: {} endpoints", "Operations".bold(), num_operations.to_string().cyan());
263        }
264        println!("{}: {}", "Scenario".bold(), scenario.cyan());
265        println!("{}: {}s", "Duration".bold(), duration_secs.to_string().cyan());
266
267        println!("{}\n", "─".repeat(60).bright_black());
268    }
269
270    /// Print progress message
271    pub fn print_progress(message: &str) {
272        println!("{} {}", "→".bright_green().bold(), message);
273    }
274
275    /// Print error message
276    pub fn print_error(message: &str) {
277        eprintln!("{} {}", "✗".bright_red().bold(), message.red());
278    }
279
280    /// Print success message
281    pub fn print_success(message: &str) {
282        println!("{} {}", "✓".bright_green().bold(), message.green());
283    }
284
285    /// Print warning message
286    pub fn print_warning(message: &str) {
287        println!("{} {}", "⚠".bright_yellow().bold(), message.yellow());
288    }
289
290    /// Print multi-target summary
291    pub fn print_multi_target_summary(results: &AggregatedResults) {
292        println!("\n{}", "=".repeat(60).bright_green());
293        println!("{}", "Multi-Target Load Test Complete! ✓".bright_green().bold());
294        println!("{}\n", "=".repeat(60).bright_green());
295
296        println!("{}", "Overall Summary:".bold());
297        println!("  Total Targets:        {}", results.total_targets.to_string().cyan());
298        println!(
299            "  Successful:           {} ({}%)",
300            results.successful_targets.to_string().green(),
301            format!(
302                "{:.1}",
303                (results.successful_targets as f64 / results.total_targets as f64) * 100.0
304            )
305            .green()
306        );
307        println!(
308            "  Failed:               {} ({}%)",
309            results.failed_targets.to_string().red(),
310            format!(
311                "{:.1}",
312                (results.failed_targets as f64 / results.total_targets as f64) * 100.0
313            )
314            .red()
315        );
316
317        println!("\n{}", "Aggregated Metrics:".bold());
318        println!(
319            "  Total Requests:       {}",
320            results.aggregated_metrics.total_requests.to_string().cyan()
321        );
322        println!(
323            "  Failed Requests:      {} ({}%)",
324            results.aggregated_metrics.total_failed_requests.to_string().red(),
325            format!("{:.2}", results.aggregated_metrics.error_rate).red()
326        );
327        println!(
328            "  Total RPS:            {} req/s",
329            format!("{:.1}", results.aggregated_metrics.total_rps).cyan()
330        );
331        println!(
332            "  Avg RPS/target:       {} req/s",
333            format!("{:.1}", results.aggregated_metrics.avg_rps).cyan()
334        );
335        println!(
336            "  Total VUs:            {}",
337            results.aggregated_metrics.total_vus_max.to_string().cyan()
338        );
339        println!(
340            "  Avg Response Time:    {}ms",
341            format!("{:.2}", results.aggregated_metrics.avg_duration_ms).cyan()
342        );
343        println!(
344            "  p95 Response Time:    {}ms",
345            format!("{:.2}", results.aggregated_metrics.p95_duration_ms).cyan()
346        );
347        println!(
348            "  p99 Response Time:    {}ms",
349            format!("{:.2}", results.aggregated_metrics.p99_duration_ms).cyan()
350        );
351
352        // Issue #79 round 12 — multi-target was missing the connection /
353        // iteration counters that single-target runs surface. Aggregate
354        // across targets and print only when k6 actually opened sockets
355        // / completed iterations on at least one target.
356        if results.aggregated_metrics.total_connections_opened > 0 {
357            println!(
358                "  Total Connections:    {}",
359                results.aggregated_metrics.total_connections_opened.to_string().cyan()
360            );
361            if results.aggregated_metrics.total_vus_max > 0 {
362                let reuse_ratio = results.aggregated_metrics.total_connections_opened as f64
363                    / results.aggregated_metrics.total_vus_max as f64;
364                if reuse_ratio > 5.0 {
365                    println!(
366                        "  {}: {:.0}× more sockets opened than concurrent VUs across all targets — \
367                         at least one target is closing connections (proxy pool disabled, \
368                         `Connection: close`, or short upstream idle timeout).",
369                        "Connection reuse NOT detected".yellow().bold(),
370                        reuse_ratio,
371                    );
372                }
373            }
374        }
375        if results.aggregated_metrics.total_iterations_completed > 0 {
376            println!(
377                "  Total Iterations:     {} complete (sum across all targets)",
378                results.aggregated_metrics.total_iterations_completed.to_string().cyan()
379            );
380        }
381
382        // Show per-target summary
383        let print_target = |result: &crate::parallel_executor::TargetResult| {
384            let status = if result.success {
385                "✓".bright_green()
386            } else {
387                "✗".bright_red()
388            };
389            println!("  {} {}", status, result.target_url.cyan());
390            if result.success {
391                println!(
392                    "      Requests: {}  RPS: {}  VUs: {}",
393                    result.results.total_requests.to_string().white(),
394                    format!("{:.1}", result.results.rps).white(),
395                    result.results.vus_max.to_string().white(),
396                );
397                println!(
398                    "      Latency: min={:.1}ms avg={:.1}ms med={:.1}ms p90={:.1}ms p95={:.1}ms p99={:.1}ms max={:.1}ms",
399                    result.results.min_duration_ms,
400                    result.results.avg_duration_ms,
401                    result.results.med_duration_ms,
402                    result.results.p90_duration_ms,
403                    result.results.p95_duration_ms,
404                    result.results.p99_duration_ms,
405                    result.results.max_duration_ms,
406                );
407                // Issue #79 round 12 — per-target connection/iteration counts
408                // (missing previously in multi-target output).
409                if result.results.tcp_connect_samples > 0 || result.results.iterations_completed > 0
410                {
411                    println!(
412                        "      Connections: {}  Iterations: {}",
413                        result.results.tcp_connect_samples.to_string().white(),
414                        result.results.iterations_completed.to_string().white(),
415                    );
416                }
417            }
418            if let Some(error) = &result.error {
419                println!("      Error: {}", error.red());
420            }
421        };
422
423        if results.total_targets <= 20 {
424            println!("\n{}", "Per-Target Results:".bold());
425            for result in &results.target_results {
426                print_target(result);
427            }
428        } else {
429            // Show top 10 and bottom 10
430            println!("\n{}", "Top 10 Targets (by requests):".bold());
431            let mut sorted_results = results.target_results.clone();
432            sorted_results.sort_by_key(|r| r.results.total_requests);
433            sorted_results.reverse();
434
435            for result in sorted_results.iter().take(10) {
436                print_target(result);
437            }
438
439            println!("\n{}", "Bottom 10 Targets:".bold());
440            for result in sorted_results.iter().rev().take(10) {
441                print_target(result);
442            }
443        }
444
445        println!("\n{}", "=".repeat(60).bright_green());
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_terminal_reporter_creation() {
455        let _reporter = TerminalReporter;
456    }
457}