feat(go): step 3 — multiple URLs and -n sampling with min/avg/max

Add probe.Summarize to aggregate N Results into per-phase min/avg/max
stats. Wire up -n/--count flag in main to repeat each URL N times and
display an aligned three-column table. Single-sample output (n=1) is
unchanged. URLs are grouped with a blank line between them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:29:36 +02:00
parent 0ecfb9bb1a
commit 8ec926bbcd
5 changed files with 213 additions and 14 deletions

View File

@@ -0,0 +1,71 @@
package probe
import "time"
// PhaseStats holds min/avg/max for a phase across N samples.
type PhaseStats struct {
Min, Avg, Max time.Duration
Present bool
}
// Aggregate holds per-phase statistics over a set of Results.
type Aggregate struct {
URL string
Count int
StatusCode int
DNS PhaseStats
Connect PhaseStats
TLS PhaseStats
TTFB PhaseStats
Transfer PhaseStats
Total PhaseStats
}
// Summarize computes min/avg/max per phase from a slice of Results.
func Summarize(results []Result) Aggregate {
a := Aggregate{Count: len(results)}
if len(results) == 0 {
return a
}
a.URL = results[0].URL
a.StatusCode = results[len(results)-1].StatusCode
a.DNS = phaseStats(results, func(r Result) Phase { return r.DNS })
a.Connect = phaseStats(results, func(r Result) Phase { return r.Connect })
a.TLS = phaseStats(results, func(r Result) Phase { return r.TLS })
a.TTFB = phaseStats(results, func(r Result) Phase { return r.TTFB })
a.Transfer = phaseStats(results, func(r Result) Phase { return r.Transfer })
a.Total = phaseStats(results, func(r Result) Phase { return r.Total })
return a
}
func phaseStats(results []Result, get func(Result) Phase) PhaseStats {
var durations []time.Duration
for _, r := range results {
if p := get(r); p.Present {
durations = append(durations, p.Duration)
}
}
if len(durations) == 0 {
return PhaseStats{}
}
min, max := durations[0], durations[0]
var sum time.Duration
for _, d := range durations {
if d < min {
min = d
}
if d > max {
max = d
}
sum += d
}
return PhaseStats{
Min: min,
Avg: sum / time.Duration(len(durations)),
Max: max,
Present: true,
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"time"
"latprobe/internal/probe"
)
@@ -39,22 +40,34 @@ func main() {
os.Exit(1)
}
// --json and -n are parsed but not yet functional (wired in Steps 34).
// --json parsed but not yet functional (wired in Step 4).
_ = jsonOut
_ = count
failed := false
for i, url := range urls {
if i > 0 {
fmt.Println()
}
r := probe.Measure(url)
if r.Err != nil {
fmt.Fprintf(os.Stderr, "error %s: %v\n", r.URL, r.Err)
failed = true
results := make([]probe.Result, 0, *count)
for j := 0; j < *count; j++ {
r := probe.Measure(url)
if r.Err != nil {
fmt.Fprintf(os.Stderr, "error %s (sample %d/%d): %v\n", url, j+1, *count, r.Err)
failed = true
break
}
results = append(results, r)
}
if len(results) == 0 {
continue
}
printResult(r)
if *count == 1 {
printResult(results[0])
} else {
printAggregate(probe.Summarize(results))
}
}
if failed {
@@ -62,6 +75,8 @@ func main() {
}
}
// ── single-sample output ──────────────────────────────────────────────────────
func printResult(r probe.Result) {
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
@@ -75,17 +90,42 @@ func printResult(r probe.Result) {
{"Server (TTFB) ", r.TTFB},
{"Transfer ", r.Transfer},
}
for _, ph := range phases {
if ph.p.Present {
fmt.Printf(" %s : %8.2f ms\n", ph.label, msec(ph.p))
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
}
}
fmt.Println(" " + strings.Repeat("─", 29))
fmt.Printf(" %s : %8.2f ms\n", "Total ", msec(r.Total))
fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
}
func msec(p probe.Phase) float64 {
return float64(p.Duration.Microseconds()) / 1000
// ── multi-sample output ───────────────────────────────────────────────────────
func printAggregate(a probe.Aggregate) {
fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count)
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
phases := []struct {
label string
p probe.PhaseStats
}{
{"DNS lookup ", a.DNS},
{"TCP connect ", a.Connect},
{"TLS handshake ", a.TLS},
{"Server (TTFB) ", a.TTFB},
{"Transfer ", a.Transfer},
}
for _, ph := range phases {
if ph.p.Present {
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
ph.label, ms(ph.p.Min), ms(ph.p.Avg), ms(ph.p.Max))
}
}
fmt.Println(" " + strings.Repeat("─", 49))
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n",
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
}
func ms(d time.Duration) float64 {
return float64(d.Microseconds()) / 1000
}