feat(go): step 4 — --json output flag

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:31:54 +02:00
parent 8ec926bbcd
commit 3affc05996
4 changed files with 227 additions and 46 deletions

View File

@@ -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) ## 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 - `-n`/`--count` flag repeats each URL N times and reports min/avg/max per phase

View File

@@ -93,7 +93,7 @@ https://example.com (5 samples)
| 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 | ✅ Done | | 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ✅ Done |
| 4 | `--json` output flag | ⬜ Pending | | 4 | `--json` output flag | ✅ Done |
### Python ### Python

View File

@@ -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> [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).

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@@ -29,7 +30,7 @@ Examples:
func main() { func main() {
count := flag.Int("count", 1, "number of requests per URL") count := flag.Int("count", 1, "number of requests per URL")
flag.IntVar(count, "n", 1, "number of requests per URL (shorthand)") 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.Usage = func() { fmt.Fprint(os.Stderr, usageText) }
flag.Parse() flag.Parse()
@@ -40,29 +41,23 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// --json parsed but not yet functional (wired in Step 4).
_ = jsonOut
failed := false failed := false
for i, url := range urls { var jsonEntries []jsonEntry
if i > 0 {
fmt.Println()
}
results := make([]probe.Result, 0, *count) for i, url := range urls {
for j := 0; j < *count; j++ { results := collectSamples(url, *count, &failed)
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 { if len(results) == 0 {
continue continue
} }
if *jsonOut {
jsonEntries = append(jsonEntries, toJSONEntry(probe.Summarize(results)))
continue
}
if i > 0 {
fmt.Println()
}
if *count == 1 { if *count == 1 {
printResult(results[0]) printResult(results[0])
} else { } 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 { if failed {
os.Exit(1) 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) { func printResult(r probe.Result) {
fmt.Printf("%s (%d)\n", r.URL, r.StatusCode) fmt.Printf("%s (%d)\n", r.URL, r.StatusCode)
for _, ph := range singlePhases(r) {
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 {
if ph.p.Present { if ph.p.Present {
fmt.Printf(" %s : %8.2f ms\n", ph.label, ms(ph.p.Duration)) 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)) fmt.Printf(" %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
} }
// ── multi-sample output ───────────────────────────────────────────────────────
func printAggregate(a probe.Aggregate) { func printAggregate(a probe.Aggregate) {
fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count) fmt.Printf("%s (%d, %d samples)\n", a.URL, a.StatusCode, a.Count)
fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max") fmt.Printf(" %-14s %9s %9s %9s\n", "", "min", "avg", "max")
for _, ph := range aggPhases(a) {
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 { if ph.p.Present {
fmt.Printf(" %s : %6.2f ms %6.2f ms %6.2f ms\n", 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)) 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)) "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 { func ms(d time.Duration) float64 {
return float64(d.Microseconds()) / 1000 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
}