feat(go): step 2 — per-phase request breakdown via httptrace

Instruments HTTP requests with net/http/httptrace.ClientTrace to capture
timestamps for DNS, TCP connect, TLS handshake, TTFB, and body transfer.
TLS row is omitted automatically for plain http:// URLs. Output is an
aligned text table with a separator before the Total row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:23:36 +02:00
parent a588eb3b0e
commit 0ecfb9bb1a
5 changed files with 184 additions and 10 deletions

View File

@@ -2,8 +2,11 @@
package probe
import (
"context"
"crypto/tls"
"io"
"net/http"
"net/http/httptrace"
"time"
)
@@ -30,14 +33,46 @@ type Result struct {
Err error
}
// Measure performs an HTTP GET to url and returns a Result.
// In Step 1 only the Total phase is populated.
// Measure performs an HTTP GET to url and returns a Result with all phases
// populated via net/http/httptrace.
func Measure(url string) Result {
r := Result{URL: url}
start := time.Now()
var (
dnsStart time.Time
dnsDone time.Time
connectStart time.Time
connectDone time.Time
tlsStart time.Time
tlsDone time.Time
wroteRequest time.Time
firstByte time.Time
)
resp, err := http.Get(url) //nolint:noctx
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
ConnectStart: func(_, _ string) {
if connectStart.IsZero() {
connectStart = time.Now()
}
},
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
WroteRequest: func(_ httptrace.WroteRequestInfo) { wroteRequest = time.Now() },
GotFirstResponseByte: func() { firstByte = time.Now() },
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
r.Err = err
return r
}
start := time.Now()
resp, err := http.DefaultClient.Do(req)
if err != nil {
r.Err = err
r.Total = Phase{Duration: time.Since(start), Present: true}
@@ -46,11 +81,30 @@ func Measure(url string) Result {
defer resp.Body.Close()
_, err = io.Copy(io.Discard, resp.Body)
end := time.Now()
r.Total = Phase{Duration: time.Since(start), Present: true}
r.StatusCode = resp.StatusCode
if err != nil {
r.Err = err
}
r.Total = Phase{Duration: end.Sub(start), Present: true}
if !dnsStart.IsZero() && !dnsDone.IsZero() {
r.DNS = Phase{Duration: dnsDone.Sub(dnsStart), Present: true}
}
if !connectStart.IsZero() && !connectDone.IsZero() {
r.Connect = Phase{Duration: connectDone.Sub(connectStart), Present: true}
}
if !tlsStart.IsZero() && !tlsDone.IsZero() {
r.TLS = Phase{Duration: tlsDone.Sub(tlsStart), Present: true}
}
if !wroteRequest.IsZero() && !firstByte.IsZero() {
r.TTFB = Phase{Duration: firstByte.Sub(wroteRequest), Present: true}
}
if !firstByte.IsZero() {
r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true}
}
return r
}