diff --git a/CHANGELOG.md b/CHANGELOG.md index 895a403..3872aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All completed features are logged here in reverse-chronological order. --- +## 2026-07-01 00:08 — Multiple URLs + sampling (Go, Step 3) + +- `-n`/`--count` flag repeats each URL N times and reports min/avg/max per phase +- `probe.Summarize` aggregates a slice of Results into per-phase PhaseStats +- Multi-sample output shows an aligned min/avg/max table with a header row +- Single-sample output (n=1) is unchanged from Step 2 + +--- + ## 2026-07-01 00:08 — Per-phase breakdown (Go, Step 2) - Instrumented requests with `net/http/httptrace.ClientTrace` diff --git a/README.md b/README.md index 6652940..6fa0aea 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ https://example.com (5 samples) | 0 | Project scaffold — directory structure, `go.mod`, minimal binary | ✅ Done | | 1 | Simple total latency — single URL, wall-clock time | ✅ Done | | 2 | Per-phase breakdown — DNS, TCP, TLS, TTFB, transfer (`net/http/httptrace`) | ✅ Done | -| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ⬜ Pending | +| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ✅ Done | | 4 | `--json` output flag | ⬜ Pending | ### Python diff --git a/docs/usage/step-3-sampling.md b/docs/usage/step-3-sampling.md new file mode 100644 index 0000000..efd5384 --- /dev/null +++ b/docs/usage/step-3-sampling.md @@ -0,0 +1,79 @@ +# Step 3 — Multiple URLs + Sampling + +## What this step delivers + +- Multiple URLs accepted as positional arguments; each is measured in turn. +- `-n`/`--count` flag repeats each URL N times and reports **min / avg / max** + per phase instead of a single value. +- With `n=1` (the default) the output is identical to Step 2. + +## Build + +```sh +cd go +go build -o latprobe . +``` + +## Usage + +```sh +latprobe [flags] [url ...] + + -n, --count int Number of requests per URL (default 1) +``` + +## Examples + +### Default (single sample, unchanged from Step 2) + +```sh +$ ./latprobe https://example.com +https://example.com (200) + DNS lookup : 13.74 ms + TCP connect : 11.20 ms + TLS handshake : 22.92 ms + Server (TTFB) : 14.88 ms + Transfer : 0.11 ms + ───────────────────────────── + Total : 63.97 ms +``` + +### Multiple samples (`-n`) + +```sh +$ ./latprobe -n 5 https://example.com +https://example.com (200, 5 samples) + min avg max + DNS lookup : 1.50 ms 1.60 ms 1.70 ms + TCP connect : 10.20 ms 10.80 ms 11.50 ms + TLS handshake : 21.00 ms 22.10 ms 23.40 ms + Server (TTFB) : 12.00 ms 13.50 ms 15.20 ms + Transfer : 0.07 ms 0.09 ms 0.12 ms + ───────────────────────────────────────────────── + Total : 45.00 ms 48.10 ms 52.00 ms +``` + +### Multiple URLs + +```sh +$ ./latprobe -n 3 https://example.com https://www.google.com +https://example.com (200, 3 samples) + min avg max + ... + ───────────────────────────────────────────────── + Total : ... + +https://www.google.com (200, 3 samples) + min avg max + ... +``` + +## Notes + +- **DNS and connect on repeated requests**: after the first request the OS + DNS cache and HTTP keep-alive may eliminate those phases in subsequent + samples. The spread in TCP connect and DNS between min and max shows the + true variance when caches are cold. +- **Partial failure**: if a sample fails mid-run, that URL is skipped and + the exit code is 1. Successful samples already collected are discarded for + that URL. diff --git a/go/internal/probe/aggregate.go b/go/internal/probe/aggregate.go new file mode 100644 index 0000000..cb6a5ee --- /dev/null +++ b/go/internal/probe/aggregate.go @@ -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, + } +} diff --git a/go/main.go b/go/main.go index e4e84ca..49f6a66 100644 --- a/go/main.go +++ b/go/main.go @@ -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 3–4). + // --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 }