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>
This commit is contained in:
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal file
139
docs/plans/2026-07-01-12-23-py-latprobe.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
505
docs/plans/2026-07-01-12-55-py-latprobe-verbose.md
Normal file
505
docs/plans/2026-07-01-12-55-py-latprobe-verbose.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# Plan: `--verbose` / `-v` flag for `latprobe`
|
||||
|
||||
**Timestamp:** 2026-07-01 12:55
|
||||
**Status:** Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Add a `--verbose` / `-v` flag that surfaces diagnostic detail beyond timing:
|
||||
which IP was used, what TLS version and cipher was negotiated, certificate
|
||||
metadata (CN, expiry, issuer), and the most useful response headers. Useful
|
||||
for debugging *why* a probe failed, not just *that* it did.
|
||||
|
||||
---
|
||||
|
||||
## What verbose mode shows
|
||||
|
||||
### Text output — successful HTTPS request
|
||||
|
||||
```
|
||||
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
|
||||
IP : 93.184.216.34
|
||||
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
|
||||
Cert : CN=www.example.com valid until 2025-06-14 DigiCert Inc
|
||||
Server : ECS (dcb/7F84)
|
||||
Content-Type : text/html; charset=UTF-8
|
||||
```
|
||||
|
||||
### Text output — TLS failure (with diagnostic cert pass)
|
||||
|
||||
```
|
||||
https://expired.badssl.com/ (FAILED)
|
||||
DNS lookup : 15.39 ms
|
||||
TCP connect : 124.98 ms
|
||||
TLS handshake : 305.30 ms
|
||||
─────────────────────────────
|
||||
Total : 473.17 ms
|
||||
✗ tls: certificate verify failed: certificate has expired
|
||||
IP : 104.154.89.105
|
||||
Cert (unverified) : CN=*.badssl.com EXPIRED 2015-04-09 COMODO RSA Domain Validation
|
||||
```
|
||||
|
||||
### Text output — DNS failure (minimal; nothing to show after DNS)
|
||||
|
||||
```
|
||||
http://no.such.host.invalid (FAILED)
|
||||
Total : 0.65 ms
|
||||
✗ dns: [Errno 8] nodename nor servname provided, or not known
|
||||
```
|
||||
No verbose block — IP is unknown, no TLS, no headers.
|
||||
|
||||
### Text output — HTTP 4xx with redirect header
|
||||
|
||||
```
|
||||
https://www.google.com/this-page-does-not-exist (404)
|
||||
...
|
||||
Total : 145.50 ms
|
||||
IP : 142.251.36.4
|
||||
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
|
||||
Cert : CN=*.google.com valid until 2026-01-20 Google Trust Services
|
||||
Server : gws
|
||||
Content-Type : text/html; charset=UTF-8
|
||||
```
|
||||
|
||||
### Text output — 3xx redirect (Location shown)
|
||||
|
||||
```
|
||||
https://google.com (301)
|
||||
...
|
||||
IP : 142.251.36.4
|
||||
TLS : TLSv1.3 TLS_AES_128_GCM_SHA256 128 bit
|
||||
Cert : CN=*.google.com valid until 2026-01-20 Google Trust Services
|
||||
Location : https://www.google.com/
|
||||
Server : gws
|
||||
Content-Type : text/html; charset=UTF-8
|
||||
```
|
||||
|
||||
### Aggregate verbose (multi-sample `-n N`)
|
||||
|
||||
```
|
||||
https://example.com (200, 5 samples)
|
||||
min avg max
|
||||
...
|
||||
Total : 97.10 ms 101.20 ms 109.80 ms
|
||||
IP : 93.184.216.34
|
||||
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
|
||||
Cert : CN=www.example.com valid until 2025-06-14 DigiCert Inc
|
||||
Server : ECS (dcb/7F84)
|
||||
Content-Type : text/html; charset=UTF-8
|
||||
```
|
||||
Verbose block taken from the **last successful sample**. If DNS resolves to
|
||||
multiple IPs and different samples hit different addresses, they are listed as
|
||||
`IP : 93.184.216.34, 93.184.216.35` (deduped, insertion-ordered).
|
||||
|
||||
---
|
||||
|
||||
## Verbose block format rules
|
||||
|
||||
- Label column: **14 chars**, left-padded with spaces, matching the phase labels.
|
||||
- Separator: `" : "` (4 chars) — same as phase rows.
|
||||
- Value: free-form string, no fixed width.
|
||||
- Verbose block appears immediately after the last line of the standard block
|
||||
(after `Total` or after the `✗ …` error line).
|
||||
- Block is omitted entirely when there is nothing to show (e.g. pure DNS
|
||||
failure where IP is unknown).
|
||||
- No separator line before the verbose block — the visual break from `Total`
|
||||
and `✗` is enough.
|
||||
|
||||
Label strings (14 chars each):
|
||||
```
|
||||
"IP " # resolved IP address
|
||||
"TLS " # version + cipher + bits
|
||||
"Cert " # CN, expiry, issuer (verified)
|
||||
"Cert (unvrf.) " # same fields, cert not trusted (TLS failure)
|
||||
"Location " # for 3xx responses
|
||||
"Server " # Server response header
|
||||
"Content-Type " # Content-Type response header
|
||||
"X-Cache " # CDN cache status (if present)
|
||||
"Via " # proxy chain (if present)
|
||||
```
|
||||
|
||||
Additional headers shown only when present (see priority list in
|
||||
`_VERBOSE_HEADERS` below).
|
||||
|
||||
---
|
||||
|
||||
## JSON extension
|
||||
|
||||
When `--verbose --json` are both set, each entry gets a top-level `"verbose"`
|
||||
object (omitted when `--verbose` is absent):
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"status": 200,
|
||||
"succeeded": 1,
|
||||
"failed": 0,
|
||||
"phases": { ... },
|
||||
"verbose": {
|
||||
"ip": "93.184.216.34",
|
||||
"tls_version": "TLSv1.3",
|
||||
"tls_cipher": "TLS_AES_256_GCM_SHA384",
|
||||
"tls_bits": 256,
|
||||
"cert": {
|
||||
"cn": "www.example.com",
|
||||
"sans": ["www.example.com", "example.com"],
|
||||
"expiry": "2025-06-14",
|
||||
"issuer_cn": "DigiCert SHA2 Secure Server CA",
|
||||
"verified": true
|
||||
},
|
||||
"headers": {
|
||||
"Server": "ECS (dcb/7F84)",
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Cache": "HIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `"verbose"` is omitted when `--verbose` is not set.
|
||||
- `"cert"` is omitted for `http://` URLs (no TLS).
|
||||
- For TLS failures: `"cert"` contains what the diagnostic pass found, with
|
||||
`"verified": false`. If the diagnostic pass itself failed, `"cert"` is omitted.
|
||||
- `"headers"` contains **all** parsed response headers (not just the priority
|
||||
list used in text mode).
|
||||
- When `succeeded == 0` the `"verbose"` object may still contain `"ip"` if DNS
|
||||
resolved, but will lack `"tls_version"`, `"cert"`, and `"headers"`.
|
||||
|
||||
---
|
||||
|
||||
## Data model changes
|
||||
|
||||
### `probe.py` — new dataclasses
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CertInfo:
|
||||
cn: str = ""
|
||||
sans: list[str] = field(default_factory=list)
|
||||
expiry: str = "" # ISO date "YYYY-MM-DD"
|
||||
issuer_cn: str = ""
|
||||
verified: bool = False # True = TLS handshake passed verification
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerboseDetail:
|
||||
resolved_ip: str = ""
|
||||
tls_version: str = ""
|
||||
tls_cipher: str = ""
|
||||
tls_bits: int = 0
|
||||
cert: CertInfo | None = None
|
||||
headers: dict[str, str] = field(default_factory=dict) # all parsed headers
|
||||
```
|
||||
|
||||
### `probe.py` — `Options` change
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Options:
|
||||
timeout: float = 10.0
|
||||
verbose: bool = False # NEW
|
||||
```
|
||||
|
||||
### `probe.py` — `Result` change
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Result:
|
||||
...existing fields...
|
||||
detail: VerboseDetail | None = None # NEW; None when opts.verbose=False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Capture points in `probe.py`
|
||||
|
||||
### Resolved IP
|
||||
After `socket.getaddrinfo` succeeds:
|
||||
```python
|
||||
if opts.verbose:
|
||||
r.detail = VerboseDetail(resolved_ip=str(infos[0][4][0]))
|
||||
```
|
||||
`infos[0][4][0]` is the first resolved address string (works for both IPv4
|
||||
and IPv6 since `[4]` is the full sockaddr tuple and `[0]` is the address).
|
||||
|
||||
### TLS version, cipher, and certificate
|
||||
After `ctx.wrap_socket()` succeeds:
|
||||
```python
|
||||
if opts.verbose and r.detail:
|
||||
r.detail.tls_version = sock.version() or ""
|
||||
cipher_name, _, bits = sock.cipher()
|
||||
r.detail.tls_cipher = cipher_name or ""
|
||||
r.detail.tls_bits = bits or 0
|
||||
r.detail.cert = _parse_cert(sock.getpeercert(), verified=True)
|
||||
```
|
||||
`sock.getpeercert()` returns a dict with `subject`, `issuer`, `notAfter`,
|
||||
`subjectAltName` when called after a successful handshake.
|
||||
|
||||
### Response headers
|
||||
The current code reads the first non-empty chunk then drains the body.
|
||||
For verbose mode (and more correctly in general), the probe must accumulate
|
||||
bytes until `\r\n\r\n` is found before recording `t_first_byte`, so that the
|
||||
full header section is available.
|
||||
|
||||
Modified TTFB loop (replaces the current `while not first_chunk:` block):
|
||||
|
||||
```python
|
||||
buf = b""
|
||||
t_first_byte: float | None = None
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("server closed connection before headers complete")
|
||||
if t_first_byte is None:
|
||||
t_first_byte = time.perf_counter() # first byte — unchanged semantics
|
||||
buf += chunk
|
||||
|
||||
t_first_byte = t_first_byte or time.perf_counter()
|
||||
r.ttfb = _p(t_wrote, t_first_byte)
|
||||
r.status_code = _parse_status(buf)
|
||||
if opts.verbose and r.detail:
|
||||
r.detail.headers = _parse_response_headers(buf)
|
||||
```
|
||||
|
||||
TTFB semantics are **unchanged** — `t_first_byte` is captured on the first
|
||||
`recv()` call that returns data, not after all headers arrive.
|
||||
|
||||
The transfer drain loop needs to also drain `buf` bytes that follow
|
||||
`\r\n\r\n` (the body prefix already read into the buffer).
|
||||
|
||||
### Certificate inspection on TLS failure
|
||||
When `opts.verbose=True` and `r.fail_phase == "tls"`, call a helper that
|
||||
opens a second, non-verifying connection purely to fetch the certificate:
|
||||
|
||||
```python
|
||||
def _fetch_cert_unverified(
|
||||
host: str, port: int, addr, family: int, timeout: float
|
||||
) -> CertInfo | None:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
sock = socket.socket(family, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect(addr)
|
||||
ssl_sock = ctx.wrap_socket(sock, server_hostname=host)
|
||||
peer = ssl_sock.getpeercert()
|
||||
ssl_sock.close()
|
||||
return _parse_cert(peer, verified=False) if peer else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
```
|
||||
|
||||
This runs **after** `measure()` records timing, so it does not affect any
|
||||
phase durations. It adds a short additional wall-clock delay (one more TLS
|
||||
round trip) only in verbose mode on TLS failures — acceptable for a diagnostic
|
||||
path.
|
||||
|
||||
### Certificate parsing helper
|
||||
|
||||
```python
|
||||
from email.utils import parsedate # for "Jun 14 00:00:00 2025 GMT"
|
||||
import datetime
|
||||
|
||||
def _parse_cert(peer: dict, verified: bool) -> CertInfo:
|
||||
def _cn(rdns):
|
||||
for rdn in rdns:
|
||||
for k, v in rdn:
|
||||
if k == "commonName":
|
||||
return v
|
||||
return ""
|
||||
|
||||
sans = [v for k, v in peer.get("subjectAltName", ()) if k == "DNS"]
|
||||
expiry = ""
|
||||
not_after = peer.get("notAfter", "")
|
||||
if not_after:
|
||||
t = parsedate(not_after) # returns time.struct_time or None
|
||||
if t:
|
||||
expiry = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}"
|
||||
|
||||
return CertInfo(
|
||||
cn = _cn(peer.get("subject", ())),
|
||||
sans = sans,
|
||||
expiry = expiry,
|
||||
issuer_cn = _cn(peer.get("issuer", ())),
|
||||
verified = verified,
|
||||
)
|
||||
```
|
||||
|
||||
### Response header parsing helper
|
||||
|
||||
```python
|
||||
_VERBOSE_HEADERS = [
|
||||
"Location", "Server", "Content-Type", "X-Cache",
|
||||
"CF-Cache-Status", "Cache-Control", "Via",
|
||||
"X-Powered-By", "Strict-Transport-Security",
|
||||
]
|
||||
|
||||
def _parse_response_headers(buf: bytes) -> dict[str, str]:
|
||||
"""Parse all headers from a raw HTTP response buffer (up to \\r\\n\\r\\n)."""
|
||||
header_section = buf.split(b"\r\n\r\n", 1)[0]
|
||||
lines = header_section.decode("latin-1", errors="replace").splitlines()
|
||||
headers: dict[str, str] = {}
|
||||
for line in lines[1:]: # skip status line
|
||||
if ":" in line:
|
||||
name, _, value = line.partition(":")
|
||||
headers[name.strip()] = value.strip()
|
||||
return headers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `cli.py` changes
|
||||
|
||||
### New flag
|
||||
```python
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true",
|
||||
help="show resolved IP, TLS details, certificate, and response headers",
|
||||
)
|
||||
```
|
||||
Passed into `Options(verbose=ns.verbose)`.
|
||||
|
||||
### Text rendering — verbose block
|
||||
|
||||
```python
|
||||
def _print_verbose_block(detail: VerboseDetail, out: IO) -> None:
|
||||
rows: list[tuple[str, str]] = []
|
||||
|
||||
if detail.resolved_ip:
|
||||
rows.append(("IP ", detail.resolved_ip))
|
||||
|
||||
if detail.tls_version:
|
||||
tls_val = detail.tls_version
|
||||
if detail.tls_cipher:
|
||||
tls_val += f" {detail.tls_cipher}"
|
||||
if detail.tls_bits:
|
||||
tls_val += f" {detail.tls_bits} bit"
|
||||
rows.append(("TLS ", tls_val))
|
||||
|
||||
if detail.cert:
|
||||
c = detail.cert
|
||||
label = "Cert (unvrf.) " if not c.verified else "Cert "
|
||||
parts = [f"CN={c.cn}"] if c.cn else []
|
||||
if c.expiry:
|
||||
tag = "EXPIRED" if _cert_expired(c.expiry) else "valid until"
|
||||
parts.append(f"{tag} {c.expiry}")
|
||||
if c.issuer_cn:
|
||||
parts.append(c.issuer_cn)
|
||||
rows.append((label, " ".join(parts)))
|
||||
|
||||
# Response headers — show priority list, in order, if present
|
||||
for name in _VERBOSE_HEADERS:
|
||||
val = detail.headers.get(name)
|
||||
if val:
|
||||
label = f"{name:<14}"
|
||||
rows.append((label, val))
|
||||
|
||||
for label, value in rows:
|
||||
out.write(f" {label} : {value}\n")
|
||||
```
|
||||
|
||||
`_cert_expired(expiry: str) -> bool` compares the ISO date string against
|
||||
today's date using `datetime.date.fromisoformat`.
|
||||
|
||||
Call site: `_print_verbose_block` is called from `_print_single`,
|
||||
`_print_all_failed`, and `_print_aggregate` — after the last line written by
|
||||
each function — but only when `result.detail` (or `agg`'s last-sample detail)
|
||||
is not `None`.
|
||||
|
||||
For aggregate rendering: collect `detail` from the last successful result
|
||||
before calling `summarize`. Pass it alongside `agg` to `_print_aggregate`.
|
||||
|
||||
### JSON extension
|
||||
|
||||
In `_build_json_entry`, when `detail` is not `None`:
|
||||
```python
|
||||
v: dict = {}
|
||||
if detail.resolved_ip:
|
||||
v["ip"] = detail.resolved_ip
|
||||
if detail.tls_version:
|
||||
v["tls_version"] = detail.tls_version
|
||||
v["tls_cipher"] = detail.tls_cipher
|
||||
v["tls_bits"] = detail.tls_bits
|
||||
if detail.cert:
|
||||
c = detail.cert
|
||||
v["cert"] = {
|
||||
"cn": c.cn,
|
||||
"sans": c.sans,
|
||||
"expiry": c.expiry,
|
||||
"issuer_cn": c.issuer_cn,
|
||||
"verified": c.verified,
|
||||
}
|
||||
if detail.headers:
|
||||
v["headers"] = dict(detail.headers)
|
||||
if v:
|
||||
entry["verbose"] = v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `python/latprobe/probe.py` | `CertInfo`, `VerboseDetail` dataclasses; `Options.verbose`; `Result.detail`; capture points for IP, TLS, cert, headers; `_fetch_cert_unverified`; `_parse_cert`; `_parse_response_headers`; modified TTFB loop |
|
||||
| `python/latprobe/cli.py` | `-v`/`--verbose` flag; `_print_verbose_block`; verbose call sites in all 3 print functions; `_build_json_entry` extension; `_cert_expired` helper |
|
||||
| `python/tests/test_probe.py` | Tests for verbose fields on success (IP, TLS, cert, headers), on TLS failure (unverified cert), and that non-verbose leaves `detail=None` |
|
||||
| `python/tests/test_cli.py` | Tests for `--verbose` text layout (IP/TLS/cert/header rows), `--verbose --json` schema, verbose absent when flag not set |
|
||||
| `python/tests/test_integration.py` | Tests for real site verbose output (expiry date format, header values, actual IPs), TLS failure cert inspection against badssl.com |
|
||||
| `docs/usage/py-latprobe.md` | New `--verbose` section with example output |
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **`probe.py` — data model**: add `CertInfo`, `VerboseDetail`, `Options.verbose`,
|
||||
`Result.detail`. No behaviour change yet — all `None` by default.
|
||||
2. **`probe.py` — capture IP**: set `r.detail.resolved_ip` after `getaddrinfo`.
|
||||
Easiest capture; good checkpoint.
|
||||
3. **`probe.py` — modify TTFB loop**: accumulate to `\r\n\r\n`, update body drain.
|
||||
This is the riskiest change (touches the hot path); verify existing tests
|
||||
still pass before continuing.
|
||||
4. **`probe.py` — parse headers**: add `_parse_response_headers`, `_VERBOSE_HEADERS`;
|
||||
populate `r.detail.headers` when verbose.
|
||||
5. **`probe.py` — TLS details on success**: add `_parse_cert`; populate
|
||||
`tls_version`, `tls_cipher`, `tls_bits`, `cert` after `wrap_socket`.
|
||||
6. **`probe.py` — TLS failure cert**: add `_fetch_cert_unverified`; call it at the
|
||||
end of `measure` when `opts.verbose and r.fail_phase == "tls"`.
|
||||
7. **`cli.py` — flag + text rendering**: add `-v`, `_print_verbose_block`,
|
||||
`_cert_expired`; wire into all print functions.
|
||||
8. **`cli.py` — JSON**: extend `_build_json_entry`.
|
||||
9. **Tests**: hermetic tests for each capture point; CLI text/JSON tests;
|
||||
integration tests against real sites.
|
||||
10. **Docs**: update `docs/usage/py-latprobe.md`.
|
||||
|
||||
Steps 1–2 and 7 can be skipped ahead and demonstrated early (IP line shows up
|
||||
immediately); steps 3–6 build the richer detail progressively.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations / out of scope
|
||||
|
||||
- **Aggregate verbose from last sample only** — no per-sample IP list unless
|
||||
sampling actually returned multiple distinct IPs (rare; only with round-robin
|
||||
DNS between samples).
|
||||
- **DNS timeout not controllable** — `getaddrinfo` does not accept a Python
|
||||
timeout; the `--timeout` flag applies only from TCP connect onward. Already
|
||||
documented in existing usage doc.
|
||||
- **No redirect following** — 3xx responses show the `Location` header in the
|
||||
verbose block but do not probe the target. Consistent with non-verbose
|
||||
behaviour.
|
||||
- **Diagnostic cert pass adds latency** — only on TLS failures in verbose mode;
|
||||
documented inline in output (future: could suppress with a flag).
|
||||
- **HTTP/2, HTTP/3 not supported** — raw socket drives HTTP/1.1 only; TLS
|
||||
ALPN negotiation may result in some servers rejecting the connection. Out of
|
||||
scope for this tool.
|
||||
294
docs/usage/py-latprobe.md
Normal file
294
docs/usage/py-latprobe.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# `latprobe/` — Full Python Port
|
||||
|
||||
## What it does
|
||||
|
||||
A packaged Python CLI that mirrors the Go `latprobe` tool: per-phase HTTP
|
||||
latency measurement (DNS, TCP connect, TLS, TTFB, Transfer, Total) with
|
||||
configurable sampling, cross-URL concurrency, JSON output, and distinct exit
|
||||
codes per failure class.
|
||||
|
||||
Run it from the `python/` directory with `python -m latprobe`.
|
||||
|
||||
Phases measured:
|
||||
|
||||
| Phase | What is timed |
|
||||
|-------|---------------|
|
||||
| DNS lookup | `socket.getaddrinfo()` — hostname resolution |
|
||||
| TCP connect | `sock.connect()` — SYN to connection established |
|
||||
| TLS handshake | `ssl.wrap_socket()` — full handshake (HTTPS only) |
|
||||
| Server (TTFB) | `sendall()` return → first `recv()` byte |
|
||||
| Transfer | First byte → EOF — body download time |
|
||||
| Total | DNS start → body EOF — wall-clock end-to-end |
|
||||
|
||||
## Flags / arguments
|
||||
|
||||
```
|
||||
python -m latprobe [flags] <url> [url ...]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `url …` (positional) | required | One or more URLs to probe |
|
||||
| `-n N`, `--count N` | `1` | Number of requests per URL |
|
||||
| `-c N`, `--concurrency N` | `0` (auto) | Max parallel URLs; `0` = `min(len(urls), 8)` |
|
||||
| `--timeout DURATION` | `10s` | Per-request timeout; supports `ms`, `s`, `m`, or bare seconds |
|
||||
| `--fail` | off | Exit non-zero when any HTTP status ≥ 400 |
|
||||
| `--json` | off | Output as JSON array instead of text |
|
||||
| `-v`, `--verbose` | off | Show resolved IP, TLS version/cipher, certificate details, and response headers |
|
||||
| `-h`, `--help` | — | Show help and exit 0 |
|
||||
|
||||
**Exit codes:**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All probes succeeded |
|
||||
| 1 | Usage / argument error |
|
||||
| 2 | DNS failure |
|
||||
| 3 | TCP connect failure |
|
||||
| 4 | Timeout |
|
||||
| 5 | TLS error |
|
||||
| 6 | HTTP status ≥ 400 (`--fail` only) |
|
||||
|
||||
The highest exit code across all URLs is used as the process exit.
|
||||
|
||||
## Examples
|
||||
|
||||
### Single URL
|
||||
```sh
|
||||
python -m latprobe https://example.com
|
||||
```
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Sampling (`-n`) — shows min/avg/max table
|
||||
```sh
|
||||
python -m latprobe -n 5 https://example.com
|
||||
```
|
||||
```
|
||||
https://example.com (200, 5 samples)
|
||||
min avg max
|
||||
DNS lookup : 1.80 ms 3.14 ms 5.00 ms
|
||||
TCP connect : 9.50 ms 10.25 ms 11.40 ms
|
||||
TLS handshake : 13.80 ms 15.20 ms 18.90 ms
|
||||
Server (TTFB) : 62.00 ms 66.50 ms 71.30 ms
|
||||
Transfer : 0.10 ms 0.25 ms 0.40 ms
|
||||
─────────────────────────────────────────────────
|
||||
Total : 97.10 ms 101.20 ms 109.80 ms
|
||||
```
|
||||
|
||||
### Multiple URLs (probed in parallel)
|
||||
```sh
|
||||
python -m latprobe https://example.com https://www.google.com
|
||||
```
|
||||
|
||||
Output for each URL is separated by a blank line. Exit code = worst across all.
|
||||
|
||||
### JSON output
|
||||
```sh
|
||||
python -m latprobe --json -n 3 https://example.com
|
||||
```
|
||||
```json
|
||||
[
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"status": 200,
|
||||
"succeeded": 3,
|
||||
"failed": 0,
|
||||
"phases": {
|
||||
"dns": {"min_ms": 1.80, "avg_ms": 3.14, "max_ms": 5.00},
|
||||
"connect": {"min_ms": 9.50, "avg_ms": 10.25, "max_ms": 11.40},
|
||||
"tls": {"min_ms": 13.80, "avg_ms": 15.20, "max_ms": 18.90},
|
||||
"ttfb": {"min_ms": 62.00, "avg_ms": 66.50, "max_ms": 71.30},
|
||||
"transfer": {"min_ms": 0.10, "avg_ms": 0.25, "max_ms": 0.40},
|
||||
"total": {"min_ms": 97.10, "avg_ms": 101.20, "max_ms": 109.80}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`phases` is omitted when all samples failed. `tls` key is omitted for `http://`
|
||||
URLs. `errors` is included (and `phases` omitted) when some samples fail.
|
||||
|
||||
### Verbose mode (`-v` / `--verbose`)
|
||||
|
||||
Shows the resolved IP address, TLS version/cipher/bits, certificate details
|
||||
(CN, expiry, issuer), and useful response headers. Appended to the standard
|
||||
timing block.
|
||||
|
||||
```sh
|
||||
python -m latprobe --verbose https://example.com
|
||||
```
|
||||
```
|
||||
https://example.com (200)
|
||||
DNS lookup : 16.47 ms
|
||||
TCP connect : 9.76 ms
|
||||
TLS handshake : 13.09 ms
|
||||
Server (TTFB) : 60.31 ms
|
||||
Transfer : 0.20 ms
|
||||
─────────────────────────────
|
||||
Total : 112.76 ms
|
||||
IP : 104.20.23.154
|
||||
TLS : TLSv1.3 TLS_AES_256_GCM_SHA384 256 bit
|
||||
Cert : CN=example.com valid until 2026-08-29 SSL Corporation
|
||||
Server : cloudflare
|
||||
Content-Type : text/html
|
||||
```
|
||||
|
||||
For **TLS failures**, the verbose block still shows the IP (DNS + TCP
|
||||
succeeded) so you can tell which server you actually reached:
|
||||
|
||||
```sh
|
||||
python -m latprobe --verbose https://expired.badssl.com/
|
||||
```
|
||||
```
|
||||
https://expired.badssl.com/ (FAILED)
|
||||
DNS lookup : 30.98 ms
|
||||
TCP connect : 126.58 ms
|
||||
TLS handshake : 293.58 ms
|
||||
─────────────────────────────
|
||||
Total : 463.37 ms
|
||||
✗ tls: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
|
||||
IP : 104.154.89.105
|
||||
```
|
||||
|
||||
For **DNS failures**, the verbose block is empty (IP unknown), so it is not
|
||||
printed.
|
||||
|
||||
Combined with `--json`, verbose detail appears in a `"verbose"` object:
|
||||
|
||||
```sh
|
||||
python -m latprobe --verbose --json https://example.com
|
||||
```
|
||||
```json
|
||||
[
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"status": 200,
|
||||
"succeeded": 1,
|
||||
"failed": 0,
|
||||
"phases": { ... },
|
||||
"verbose": {
|
||||
"ip": "104.20.23.154",
|
||||
"tls_version": "TLSv1.3",
|
||||
"tls_cipher": "TLS_AES_256_GCM_SHA384",
|
||||
"tls_bits": 256,
|
||||
"cert": {
|
||||
"cn": "example.com",
|
||||
"sans": ["example.com", "*.example.com"],
|
||||
"expiry": "2026-08-29",
|
||||
"issuer_cn": "SSL Corporation",
|
||||
"verified": true
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "text/html",
|
||||
"Server": "cloudflare",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `"verbose"` key is omitted when `--verbose` is not set. The `"cert"` key
|
||||
is omitted for `http://` URLs and when TLS fails (Python's stdlib does not
|
||||
expose parsed cert data from a failed handshake). `"headers"` contains **all**
|
||||
parsed response headers (the text block shows a curated priority list).
|
||||
|
||||
### DNS failure
|
||||
```sh
|
||||
python -m latprobe http://no.such.host.invalid
|
||||
echo "exit: $?"
|
||||
```
|
||||
```
|
||||
http://no.such.host.invalid (FAILED)
|
||||
Total : 0.65 ms
|
||||
✗ dns: [Errno 8] nodename nor servname provided, or not known
|
||||
exit: 2
|
||||
```
|
||||
|
||||
### `--fail` flag (exit non-zero on HTTP 4xx/5xx)
|
||||
```sh
|
||||
python -m latprobe --fail https://www.google.com/this-page-does-not-exist-at-all
|
||||
echo "exit: $?"
|
||||
```
|
||||
```
|
||||
https://www.google.com/this-page-does-not-exist-at-all (404 ✗)
|
||||
DNS lookup : 3.10 ms
|
||||
TCP connect : 9.60 ms
|
||||
TLS handshake : 14.20 ms
|
||||
Server (TTFB) : 118.50 ms
|
||||
Transfer : 0.12 ms
|
||||
─────────────────────────────
|
||||
Total : 145.50 ms
|
||||
exit: 6
|
||||
```
|
||||
|
||||
### Mixed — some URLs succeed, some fail
|
||||
```sh
|
||||
python -m latprobe https://example.com http://no.such.host.invalid
|
||||
echo "exit: $?"
|
||||
```
|
||||
```
|
||||
https://example.com (200)
|
||||
...
|
||||
Total : 118.39 ms
|
||||
|
||||
http://no.such.host.invalid (FAILED)
|
||||
Total : 0.65 ms
|
||||
✗ dns: ...
|
||||
exit: 2
|
||||
```
|
||||
|
||||
### Timeout
|
||||
```sh
|
||||
python -m latprobe --timeout 500ms http://10.255.255.1/
|
||||
echo "exit: $?"
|
||||
```
|
||||
```
|
||||
http://10.255.255.1/ (FAILED)
|
||||
DNS lookup : 0.20 ms
|
||||
TCP connect : 500.18 ms
|
||||
─────────────────────────────
|
||||
Total : 500.40 ms
|
||||
✗ timeout: timed out
|
||||
exit: 4
|
||||
```
|
||||
|
||||
## Makefile targets
|
||||
|
||||
```sh
|
||||
make py-run ARGS="-n 3 https://example.com" # run the package
|
||||
make py-test # run test suite
|
||||
make py-check # alias for py-test
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **DNS timeout is OS-controlled.** Python's `socket.getaddrinfo` does not
|
||||
accept a timeout parameter. The `--timeout` flag applies to TCP connect and
|
||||
subsequent phases. DNS failures from an unreachable or NXDOMAIN host still
|
||||
happen quickly in practice.
|
||||
- **HTTP 1.1 + `Connection: close` only.** No keep-alive, HTTP/2, auth, custom
|
||||
headers, or redirect following. HTTP 3xx is shown with its raw status code.
|
||||
- **Body fully drained.** Transfer time is real download time; large bodies
|
||||
affect the Transfer and Total phases.
|
||||
|
||||
## Comparison with `simple.py` and `phases.py`
|
||||
|
||||
| | `simple.py` | `phases.py` | `latprobe/` |
|
||||
|--|-------------|-------------|-------------|
|
||||
| Phases | total only | all phases | all phases |
|
||||
| Sampling | no | no | `-n` flag |
|
||||
| Concurrency | no | no | `-c` flag |
|
||||
| JSON | no | no | `--json` flag |
|
||||
| `--fail` | implicit (urlopen raises on 4xx) | no | `--fail` flag |
|
||||
| Exit codes | 0 or 1 | 0 or 1 | 0–6 per failure class |
|
||||
| Config file | yes | yes | no (URL args only) |
|
||||
Reference in New Issue
Block a user