From 3affc059961a165577a917585dac4c2383a08d89 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 1 Jul 2026 00:31:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(go):=20step=204=20=E2=80=94=20--json=20out?= =?UTF-8?q?put=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --json flag that emits all results as a JSON array. Schema always uses min/avg/max per phase (consistent regardless of -n); phases absent from the request are omitted from the phases object. Output is buffered until all URLs complete, then printed as one pretty-printed array. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++ README.md | 2 +- docs/usage/step-4-json-output.md | 105 +++++++++++++++++++++ go/main.go | 156 ++++++++++++++++++++++--------- 4 files changed, 227 insertions(+), 46 deletions(-) create mode 100644 docs/usage/step-4-json-output.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3872aad..dee7ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All completed features are logged here in reverse-chronological order. --- +## 2026-07-01 00:08 — JSON output flag (Go, Step 4) + +- `--json` flag emits a JSON array with one entry per URL +- Schema always uses min/avg/max shape (consistent regardless of `-n`) +- Phases absent from the request (e.g. TLS on HTTP) are omitted from the JSON object +- Failed URLs are excluded from JSON output and reported to stderr +- Text output unchanged and remains the default + +--- + ## 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 diff --git a/README.md b/README.md index 6fa0aea..8dc782a 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ https://example.com (5 samples) | 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 | ✅ Done | -| 4 | `--json` output flag | ⬜ Pending | +| 4 | `--json` output flag | ✅ Done | ### Python diff --git a/docs/usage/step-4-json-output.md b/docs/usage/step-4-json-output.md new file mode 100644 index 0000000..6db2237 --- /dev/null +++ b/docs/usage/step-4-json-output.md @@ -0,0 +1,105 @@ +# Step 4 — JSON Output + +## What this step delivers + +A `--json` flag that emits all results as a JSON array instead of the text +table. The text format remains the default. + +The JSON schema is always the aggregate shape (min/avg/max per phase), even +for a single sample — so the schema is stable regardless of `-n`. + +Phases absent from the request (e.g. TLS for `http://`) are omitted from the +`phases` object. + +## Build + +```sh +cd go +go build -o latprobe . +``` + +## Usage + +```sh +latprobe --json [other flags] [url ...] +``` + +## JSON Schema + +```json +[ + { + "url": "https://example.com", + "status": 200, + "samples": 1, + "phases": { + "dns": { "min_ms": 17.17, "avg_ms": 17.17, "max_ms": 17.17 }, + "connect": { "min_ms": 12.01, "avg_ms": 12.01, "max_ms": 12.01 }, + "tls": { "min_ms": 22.78, "avg_ms": 22.78, "max_ms": 22.78 }, + "ttfb": { "min_ms": 12.99, "avg_ms": 12.99, "max_ms": 12.99 }, + "transfer": { "min_ms": 0.18, "avg_ms": 0.18, "max_ms": 0.18 }, + "total": { "min_ms": 66.19, "avg_ms": 66.19, "max_ms": 66.19 } + } + } +] +``` + +Phase keys: `dns`, `connect`, `tls` (HTTPS only), `ttfb`, `transfer`, `total`. +All `*_ms` values are floating-point milliseconds. + +## Examples + +### Single URL, single sample + +```sh +$ ./latprobe --json https://example.com +[ + { + "url": "https://example.com", + "status": 200, + "samples": 1, + "phases": { ... } + } +] +``` + +### Multiple samples — min/avg/max diverge + +```sh +$ ./latprobe --json -n 5 https://example.com +[ + { + "url": "https://example.com", + "status": 200, + "samples": 5, + "phases": { + "total": { "min_ms": 45.00, "avg_ms": 52.30, "max_ms": 61.80 }, + ... + } + } +] +``` + +### Pipe to jq + +```sh +# Extract avg total latency for each URL +$ ./latprobe --json -n 3 https://example.com https://www.google.com \ + | jq '.[] | {url, avg_total_ms: .phases.total.avg_ms}' + +{ + "url": "https://example.com", + "avg_total_ms": 52.3 +} +{ + "url": "https://www.google.com", + "avg_total_ms": 82.8 +} +``` + +## Notes + +- Output is buffered until all URLs are measured, then printed as one array. + Errors for individual URLs are written to stderr; that URL is excluded from + the JSON array, and the process exits with code 1. +- The text format is unchanged and remains the default (no `--json` flag). diff --git a/go/main.go b/go/main.go index 49f6a66..01ce65f 100644 --- a/go/main.go +++ b/go/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "fmt" "os" @@ -29,7 +30,7 @@ Examples: func main() { count := flag.Int("count", 1, "number of requests per URL") flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)") - jsonOut := flag.Bool("json", false, "output results as JSON") + jsonOut := flag.Bool("json", false, "output results as JSON instead of text") flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) } flag.Parse() @@ -40,29 +41,23 @@ func main() { os.Exit(1) } - // --json parsed but not yet functional (wired in Step 4). - _ = jsonOut - failed := false - for i, url := range urls { - if i > 0 { - fmt.Println() - } + var jsonEntries []jsonEntry - 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) - } + for i, url := range urls { + results := collectSamples(url, *count, &failed) if len(results) == 0 { continue } + if *jsonOut { + jsonEntries = append(jsonEntries, toJSONEntry(probe.Summarize(results))) + continue + } + + if i > 0 { + fmt.Println() + } if *count == 1 { printResult(results[0]) } else { @@ -70,27 +65,39 @@ func main() { } } + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(jsonEntries); err != nil { + fmt.Fprintf(os.Stderr, "json encode: %v\n", err) + os.Exit(1) + } + } + if failed { os.Exit(1) } } -// ── single-sample output ────────────────────────────────────────────────────── +func collectSamples(url string, count int, failed *bool) []probe.Result { + results := make([]probe.Result, 0, count) + for i := range count { + r := probe.Measure(url) + if r.Err != nil { + fmt.Fprintf(os.Stderr, "error %s (sample %d/%d): %v\n", url, i+1, count, r.Err) + *failed = true + return nil + } + results = append(results, r) + } + return results +} + +// ── text output ─────────────────────────────────────────────────────────────── func printResult(r probe.Result) { fmt.Printf("%s (%d)\n", r.URL, r.StatusCode) - - phases := []struct { - label string - p probe.Phase - }{ - {"DNS lookup ", r.DNS}, - {"TCP connect ", r.Connect}, - {"TLS handshake ", r.TLS}, - {"Server (TTFB) ", r.TTFB}, - {"Transfer ", r.Transfer}, - } - for _, ph := range phases { + for _, ph := range singlePhases(r) { if ph.p.Present { fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration)) } @@ -99,23 +106,10 @@ func printResult(r probe.Result) { fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration)) } -// ── 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 { + for _, ph := range aggPhases(a) { 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)) @@ -126,6 +120,78 @@ func printAggregate(a probe.Aggregate) { "Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max)) } +func singlePhases(r probe.Result) []struct { + label string + p probe.Phase +} { + return []struct { + label string + p probe.Phase + }{ + {"DNS lookup ", r.DNS}, + {"TCP connect ", r.Connect}, + {"TLS handshake ", r.TLS}, + {"Server (TTFB) ", r.TTFB}, + {"Transfer ", r.Transfer}, + } +} + +func aggPhases(a probe.Aggregate) []struct { + label string + p probe.PhaseStats +} { + return []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}, + } +} + func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } + +// ── JSON output ─────────────────────────────────────────────────────────────── + +type jsonPhase struct { + MinMS float64 `json:"min_ms"` + AvgMS float64 `json:"avg_ms"` + MaxMS float64 `json:"max_ms"` +} + +type jsonEntry struct { + URL string `json:"url"` + Status int `json:"status"` + Samples int `json:"samples"` + Phases map[string]jsonPhase `json:"phases"` +} + +func toJSONEntry(a probe.Aggregate) jsonEntry { + e := jsonEntry{ + URL: a.URL, + Status: a.StatusCode, + Samples: a.Count, + Phases: make(map[string]jsonPhase), + } + add := func(name string, s probe.PhaseStats) { + if s.Present { + e.Phases[name] = jsonPhase{ + MinMS: ms(s.Min), + AvgMS: ms(s.Avg), + MaxMS: ms(s.Max), + } + } + } + add("dns", a.DNS) + add("connect", a.Connect) + add("tls", a.TLS) + add("ttfb", a.TTFB) + add("transfer", a.Transfer) + add("total", a.Total) + return e +}