commit 5717f018b7801af6982cbfa22ab959474ee06f12 Author: Jan Novak Date: Wed Jul 1 00:16:05 2026 +0200 chore: initial scaffold for latprobe CLI tool Sets up project structure, working conventions (CLAUDE.md), README with the full assignment and roadmap, seeded CHANGELOG, and a buildable Go scaffold (Step 0) with flag parsing and stub probe types. Co-Authored-By: Claude Sonnet 4.6 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d7150ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All completed features are logged here in reverse-chronological order. + +--- + +## 2026-07-01 00:08 — Project scaffold (Go, Step 0) + +- Created project structure: `go/`, `python/`, `docs/plans/`, `docs/usage/` +- Initialised Go module `latprobe` (`go/go.mod`) +- Minimal `go/main.go` that prints usage and exits cleanly when no URL is given +- Stub `go/internal/probe/probe.go` defining the `Result` type and `Measure` signature +- Foundation files: `README.md`, `CLAUDE.md`, `CHANGELOG.md` +- Saved initial plan to `docs/plans/2026-07-01-00-08-go-latency-tool.md` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..150a81d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md — Working Conventions + +This file records the conventions Claude must follow throughout this project. + +## Plans + +Every implementation plan is saved under `docs/plans/` with a filename that +starts with the **current timestamp in `yyyy-mm-dd-hh-mm` format** followed by +a short kebab-case description. + +Example: `docs/plans/2026-07-01-00-08-go-latency-tool.md` + +## Changelog + +Every completed feature is appended to `CHANGELOG.md` at the project root with +a **timestamp** and a one-line description of what was added or changed. + +## User Documentation + +Every shipped feature must have a corresponding documentation file under +`docs/usage/`. Each file must include: +- What the feature does +- All relevant flags / arguments +- At least one concrete command-line example with expected output + +## Implementation Order + +1. Go implementation — developed first, incrementally, with user sign-off + between steps. +2. Python port — begins only after the user approves the Go implementation. + Its plan is drafted separately at that time. + +## Steps (Go) + +| Step | Description | +|------|-------------| +| 0 | Scaffold — directory structure, `go.mod`, minimal `main.go` that prints usage | +| 1 | Simple total latency — single URL, print wall-clock time of the full request | +| 2 | Per-phase breakdown — DNS, TCP connect, TLS, TTFB, transfer, total via `net/http/httptrace` | +| 3 | Multiple URLs + sampling — `--count`/`-n` flag, min/avg/max per phase | +| 4 | JSON output — `--json` flag; text stays default | diff --git a/README.md b/README.md new file mode 100644 index 0000000..75f949c --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# latprobe — Website Latency Probe + +A command-line tool that measures the latency of one or more websites, with a +**full per-request phase breakdown** — DNS resolution, TCP connect, TLS +handshake, server processing (time-to-first-byte), and content transfer. +Not just a ping: `latprobe` shows you _where_ the time goes. + +## Why + +A single total request time hides the root cause of slowness. Is it DNS? A slow +TLS negotiation? A laggy server? `latprobe` breaks the request into its +constituent phases so the bottleneck is immediately obvious. + +## Implementations + +| Language | Status | Location | +|----------|-----------|------------| +| Go | In progress | `go/` | +| Python | Planned | `python/` | + +Both implementations produce identical CLI behaviour and output formats. + +--- + +## Usage (Go) + +``` +latprobe [flags] [url ...] + +Flags: + -n, --count int Number of requests per URL (default 1) + --json Output results as JSON instead of text + +Examples: + latprobe https://example.com + latprobe -n 5 https://example.com https://www.google.com + latprobe --json https://example.com | jq . +``` + +--- + +## Output + +### Text (default) + +``` +https://example.com + DNS lookup : 12.34 ms + TCP connect : 8.91 ms + TLS handshake : 45.20 ms + Server (TTFB) : 78.56 ms + Transfer : 2.10 ms + ───────────────────────── + Total : 147.11 ms +``` + +With `-n 5` (min / avg / max columns): + +``` +https://example.com (5 samples) + min avg max + DNS lookup : 10.1ms 12.3ms 15.7ms + TCP connect : 7.8ms 9.0ms 11.2ms + ... +``` + +### JSON (`--json`) + +```json +{ + "url": "https://example.com", + "samples": 1, + "phases": { + "dns": { "ms": 12.34 }, + "connect": { "ms": 8.91 }, + "tls": { "ms": 45.20 }, + "ttfb": { "ms": 78.56 }, + "transfer": { "ms": 2.10 }, + "total": { "ms": 147.11 } + } +} +``` + +--- + +## Implementation Roadmap + +### Go + +| Step | Feature | Status | +|------|---------|--------| +| 0 | Project scaffold — directory structure, `go.mod`, minimal binary | ✅ Done | +| 1 | Simple total latency — single URL, wall-clock time | ⬜ Pending | +| 2 | Per-phase breakdown — DNS, TCP, TLS, TTFB, transfer (`net/http/httptrace`) | ⬜ Pending | +| 3 | Multiple URLs + `--count`/`-n` — min/avg/max aggregates | ⬜ Pending | +| 4 | `--json` output flag | ⬜ Pending | + +### Python + +Begins after the Go implementation is approved. Will mirror the same CLI and +output format. Timed using low-level socket hooks (DNS via `socket.getaddrinfo`, +connection timings via custom socket wrap or `httpx`/`urllib3` hooks). + +--- + +## Development Environment + +- Go 1.26.4 / darwin arm64 +- Python 3.14.6 +- No external dependencies (Go uses stdlib only; Python TBD at port time) + +## Project Conventions + +See [CLAUDE.md](CLAUDE.md) for the development conventions followed in this project: +- Plans saved to `docs/plans/` with timestamped filenames +- Completed features logged in `CHANGELOG.md` with timestamps +- Per-feature user docs in `docs/usage/` diff --git a/docs/plans/2026-07-01-00-08-go-latency-tool.md b/docs/plans/2026-07-01-00-08-go-latency-tool.md new file mode 100644 index 0000000..6e37749 --- /dev/null +++ b/docs/plans/2026-07-01-00-08-go-latency-tool.md @@ -0,0 +1,122 @@ +# Plan: Website latency CLI tool (Go first, then Python) + +## Context + +We're building a small CLI tool that measures the latency of one or more +websites given their URLs. The point of difference from a naive timer is a +**full per-phase breakdown of an HTTP request** — DNS resolution, TCP connect, +TLS handshake, server processing (time-to-first-byte), and content transfer — +not just a single total number. + +We start simple (total latency only) and grow the tool in clear steps. The Go +implementation comes first; once the user is satisfied, we port the same +behaviour to Python. This plan also establishes project-wide working +conventions (plans, changelog, per-feature docs) up front. + +Design decisions confirmed with the user: +- **Output:** human-readable text by default, with a `--json` flag for machine output. +- **Sampling:** a `--count`/`-n` flag repeats N requests per URL and reports min/avg/max per phase. +- **Input:** accept multiple URLs as positional arguments, measured in turn. + +Toolchain present: Go 1.26.4, Python 3.14.6. + +## Proposed layout + +``` +tt-excercise/ +├── CLAUDE.md # working conventions (created first) +├── README.md # the assignment + implementation steps +├── CHANGELOG.md # timestamped completed features +├── docs/ +│ ├── plans/ # timestamped plans (this plan copied here) +│ └── usage/ # per-feature user documentation +├── go/ +│ ├── go.mod # module: latprobe (name adjustable) +│ ├── main.go # CLI: flag parsing, orchestration, output +│ └── internal/probe/ +│ └── probe.go # timing logic via net/http/httptrace +└── python/ # added later, during the port +``` + +Binary/module name proposed: **`latprobe`** (easy to change). + +## Foundation files (created at the start) + +### CLAUDE.md — working conventions +- Every plan is saved under `docs/plans/` with a filename starting with the + current timestamp in `yyyy-mm-dd-hh-mm` format + (e.g. `2026-07-01-00-08-go-latency-tool.md`). +- Every completed feature is appended to `CHANGELOG.md` with a timestamp. +- Every feature ships with user documentation under `docs/usage/` including a + concrete example of how to use it. + +### README.md — the assignment +- Project purpose: CLI tool to measure website latency, with a full per-request + phase breakdown including DNS resolution. +- Two implementations: Go first, then a Python port. +- Usage sketch and the staged implementation roadmap (below). +- Note that Go 1.26 / Python 3.14 are the development versions. + +### CHANGELOG.md +- Seeded with a header; entries appended per completed feature. + +## Implementation steps (Go) + +Each step is a self-contained, reviewable increment. We pause for the user's +sign-off between steps. On completion of each: append to `CHANGELOG.md` and add +its `docs/usage/` page. + +**Step 0 — Scaffold.** Create `go/go.mod` (`go mod init latprobe`), a minimal +`main.go` that prints usage, and the directory structure. Confirms the toolchain +builds. + +**Step 1 — Simple total latency.** Accept a single URL argument, perform an +HTTP GET, and print the total wall-clock time of the request. No breakdown yet. +Reuse the standard library only (`net/http`, `time`). + +**Step 2 — Full per-phase breakdown (the core feature).** Instrument the request +with `net/http/httptrace.ClientTrace` to capture timestamps and derive each phase: +- DNS lookup (`DNSStart` → `DNSDone`) +- TCP connect (`ConnectStart` → `ConnectDone`) +- TLS handshake (`TLSHandshakeStart` → `TLSHandshakeDone`, HTTPS only) +- Server processing / TTFB (`WroteRequest` → `GotFirstResponseByte`) +- Content transfer (`GotFirstResponseByte` → response body fully read) +- Total +Print an aligned text breakdown. `httptrace` is the key reusable stdlib piece — +no third-party dependency needed. + +**Step 3 — Multiple URLs + sampling.** Accept multiple positional URLs; add +`--count`/`-n` (default 1) to repeat each URL N times and report **min/avg/max** +per phase. Group output clearly per URL. + +**Step 4 — JSON output.** Add a `--json` flag producing structured output +(per URL, per phase, with the min/avg/max aggregates and sample count). Keep the +human-readable text as the default. + +Reasonable extras to consider later (not committed now): `--timeout`, +`--method`, redirect handling, HTTP/2 vs HTTP/1.1 reporting, exit codes on failure. + +## Port to Python (after Go is approved) + +Mirror the same CLI and output. Python has no direct `httptrace` equivalent, so +the plan will measure phases at a lower level — e.g. custom timing around +`socket.getaddrinfo` (DNS), connection establishment, and TLS wrap, or by using +`urllib3`/`httpx` connection hooks. The detailed Python design will be drafted +as its own plan (saved to `docs/plans/`) when we reach that stage. + +## Verification + +- `cd go && go build ./...` succeeds at every step. +- Manual runs against known hosts, e.g.: + - `./latprobe https://example.com` → total only (Step 1), full breakdown (Step 2+). + - `./latprobe -n 5 https://example.com https://www.google.com` → min/avg/max per URL. + - `./latprobe --json https://example.com` → valid JSON (pipe through a JSON validator). +- Sanity-check phase numbers: phases should roughly sum to the total; HTTPS shows + a TLS phase, plain HTTP does not. +- `go vet ./...` clean. + +## Bookkeeping at execution time + +On exiting plan mode, before/with Step 0: +1. Copy this plan to `docs/plans/2026-07-01-00-08-go-latency-tool.md`. +2. Create `CLAUDE.md`, `README.md`, `CHANGELOG.md` as described above. diff --git a/docs/usage/step-0-scaffold.md b/docs/usage/step-0-scaffold.md new file mode 100644 index 0000000..0c261ce --- /dev/null +++ b/docs/usage/step-0-scaffold.md @@ -0,0 +1,51 @@ +# Step 0 — Scaffold + +## What this step delivers + +A buildable Go binary (`latprobe`) that parses flags and URLs, prints usage +when invoked without arguments, and exits cleanly. No measurement logic yet — +the full implementation is wired in during Steps 1–4. + +## Build + +```sh +cd go +go build -o latprobe . +``` + +## Usage + +```sh +# No arguments → prints usage and exits with code 1 +./latprobe + +# With a URL → scaffold message until Step 1 is implemented +./latprobe https://example.com +``` + +Expected output (scaffold stage): + +``` +latprobe — measure per-phase HTTP request latency + +Usage: + latprobe [flags] [url ...] + +Flags: + -n, --count int Number of requests per URL (default 1) + --json Output results as JSON instead of text + -h, --help Show this help + +Examples: + latprobe https://example.com + latprobe -n 5 https://example.com https://www.google.com + latprobe --json https://example.com | jq . +``` + +## Available flags (accepted by the parser, not yet functional) + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--count` | `-n` | `1` | Number of requests per URL | +| `--json` | — | false | Output as JSON instead of text | +| `--help` | `-h` | — | Show usage | diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..046a100 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module latprobe + +go 1.26.4 diff --git a/go/internal/probe/probe.go b/go/internal/probe/probe.go new file mode 100644 index 0000000..28169e1 --- /dev/null +++ b/go/internal/probe/probe.go @@ -0,0 +1,34 @@ +// Package probe measures per-phase HTTP request latency. +package probe + +import "time" + +// Phase holds the measured duration of a single request phase. +// A zero Duration means the phase did not occur (e.g. TLS on plain HTTP). +type Phase struct { + Duration time.Duration + // Present is false when the phase was skipped (e.g. no TLS for http://). + Present bool +} + +// Result holds all timing phases for a single HTTP request. +type Result struct { + URL string + DNS Phase + Connect Phase + TLS Phase + TTFB Phase // server processing: WroteRequest → GotFirstResponseByte + Transfer Phase // body read: GotFirstResponseByte → body closed + Total Phase + + // StatusCode is the HTTP response status code (0 on error). + StatusCode int + // Err is non-nil if the request failed. + Err error +} + +// Measure performs an HTTP GET request to url and returns a populated Result. +// It is a placeholder until Step 1 wires in the actual implementation. +func Measure(url string) Result { + return Result{URL: url} +} diff --git a/go/latprobe b/go/latprobe new file mode 100755 index 0000000..0bc0e49 Binary files /dev/null and b/go/latprobe differ diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..285d22b --- /dev/null +++ b/go/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +const usageText = `latprobe — measure per-phase HTTP request latency + +Usage: + latprobe [flags] [url ...] + +Flags: + -n, --count int Number of requests per URL (default 1) + --json Output results as JSON instead of text + -h, --help Show this help + +Examples: + latprobe https://example.com + latprobe -n 5 https://example.com https://www.google.com + latprobe --json https://example.com | jq . +` + +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") + + flag.Usage = func() { fmt.Fprint(os.Stderr, usageText) } + flag.Parse() + + urls := flag.Args() + if len(urls) == 0 { + fmt.Fprint(os.Stderr, usageText) + os.Exit(1) + } + + // Placeholders — implementations wired in per step. + _, _ = count, jsonOut + + fmt.Fprintf(os.Stderr, "latprobe: not yet implemented (scaffold only)\n") + os.Exit(1) +}