Files
http-latency-prober/CHANGELOG.md
Jan Novak f487a4b1bd docs: hxprobe plans, summaries, explanations, usage, and changelog
Plans (docs/plans/):
- 2026-07-01-23-47-py-hxprobe-httpx.md — initial httpx probe design
- 2026-07-02-09-32 through 14-05 — standalone project, toolchain, usage
  doc + Makefile, file input (-f), simplification pass, run-summary footer

Summaries (docs/summaries/): one per completed feature, recording what
was actually built, deviations from the plan, and verification steps

Explanations (docs/explanations/): two deep-dives written during review —
hxprobe concurrency model and worst-exit-code + render-loop analysis

Usage (docs/usage/hxprobe.md): overview with pointer to hxprobe/USAGE.md
for the full runnable reference

Walkthrough (docs/py-latprobe-walkthrough.md): narrative tour of the
latprobe Python package for interview / code-review context

CHANGELOG.md: entries for all hxprobe features (toolchain, usage doc,
file input, simplification, run-summary footer)

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

24 KiB
Raw Permalink Blame History

Changelog

All completed features are logged here in reverse-chronological order.


  • hxprobe/hxprobe/cli.py: when more than one URL is probed (positional args or -f), a footer is now appended after the last URL block — a tally of every URL's outcome (N ok, M failed broken down by class) plus the → exit N (label) line tying it to the process exit code. Single-URL text output and JSON output (still a bare array) are both byte-identical to before — new _print_run_summary(), gated on len(urls) > 1 and text mode only
  • The process exit code itself is unchanged: still the highest severity across all URLs (worst-code wins), matching latprobe/Go and the 13 existing exit-code tests — a deliberate decision, since that scalar is a documented cross-implementation contract (hxprobe/USAGE.md's Exit codes table). The footer exists to give humans the per-URL breakdown the scalar can't show, not to change what gets returned
  • New _EXIT_LABELS (inverse of _PHASE_EXIT) for rendering exit codes as short labels (dns, tls, http, …) in the footer
  • Refactored the per-URL accumulation loop to compute one worst-code per URL first, then fold into the global worst — this also unified the two "running max" idioms (if c > worst: worst = c vs max(worst, ...)) that docs/explanations/2026-07-02-13-25-hxprobe-worst-exit-code-and-render-loop.md had flagged as a stylistic wrinkle into a single max(...) call
  • hxprobe/tests/test_cli.py: 3 new hermetic tests (TestCLIRunSummary) — multi-URL mixed outcome, multi-URL all-ok, and single-URL-has-no-footer; all 33 pre-existing tests pass unedited
  • Triggered by a follow-up question: does a single worst-code exit even make sense across multiple URLs with different error classes? Answer: keep the scalar (parity contract) but add the missing visibility as output, not by changing the exit code's semantics
  • hxprobe/USAGE.md: new "Multi-URL summary footer" section with real captured output; refreshed the "Multiple URLs" and -f multi-URL examples to show the footer (previously stale — captured before this feature existed); added a sentence to the Exit codes section
  • docs/usage/hxprobe.md: one-line mention pointing at the new section
  • Plan: docs/plans/2026-07-02-14-05-hxprobe-run-summary-footer.md
  • Summary: docs/summaries/2026-07-02-14-05-hxprobe-run-summary-footer.md

2026-07-02 12:05 — hxprobe simplification pass (no behavior change)

  • hxprobe/hxprobe/cli.py: deleted the _Parser/_ArgExit scaffolding (~32 lines) that hand-reimplemented what argparse.ArgumentParser already does (help→stdout, usage/errors→stderr, raise instead of hard-exit); replaced with a plain ArgumentParser wrapped in contextlib.redirect_stdout/redirect_stderr, catching SystemExit at one site
  • hxprobe/hxprobe/probe.py: trimmed _TimingStream.get_extra_info to the one branch (ssl_object) actually consumed on hxprobe's request path — the server_addr/client_addr branches were dead (only httpx's own CLI queries them); removed _dns_set/_connect_set/_tls_set from _Trace, redundant with the Phase.present flag already on self.dns/connect/tls
  • hxprobe/hxprobe/cli.py: _load_urls no longer takes only the first whitespace token per line ("forward-compatible with future key=value annotations" that never materialized); extracted _summarize_failures() to remove a duplicated dedup loop shared by _print_failure_summary and _build_json_entry
  • Triggered by a question about a dead file= parameter on _Parser.print_help/print_usage; verified no behavior changed — all 33 existing tests pass unedited, plus manual smoke tests of -h, no-args (stdout/stderr routing double-checked via separate file redirection), and a live verbose request
  • Plan: docs/plans/2026-07-02-12-05-hxprobe-simplification.md
  • Summary: docs/summaries/2026-07-02-12-05-hxprobe-simplification.md

2026-07-02 11:14 — hxprobe: read target URLs from a file (-f/--file)

  • hxprobe/hxprobe/cli.py: new -f/--file PATH flag reads URLs from a plain-text file (one per line, # comments, blank lines skipped, first whitespace-separated token per line) — same format as simple.py's load_sites(), reimplemented locally as _load_urls() rather than imported (hxprobe still imports nothing outside its own directory)
  • -f/--file is mutually exclusive with positional url args (both or neither given → usage error); urls positional changed from nargs="+" to nargs="*" to allow the file-only case
  • Missing file / unreadable file / empty file all produce a clear usage error (EXIT_USAGE) rather than a traceback
  • hxprobe/configs/*.txt: 7 new fixtures mirroring python/configs/'s set (all-ok, dns-failure, connection-refused, timeout, tls-errors, http-errors, mixed), each with a verified expected exit code in its header comment — actually run against hxprobe -f ... during implementation, not assumed from the simple.py originals (whose blanket 0/1 exit scheme differs from hxprobe's per-failure-class 06)
  • hxprobe/tests/test_cli.py: 5 new hermetic tests (TestCLIFileInput) — successful multi-URL read from a temp file, missing file, empty file, both-sources-given, and neither-given error paths
  • hxprobe/USAGE.md: new "Reading URLs from a file (-f)" section with real captured output, placed after "Multiple URLs"
  • Plan: docs/plans/2026-07-02-11-14-hxprobe-file-input.md
  • Summary: docs/summaries/2026-07-02-11-14-hxprobe-file-input.md

2026-07-02 10:22 — hxprobe usage reference doc + standalone Makefile

  • hxprobe/USAGE.md: new "Runnable Usage Reference" matching the depth of python/configs/usage-latprobe.md — 16 cases with real captured output (basic, verbose × HTTPS/plain-HTTP/redirect-followed/--no-follow- redirects/--no-http2/TLS-failure/DNS-failure, sampling, multi-URL, --fail, JSON, JSON+verbose, timeout, exit-codes table, Makefile shortcuts). Placed inside hxprobe/ itself (not python/configs/) so it travels with the project if extracted to its own repo
  • Timeout example uses 192.0.2.1 (RFC 5737 TEST-NET-1) instead of 10.255.255.1 — the latter resolves to an immediate "connection refused" in this dev sandbox rather than a genuine timeout; 192.0.2.1 reproduces a real ~500ms timeout reliably
  • docs/usage/hxprobe.md: added a one-line pointer to hxprobe/USAGE.md at the top; no other changes — it stays the CLAUDE.md-mandated summary doc
  • hxprobe/Makefile: new, fully standalone (help, deps, run, lint, fmt, test, test-integration, check, clean) — same auto-generated ## comment help style as the root Makefile, short target names (no hx- prefix needed inside hxprobe/'s own scope). Deliberately independent from the root Makefile's hx-* targets — neither calls into the other, per explicit user decision (avoids one Makefile's changes silently breaking the other, at the cost of some duplicated uv/pytest invocation logic)
  • No changes to the root Makefile — verified its hx-* section is byte-for-byte unchanged
  • Plan: docs/plans/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md
  • Summary: docs/summaries/2026-07-02-10-22-hxprobe-usage-doc-and-makefile.md

2026-07-02 09:57 — hxprobe toolchain modernization (uv, ruff, pytest)

  • Adopted uv for environment/dependency management: hxprobe/pyproject.toml gets a [dependency-groups] dev = ["pytest>=8.0", "ruff>=0.8"] section; hxprobe/.python-version pins Python 3.14; hxprobe/uv.lock (new, 18 packages resolved) pins every transitive dependency (httpx, httpcore, h2, certifi, etc.) for reproducible installs — previously nothing was pinned beyond httpx[http2]>=0.28
  • Added ruff for linting + formatting: [tool.ruff] config (target-version = "py311", line-length = 100); fixed the one real finding (import sys unused in cli.py, inherited from the original latprobe/cli.py); ran ruff format across the project (6 files reformatted — mostly collapsing the hand-aligned =/dict-key columns to single-space, no semantic changes; verified via full syntax check + test run before and after)
  • Swapped the test runner from stdlib unittest discover to pytest. Existing unittest.TestCase classes run unchanged (pytest is a superset runner) — no test-code rewrite. Replaced the test_[!i]*.py filename-glob hermetic/integration split with a proper pytest.mark.integration marker (pytestmark = pytest.mark.integration in tests/test_integration.py); registered in [tool.pytest.ini_options] to avoid unknown-marker warnings
  • Skipped mypy for now (type hints stay as documentation only) — explicit user decision, not an oversight
  • hxprobe/README.md: new — quick start (uv sync, uv run ...) for the standalone project, independent of the parent repo's docs
  • Makefile: hx-deps/hx-run/hx-test/hx-test-integration now shell out to uv sync/uv run instead of hand-managed python -m venv + pip install -e . (removed HX_VENV/HX_VENV_PYTHON vars — uv owns this now); added hx-lint/hx-fmt; hx-check now runs lint + hermetic tests (mirrors go-check's fmt+vet+test bundling, previously only ran tests)
  • docs/usage/hxprobe.md: updated Setup and Makefile-targets sections for the new toolchain
  • .gitignore: added .pytest_cache//.ruff_cache/
  • No behavior change to the probe/CLI itself — confirmed identical output before/after, python/latprobe untouched, zero latprobe imports remain in hxprobe/
  • Plan: docs/plans/2026-07-02-09-57-hxprobe-toolchain-modernization.md
  • Summary: docs/summaries/2026-07-02-09-57-hxprobe-toolchain-modernization.md

2026-07-02 09:32 — hxprobe extracted into a standalone top-level project

  • Moved hxprobe out of python/ into a new top-level hxprobe/ directory (sibling of go/ and python/), with its own pyproject.toml, its own venv (hxprobe/.venv), and its own hxprobe/tests/ — structured so it could be cp -r'd into a separate repo and work unchanged
  • Removed every from latprobe import ... in hxprobe/: probe.py now defines its own Options/Phase/Result/VerboseDetail/CertInfo dataclasses and its own _parse_cert/_parse_cert_date helpers (copied, not shared); aggregate.py/duration.py are verbatim copies (their imports were already package-relative, so no edits needed); cli.py is now a full standalone implementation (own exit codes, argparse, text/JSON rendering) instead of delegating to latprobe.cli.run() via an injected measure_fn
  • Reverted python/latprobe/{cli.py,probe.py} to their pre-hxprobe state (git checkout --) — the measure_fn/prog/description/protocol_flags injection points, Options.follow_redirects/http2, and VerboseDetail.http_version/redirect_count only existed to support the now-removed sharing; python/ is back to zero third-party dependencies, no pyproject.toml, no venv
  • Makefile: removed py-deps and the PY_VENV* variables; py-test/ py-check reverted to running directly against $(PYTHON); added a new standalone hx-deps/hx-run/hx-test/hx-test-integration/hx-check/ hx-clean section using HX_DIR/HX_VENV*; umbrella test/check/ clean now include the hxprobe targets alongside Go and Python (go-*/py-*/hx-*)
  • Tests moved and renamed to drop the now-redundant hx_/_hx segments: test_hx_probe.pyhxprobe/tests/test_probe.py, test_hx_cli.pyhxprobe/tests/test_cli.py, test_integration_hx.pyhxprobe/tests/test_integration.py (same test_[!i]*.py hermetic-vs-integration exclusion convention as latprobe)
  • docs/usage/py-hxprobe.md renamed to docs/usage/hxprobe.md and rewritten for the new standalone structure and Makefile targets; the TCP_NODELAY/ Nagle TTFB-accuracy finding is preserved
  • No behavior change — same CLI flags, same output, same six-phase timing, same HTTP/2/redirect handling as before; this was a pure decoupling/ restructuring
  • Plan: docs/plans/2026-07-02-09-32-hxprobe-standalone-project.md
  • Summary: docs/summaries/2026-07-02-09-32-hxprobe-standalone-project.md

2026-07-02 00:24 — hxprobe: httpx-based Python probe, Go-client parity

  • New sibling package python/hxprobe/, built on httpx instead of raw sockets, so the client matches Go's http.DefaultClient: HTTP/2 negotiated via ALPN, redirects followed by default, connection pooling, default TLS verification — while still reporting the full six-phase breakdown (DNS, TCP connect, TLS, TTFB, Transfer, Total)
  • hxprobe/probe.py: DNS/TCP connect/TLS timed by instrumenting a custom httpcore NetworkBackend/NetworkStream (_TimingBackend/_TimingStream); HTTP framing (HTTP/1.1 or HTTP/2), redirects, and keep-alive stay entirely owned by httpx; measure() returns the same latprobe.probe.Result dataclass, so aggregation/rendering are reused unchanged
  • latprobe/probe.py: added Options.follow_redirects/Options.http2 (ignored by the raw-socket measure()) and VerboseDetail.http_version/redirect_count (always ""/0 there)
  • latprobe/cli.py: run()/_run_samples() take an injectable measure_fn (default: the existing socket measure), plus prog/description/ protocol_flags overrides — hxprobe.cli.run() reuses the entire argparse, concurrency, exit-code, and text/JSON rendering pipeline unchanged
  • New CLI flags (hxprobe only, via protocol_flags=True): --no-http2, --no-follow-redirects
  • python/pyproject.toml: first third-party dependency in this repo (httpx[http2]); make py-deps provisions python/.venv
  • Verified experimentally that latprobe's raw socket — which never sets TCP_NODELAY — pays a real ~40-50ms Nagle/delayed-ACK penalty on its TTFB phase; hxprobe sets TCP_NODELAY (matching httpcore's default and Go's net.Dialer) and does not. Documented in docs/usage/py-hxprobe.md as a known divergence — hxprobe's TTFB is the more accurate of the two, not just different
  • 17 new hermetic tests (tests/test_hx_probe.py), 11 new hermetic CLI tests (tests/test_hx_cli.py), 9 new live integration tests (tests/test_integration_hx.py, excluded from the default gate)
  • Makefile: py-deps, hx-run, hx-test-integration; py-test/py-check now run via the venv and include the new hermetic hxprobe tests
  • User doc: docs/usage/py-hxprobe.md
  • Plan: docs/plans/2026-07-01-23-47-py-hxprobe-httpx.md

2026-07-01 12:55 — --verbose / -v flag for latprobe (Python)

  • -v/--verbose flag added to the latprobe CLI
  • probe.py: new CertInfo + VerboseDetail dataclasses; Options.verbose; Result.detail; TTFB loop now accumulates until \r\n\r\n (unchanged TTFB semantics — t_first_byte stamped on first recv(), not when headers complete); captures resolved IP (from getaddrinfo), TLS version/cipher/bits (from sock.version()/sock.cipher()), verified cert (from getpeercert()), and all response headers
  • cli.py: verbose text block appended after timing rows in all four output branches (single, aggregate, all-failed, mixed); _VERBOSE_HEADERS priority list controls which headers appear in text mode; JSON "verbose" object includes all parsed headers, full cert fields, TLS metadata; "verbose" key omitted when flag is absent
  • 9 new hermetic probe tests, 15 new CLI tests covering verbose text and JSON across success, connect-fail, and DNS-fail scenarios
  • 15 new integration tests for real TLS cert fields (CN, expiry, issuer), IP format, TLS version string, header presence, and verbose text/JSON output
  • python/configs/usage-latprobe.md: runnable reference with real output for all features including verbose and --verbose --json
  • docs/usage/py-latprobe.md: updated with --verbose flag and examples
  • Plan: docs/plans/2026-07-01-12-55-py-latprobe-verbose.md

2026-07-01 12:23 — Full latprobe Python package (Python, Step 3)

  • python/latprobe/ package: full port of the Go CLI, runnable as python -m latprobe
  • probe.py: raw-socket HTTP measurement with Options(timeout) parameter; mirrors phases.py technique; partial phases preserved on failure
  • aggregate.py: summarize(List[Result]) -> Aggregate with per-phase PhaseStats(min_ms, avg_ms, max_ms)
  • duration.py: parses Go-style duration strings (10s, 500ms, 2m, bare seconds)
  • cli.py: injectable run(args, stdout, stderr) -> int; argparse with injected streams (_Parser subclass); ThreadPoolExecutor concurrency across URLs; four text-rendering branches (single, aggregate, all-failed, mixed); JSON output; worst-exit-code accumulation
  • Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 (--fail)
  • python/tests/test_probe.py: 9 tests — success, 404, DNS fail, connect refused, TTFB timeout, bad scheme, partial phase invariants; uses http.server + daemon threads
  • python/tests/test_cli.py: 23 tests — drives cli.run() in-process; covers usage errors, single/aggregate text, failure exit codes, worst-code, --fail, JSON schema, JSON ordering, JSON error grouping
  • Makefile py-test: added PYTHONPATH=$(PY_DIR), removed || true
  • User doc: docs/usage/py-latprobe.md

2026-07-01 12:04 — Per-phase latency measurement (Python, Step 2)

  • python/phases.py: self-contained script; hand-drives raw sockets to time each HTTP phase individually — DNS (getaddrinfo), TCP connect, TLS handshake (ssl.wrap_socket, HTTPS only), TTFB (sendall → first recv), Transfer, Total
  • Partial phases preserved on failure (same invariant as Go's probe.go)
  • Error classification mirrors Go's priority: dns → timeout → tls → connect
  • Output format matches Go's single-sample text layout (14-char labels, separator, %8.2f ms alignment)
  • Input: bare URL args or plain-text config file (same format as simple.py)
  • Exits 0 if all URLs complete without network error; 1 if any failed
  • User doc: docs/usage/py-phases.md

2026-07-01 11:30 — Error-path example configs for simple.py (Python, Step 1 refinement)

  • python/configs/ directory with 7 purpose-built config files, one per error class: all-ok.txt, dns-failure.txt, connection-refused.txt, timeout.txt, tls-errors.txt (badssl.com), http-errors.txt (httpstat.us), mixed.txt
  • python/configs/usage.md: runnable shell commands + expected output for every config
  • Fixed docs/usage/py-simple.md: 4xx/5xx responses are reported as FAIL (not OK), because urlopen raises HTTPError for non-2xx; added pointer to the example configs

2026-07-01 10:39 — Simple reachability checker (Python, Step 1)

  • python/simple.py: reads a plain-text site list (one URL per line, # comments), issues a GET to each, prints aligned OK / FAIL + elapsed ms per site
  • Plain-text config format forward-compatible with future key=value annotations
  • Exits 0 if all sites responded, 1 if any failed or config is missing
  • python/sites.txt: committed example config
  • Makefile py-* targets added: py-simple-run, py-phases-run, py-run, py-test, py-check, py-clean; umbrella test, check, clean now include Python
  • User doc: docs/usage/py-simple.md

2026-07-01 01:23 — Concurrency (Go, Step 7)

  • Added -c/--concurrency flag: max URLs probed in parallel (0 = auto)
  • Default auto-concurrency: min(numURLs, 8) — scales with workload, caps at 8
  • Parallelism is across URLs only; the N samples of each URL stay sequential to preserve the accuracy of per-URL min/avg/max statistics
  • Output buffered and printed in original input order (deterministic for both text and JSON), exit-code accumulation unchanged
  • Implementation: semaphore channel + sync.WaitGroup, each goroutine writes only its own indexed result slot — verified clean with go test -race
  • Added make go-test-race target to the Makefile
  • 3 new tests: TestRunConcurrentOrder, TestConcurrentWorstCode, TestConcurrentJSONOrder
  • Updated runtime estimates in docs/custom-usage-examples.md
  • User doc: docs/usage/step-7-concurrency.md

2026-07-01 01:05 — Root Makefile

  • Namespaced targets: go-build, go-run, go-test, go-test-verbose, go-check, go-cover, go-fmt, go-vet, go-tidy, go-install, go-lint, go-clean
  • Umbrella targets (build, test, check, fmt, vet, clean, all) delegate to Go now; py-* will slot in during the Python port
  • help is the default target; auto-generated from ## comments
  • go-run accepts ARGS= for passing flags, go-lint guards for golangci-lint
  • .gitignore updated with coverage artifacts

2026-07-01 00:49 — Integration tests (Go, Step 6)

  • Extracted run(args, stdout, stderr) int from main() to make the CLI testable in-process
  • Fixed TLS classification bug: TLSHandshakeDone fires with the error on cert rejection, so tlsErr is now captured and checked before the stale tlsStart.IsZero() guard
  • run_test.go: 14 in-process tests covering exit codes 06, multi-URL, sampling, and JSON structure assertions
  • cli_test.go: TestMain builds the real binary; 3 subprocess smoke tests exercise the actual os.Exit path
  • All test servers use httptest + stdlib; .invalid TLD for deterministic DNS failures; no external network dependency

2026-07-01 00:38 — Failure handling (Go, Step 5)

  • Classified network failures into dns / connect / timeout / tls with distinct exit codes (25)
  • Partial timing preserved up to the failure point (e.g. DNS phase shown on NXDOMAIN)
  • --timeout flag (default 10s) applied via context.WithTimeout
  • --fail flag: HTTP status ≥ 400 → exit code 6 (curl-style)
  • -n sampling continues on network failure; aggregates successes, reports fail count + cause
  • JSON output extended with succeeded, failed, errors[] fields
  • Highest exit code across all URLs/failure types is used as the process exit

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
  • probe.Summarize aggregates a slice of Results into per-phase PhaseStats
  • Multi-sample output shows an aligned min/avg/max table with a header row
  • Single-sample output (n=1) is unchanged from Step 2

2026-07-01 00:08 — Per-phase breakdown (Go, Step 2)

  • Instrumented requests with net/http/httptrace.ClientTrace
  • DNS lookup, TCP connect, TLS handshake, Server/TTFB, Transfer, Total phases
  • TLS row omitted automatically for plain http:// URLs
  • Aligned text output with separator before Total

2026-07-01 00:08 — Simple total latency (Go, Step 1)

  • probe.Measure performs an HTTP GET and records wall-clock total time
  • main.go prints URL, HTTP status code, and total duration for each URL
  • Exits with code 1 if any URL fails
  • Multiple URLs accepted as positional arguments

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