Files
http-latency-prober/docs/plans/2026-07-01-12-55-py-latprobe-verbose.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

506 lines
17 KiB
Markdown
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.
# 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 12 and 7 can be skipped ahead and demonstrated early (IP line shows up
immediately); steps 36 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.