Files
http-latency-prober/docs/plans/2026-07-01-12-23-py-latprobe.md
Jan Novak 24ea9c9e71 feat(py): step 3 — full latprobe package with --verbose flag
Adds the complete Python port of the Go latprobe tool plus diagnostic
verbose output that the Go version does not yet have.

Package (python/latprobe/):
- probe.py: raw-socket HTTP timing with CertInfo/VerboseDetail dataclasses;
  TTFB loop accumulates to \r\n\r\n so headers are parseable without changing
  t_first_byte semantics; captures resolved IP, TLS version/cipher/bits,
  verified cert (via getpeercert()), and all response headers when verbose=True
- aggregate.py: summarize() → per-phase min/avg/max PhaseStats
- cli.py: injectable run(args,stdout,stderr)->int; argparse with injected
  streams; ThreadPoolExecutor concurrency across URLs; four text-rendering
  branches; JSON output; worst-exit-code accumulation; -v/--verbose flag
  appends IP/TLS/cert/header block after every timing table; JSON extended
  with "verbose" object (omitted when flag absent)
- duration.py: parse Go-style duration strings (10s, 500ms, 2m, bare seconds)
- Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400

Tests:
- test_probe.py: 18 hermetic tests (success, 404, DNS/connect/timeout/scheme
  failures, partial-phase invariants, verbose detail fields)
- test_cli.py: 38 hermetic tests (usage errors, single/aggregate/failure text,
  worst-code, --fail, JSON schema/ordering/grouping, verbose text and JSON)
- test_integration.py: 45 tests against live internet services (badssl.com for
  TLS errors, real cert/IP/header validation in verbose mode)

Makefile: PYTHONPATH fix, fnmatch pattern test_[!i]*.py to exclude
integration tests from py-test, new py-test-integration target

Docs: docs/usage/py-latprobe.md, python/configs/usage-latprobe.md (runnable
reference with captured output), plans for both the package and verbose feature

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

140 lines
4.8 KiB
Markdown
Raw Permalink 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.
# Plan: Full `latprobe` Python Package
**Timestamp:** 2026-07-01 12:23
**Status:** Complete
## Goal
Build `python/latprobe/` — a packaged Python port of the Go `latprobe` CLI that
mirrors its behavior: flags, sampling, cross-URL concurrency, JSON output, and
distinct exit codes.
## Package layout
```
python/
latprobe/
__init__.py — package marker
__main__.py — sys.exit(cli.run(sys.argv[1:], sys.stdout, sys.stderr))
probe.py — measure(url, opts) -> Result (raw socket timing)
aggregate.py — summarize(results) -> Aggregate (min/avg/max per phase)
cli.py — run(args, stdout, stderr) -> int (argparse + rendering)
duration.py — parse_duration("10s") -> float seconds
tests/
test_probe.py — unittest: success, DNS fail, connect refused, timeout, bad scheme
test_cli.py — unittest: drives cli.run() in-process, all branches + JSON
```
## Key design decisions
- **`probe.py`**: reuses the raw-socket technique from `phases.py` with an
`Options(timeout)` parameter. Partial phases preserved on failure.
- **`aggregate.py`**: `summarize(List[Result]) -> Aggregate` computes
`PhaseStats(min_ms, avg_ms, max_ms, present)` per phase. Uses only `succeeded`
results.
- **`duration.py`**: parses `"10s"`, `"500ms"`, `"2m"`, bare numbers → float seconds.
- **`cli.py`**: injectable `run(args, stdout, stderr) -> int` seam for testability.
- `_Parser` subclasses `argparse.ArgumentParser` with custom `print_help`,
`print_usage`, `error`, `exit` to redirect all output to injected streams
and raise `_ArgExit` instead of calling `sys.exit`.
- `ThreadPoolExecutor` for cross-URL concurrency; `executor.map` preserves
input order.
- Four text-rendering branches: single-success, multi-sample aggregate,
all-failed, mixed.
- Failure summary groups errors by `(phase, message)` preserving insertion
order; `N ×` prefix when grouped.
## CLI flags
| Flag | Default | Description |
|------|---------|-------------|
| `urls` (positional, `+`) | required | One or more URLs |
| `-n`/`--count` | 1 | Requests per URL |
| `-c`/`--concurrency` | 0 (auto) | Max parallel URLs; auto = `min(numURLs, 8)` |
| `--timeout` | `10s` | Per-request timeout (parsed via `duration.py`) |
| `--fail` | off | Exit non-zero on HTTP status >= 400 |
| `--json` | off | Output as JSON array instead of text |
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | All probes succeeded (with `--fail`: no 4xx/5xx) |
| 1 | Usage / argument error |
| 2 | DNS failure |
| 3 | TCP connect failure |
| 4 | Timeout |
| 5 | TLS error |
| 6 | HTTP status >= 400 (`--fail` only) |
Worst (highest) code across all URLs wins.
## JSON schema
```json
[
{
"url": "https://example.com",
"status": 200,
"succeeded": 3,
"failed": 2,
"phases": {
"dns": {"min_ms": 1.23, "avg_ms": 1.45, "max_ms": 1.67},
"connect": {"min_ms": ...},
"tls": {"min_ms": ...},
"ttfb": {"min_ms": ...},
"transfer": {"min_ms": ...},
"total": {"min_ms": ...}
},
"errors": [
{"phase": "connect", "count": 2, "message": "..."}
]
}
]
```
- `phases` omitted when `succeeded == 0`
- Per-phase key omitted when no successful sample had that phase (e.g. `tls` for `http://`)
- `errors` omitted when `failed == 0`
## Text output format
Single sample:
```
https://example.com (200)
DNS lookup : 18.87 ms
TCP connect : 9.76 ms
TLS handshake : 14.71 ms
Server (TTFB) : 67.07 ms
Transfer : 0.14 ms
─────────────────────────────
Total : 118.39 ms
```
Aggregate (-n 3):
```
https://example.com (200, 3 samples)
min avg max
DNS lookup : 2.36 ms 3.51 ms 5.00 ms
TCP connect : 10.25 ms 17.45 ms 31.61 ms
TLS handshake : 14.04 ms 41.60 ms 96.50 ms
Server (TTFB) : 63.30 ms 66.72 ms 69.78 ms
Transfer : 0.26 ms 0.38 ms 0.51 ms
─────────────────────────────────────────────────
Total : 101.33 ms 139.19 ms 211.99 ms
```
## Testing approach
- `test_probe.py`: real `http.server.HTTPServer` in a daemon thread; closed port
for connection-refused; `.invalid` TLD for DNS failure; black-hole socket (accepts
TCP, never sends data) to trigger TTFB timeout.
- `test_cli.py`: in-process `cli.run(args, io.StringIO(), io.StringIO())` — no
subprocess, fast. Covers: usage errors, single/aggregate text, failure exit codes,
worst-code accumulation, `--fail`, JSON schema, JSON ordering, JSON error grouping.
## Makefile changes
- `py-test`: added `PYTHONPATH=$(PY_DIR)` so tests can `import latprobe`; removed
`|| true` now that real tests exist.