Files
http-latency-prober/go/main.go
Jan Novak a9534ec2c1 test(go): step 6 — integration tests covering all exit codes and output
Extract run(args, stdout, stderr) int from main() for in-process
testability. Fix TLS failure classification (tlsErr now captured from
TLSHandshakeDone hook). Add run_test.go with 14 table-driven in-process
tests and cli_test.go with TestMain + 3 subprocess smoke tests. All
servers use httptest; .invalid TLD for deterministic DNS failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 01:00:20 +02:00

375 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/url"
"os"
"strings"
"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)
--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)")
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}
worstCode := exitOK
var jsonEntries []jsonEntry
for i, rawURL := range urls {
succeeded, failed := runSamples(rawURL, *count, opts)
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
}