Probe multiple URLs in parallel with a bounded worker pool; the N samples of each URL remain sequential to preserve accurate min/avg/max statistics. Default auto-concurrency is min(numURLs, 8); -c 1 restores serial mode. Output is always buffered and printed in original input order. Verified clean with go test -race. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
411 lines
10 KiB
Go
411 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"latprobe/internal/probe"
|
||
)
|
||
|
||
const usageText = `latprobe — measure per-phase HTTP request latency
|
||
|
||
Usage:
|
||
latprobe [flags] <url> [url ...]
|
||
|
||
Flags:
|
||
-n, --count int Number of requests per URL (default 1)
|
||
-c, --concurrency int Max URLs probed in parallel, 0 = auto (default min(numURLs,8))
|
||
--timeout duration Request timeout, e.g. 10s, 500ms (default 10s)
|
||
--fail Exit non-zero on HTTP status >= 400 (exit code 6)
|
||
--json Output results as JSON instead of text
|
||
-h, --help Show this help
|
||
|
||
Exit codes:
|
||
0 All probes succeeded
|
||
1 Usage error
|
||
2 DNS resolution failure
|
||
3 Connection failure
|
||
4 Timeout
|
||
5 TLS handshake failure
|
||
6 HTTP status >= 400 (only with --fail)
|
||
|
||
Examples:
|
||
latprobe https://example.com
|
||
latprobe -n 5 https://example.com https://www.google.com
|
||
latprobe --timeout 2s https://slow-host.example.com
|
||
latprobe --fail https://example.com
|
||
latprobe --json https://example.com | jq .
|
||
`
|
||
|
||
// exit codes
|
||
const (
|
||
exitOK = 0
|
||
exitUsage = 1
|
||
exitDNS = 2
|
||
exitConnect = 3
|
||
exitTimeout = 4
|
||
exitTLS = 5
|
||
exitHTTP = 6
|
||
)
|
||
|
||
func failPhaseCode(fp string) int {
|
||
switch fp {
|
||
case "dns":
|
||
return exitDNS
|
||
case "timeout":
|
||
return exitTimeout
|
||
case "tls":
|
||
return exitTLS
|
||
default:
|
||
return exitConnect
|
||
}
|
||
}
|
||
|
||
func main() {
|
||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||
}
|
||
|
||
func run(args []string, stdout, stderr io.Writer) int {
|
||
fs := flag.NewFlagSet("latprobe", flag.ContinueOnError)
|
||
fs.SetOutput(stderr)
|
||
|
||
count := fs.Int("count", 1, "number of requests per URL")
|
||
fs.IntVar(count, "n", 1, "number of requests per URL (shorthand)")
|
||
conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)")
|
||
fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)")
|
||
timeout := fs.Duration("timeout", 10*time.Second, "request timeout per sample")
|
||
fail := fs.Bool("fail", false, "exit non-zero on HTTP status >= 400")
|
||
jsonOut := fs.Bool("json", false, "output results as JSON instead of text")
|
||
fs.Usage = func() { fmt.Fprint(stderr, usageText) }
|
||
|
||
if err := fs.Parse(args); err != nil {
|
||
if errors.Is(err, flag.ErrHelp) {
|
||
return exitOK
|
||
}
|
||
return exitUsage
|
||
}
|
||
|
||
urls := fs.Args()
|
||
if len(urls) == 0 {
|
||
fmt.Fprint(stderr, usageText)
|
||
return exitUsage
|
||
}
|
||
|
||
opts := probe.Options{Timeout: *timeout}
|
||
|
||
// Resolve effective worker count.
|
||
const defaultMaxConc = 8
|
||
workers := *conc
|
||
if workers <= 0 {
|
||
workers = min(len(urls), defaultMaxConc) // auto
|
||
}
|
||
workers = min(workers, len(urls)) // never more goroutines than work units
|
||
workers = max(workers, 1)
|
||
|
||
// ── Measure phase (concurrent) ────────────────────────────────────────────
|
||
// Each goroutine writes only its own indexed slot — no shared mutable state.
|
||
type urlResult struct {
|
||
succeeded, failed []probe.Result
|
||
}
|
||
results := make([]urlResult, len(urls))
|
||
sem := make(chan struct{}, workers)
|
||
var wg sync.WaitGroup
|
||
for i, rawURL := range urls {
|
||
wg.Add(1)
|
||
go func(i int, rawURL string) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
s, f := runSamples(rawURL, *count, opts)
|
||
results[i] = urlResult{s, f}
|
||
}(i, rawURL)
|
||
}
|
||
wg.Wait()
|
||
|
||
// ── Render phase (sequential, input order) ────────────────────────────────
|
||
worstCode := exitOK
|
||
var jsonEntries []jsonEntry
|
||
|
||
for i, rawURL := range urls {
|
||
succeeded := results[i].succeeded
|
||
failed := results[i].failed
|
||
|
||
for _, r := range failed {
|
||
if c := failPhaseCode(r.FailPhase); c > worstCode {
|
||
worstCode = c
|
||
}
|
||
}
|
||
if *fail {
|
||
for _, r := range succeeded {
|
||
if r.StatusCode >= 400 && exitHTTP > worstCode {
|
||
worstCode = exitHTTP
|
||
}
|
||
}
|
||
}
|
||
|
||
if *jsonOut {
|
||
jsonEntries = append(jsonEntries, buildJSONEntry(rawURL, succeeded, failed))
|
||
continue
|
||
}
|
||
|
||
if i > 0 {
|
||
fmt.Fprintln(stdout)
|
||
}
|
||
printURL(stdout, rawURL, succeeded, failed, *count, *fail)
|
||
}
|
||
|
||
if *jsonOut {
|
||
enc := json.NewEncoder(stdout)
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(jsonEntries); err != nil {
|
||
fmt.Fprintf(stderr, "json encode: %v\n", err)
|
||
return exitConnect
|
||
}
|
||
}
|
||
|
||
return worstCode
|
||
}
|
||
|
||
// ── sampling ──────────────────────────────────────────────────────────────────
|
||
|
||
func runSamples(rawURL string, count int, opts probe.Options) (succeeded, failed []probe.Result) {
|
||
for range count {
|
||
r := probe.Measure(rawURL, opts)
|
||
if r.Err != nil {
|
||
failed = append(failed, r)
|
||
} else {
|
||
succeeded = append(succeeded, r)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
func unwrapMsg(err error) string {
|
||
var urlErr *url.Error
|
||
if errors.As(err, &urlErr) {
|
||
return urlErr.Err.Error()
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
// ── text output ───────────────────────────────────────────────────────────────
|
||
|
||
func printURL(w io.Writer, rawURL string, succeeded, failed []probe.Result, total int, fail bool) {
|
||
nOK := len(succeeded)
|
||
nFail := len(failed)
|
||
|
||
switch {
|
||
case nFail == 0 && total == 1:
|
||
printResult(w, succeeded[0], fail)
|
||
|
||
case nFail == 0:
|
||
printAggregate(w, probe.Summarize(succeeded), nil, fail)
|
||
|
||
case nOK == 0:
|
||
// All samples failed — print header then partial timing from last failure.
|
||
header := rawURL + " (FAILED"
|
||
if total > 1 {
|
||
header += fmt.Sprintf(", 0/%d succeeded", total)
|
||
}
|
||
fmt.Fprintln(w, header+")")
|
||
last := failed[len(failed)-1]
|
||
anyPhase := false
|
||
for _, ph := range singlePhaseList(last) {
|
||
if ph.p.Present {
|
||
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||
anyPhase = true
|
||
}
|
||
}
|
||
if last.Total.Present {
|
||
if anyPhase {
|
||
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
|
||
}
|
||
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(last.Total.Duration))
|
||
}
|
||
printFailureSummary(w, failed)
|
||
|
||
default:
|
||
// Mixed: some succeeded, some failed.
|
||
printAggregate(w, probe.Summarize(succeeded), failed, fail)
|
||
}
|
||
}
|
||
|
||
func printResult(w io.Writer, r probe.Result, fail bool) {
|
||
status := fmt.Sprintf("%d", r.StatusCode)
|
||
if fail && r.StatusCode >= 400 {
|
||
status += " ✗"
|
||
}
|
||
fmt.Fprintf(w, "%s (%s)\n", r.URL, status)
|
||
|
||
for _, ph := range singlePhaseList(r) {
|
||
if ph.p.Present {
|
||
fmt.Fprintf(w, " %s : %8.2f ms\n", ph.label, ms(ph.p.Duration))
|
||
}
|
||
}
|
||
fmt.Fprintln(w, " "+strings.Repeat("─", 29))
|
||
if r.Total.Present {
|
||
fmt.Fprintf(w, " %s : %8.2f ms\n", "Total ", ms(r.Total.Duration))
|
||
}
|
||
if r.Err != nil {
|
||
fmt.Fprintf(w, " ✗ %s: %s\n", r.FailPhase, unwrapMsg(r.Err))
|
||
}
|
||
}
|
||
|
||
func printAggregate(w io.Writer, a probe.Aggregate, failed []probe.Result, fail bool) {
|
||
status := fmt.Sprintf("%d", a.StatusCode)
|
||
if fail && a.StatusCode >= 400 {
|
||
status += " ✗"
|
||
}
|
||
header := fmt.Sprintf("%s (%s, %d samples", a.URL, status, a.Count)
|
||
if len(failed) > 0 {
|
||
header += fmt.Sprintf(", %d failed", len(failed))
|
||
}
|
||
fmt.Fprintln(w, header+")")
|
||
|
||
if a.Total.Present {
|
||
fmt.Fprintf(w, " %-14s %9s %9s %9s\n", "", "min", "avg", "max")
|
||
for _, ph := range aggPhaseList(a) {
|
||
if ph.p.Present {
|
||
fmt.Fprintf(w, " %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.Fprintln(w, " "+strings.Repeat("─", 49))
|
||
fmt.Fprintf(w, " %s : %6.2f ms %6.2f ms %6.2f ms\n",
|
||
"Total ", ms(a.Total.Min), ms(a.Total.Avg), ms(a.Total.Max))
|
||
}
|
||
printFailureSummary(w, failed)
|
||
}
|
||
|
||
func printFailureSummary(w io.Writer, failed []probe.Result) {
|
||
if len(failed) == 0 {
|
||
return
|
||
}
|
||
type key struct{ phase, msg string }
|
||
counts := map[key]int{}
|
||
var order []key
|
||
for _, r := range failed {
|
||
k := key{r.FailPhase, unwrapMsg(r.Err)}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
for _, k := range order {
|
||
n := counts[k]
|
||
if n == 1 {
|
||
fmt.Fprintf(w, " ✗ %s: %s\n", k.phase, k.msg)
|
||
} else {
|
||
fmt.Fprintf(w, " ✗ %d × %s: %s\n", n, k.phase, k.msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
func singlePhaseList(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 aggPhaseList(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 jsonError struct {
|
||
Phase string `json:"phase"`
|
||
Count int `json:"count"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
type jsonEntry struct {
|
||
URL string `json:"url"`
|
||
Status int `json:"status"`
|
||
Succeeded int `json:"succeeded"`
|
||
Failed int `json:"failed"`
|
||
Phases map[string]jsonPhase `json:"phases,omitempty"`
|
||
Errors []jsonError `json:"errors,omitempty"`
|
||
}
|
||
|
||
func buildJSONEntry(rawURL string, succeeded, failed []probe.Result) jsonEntry {
|
||
e := jsonEntry{
|
||
URL: rawURL,
|
||
Succeeded: len(succeeded),
|
||
Failed: len(failed),
|
||
}
|
||
|
||
if len(succeeded) > 0 {
|
||
a := probe.Summarize(succeeded)
|
||
e.Status = a.StatusCode
|
||
e.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)
|
||
}
|
||
|
||
type key struct{ phase, msg string }
|
||
counts := map[key]int{}
|
||
var order []key
|
||
for _, r := range failed {
|
||
k := key{r.FailPhase, unwrapMsg(r.Err)}
|
||
if counts[k] == 0 {
|
||
order = append(order, k)
|
||
}
|
||
counts[k]++
|
||
}
|
||
for _, k := range order {
|
||
e.Errors = append(e.Errors, jsonError{Phase: k.phase, Count: counts[k], Message: k.msg})
|
||
}
|
||
|
||
return e
|
||
}
|