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:
@@ -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)
|
## 2026-07-01 00:08 — Per-phase breakdown (Go, Step 2)
|
||||||
|
|
||||||
- Instrumented requests with `net/http/httptrace.ClientTrace`
|
- Instrumented requests with `net/http/httptrace.ClientTrace`
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ https://example.com (5 samples)
|
|||||||
| 0 | Project scaffold — directory structure, `go.mod`, minimal binary | ✅ Done |
|
| 0 | Project scaffold — directory structure, `go.mod`, minimal binary | ✅ Done |
|
||||||
| 1 | Simple total latency — single URL, wall-clock time | ✅ Done |
|
| 1 | Simple total latency — single URL, wall-clock time | ✅ Done |
|
||||||
| 2 | Per-phase breakdown — DNS, TCP, TLS, TTFB, transfer (`net/http/httptrace`) | ✅ 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 |
|
| 4 | `--json` output flag | ⬜ Pending |
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
|||||||
79
docs/usage/step-3-sampling.md
Normal file
79
docs/usage/step-3-sampling.md
Normal file
@@ -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> [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.
|
||||||
71
go/internal/probe/aggregate.go
Normal file
71
go/internal/probe/aggregate.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
66
go/main.go
66
go/main.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"latprobe/internal/probe"
|
"latprobe/internal/probe"
|
||||||
)
|
)
|
||||||
@@ -39,22 +40,34 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
_ = jsonOut
|
||||||
_ = count
|
|
||||||
|
|
||||||
failed := false
|
failed := false
|
||||||
for i, url := range urls {
|
for i, url := range urls {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
r := probe.Measure(url)
|
|
||||||
if r.Err != nil {
|
results := make([]probe.Result, 0, *count)
|
||||||
fmt.Fprintf(os.Stderr, "error %s: %v\n", r.URL, r.Err)
|
for j := 0; j < *count; j++ {
|
||||||
failed = true
|
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
|
continue
|
||||||
}
|
}
|
||||||
printResult(r)
|
|
||||||
|
if *count == 1 {
|
||||||
|
printResult(results[0])
|
||||||
|
} else {
|
||||||
|
printAggregate(probe.Summarize(results))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed {
|
if failed {
|
||||||
@@ -62,6 +75,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── single-sample output ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func printResult(r probe.Result) {
|
func printResult(r probe.Result) {
|
||||||
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
|
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
|
||||||
|
|
||||||
@@ -75,17 +90,42 @@ func printResult(r probe.Result) {
|
|||||||
{"Server (TTFB) ", r.TTFB},
|
{"Server (TTFB) ", r.TTFB},
|
||||||
{"Transfer ", r.Transfer},
|
{"Transfer ", r.Transfer},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ph := range phases {
|
for _, ph := range phases {
|
||||||
if ph.p.Present {
|
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.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 {
|
// ── multi-sample output ───────────────────────────────────────────────────────
|
||||||
return float64(p.Duration.Microseconds()) / 1000
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user