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>
111 lines
2.9 KiB
Go
111 lines
2.9 KiB
Go
// Package probe measures per-phase HTTP request latency.
|
|
package probe
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptrace"
|
|
"time"
|
|
)
|
|
|
|
// Phase holds the measured duration of a single request phase.
|
|
type Phase struct {
|
|
Duration time.Duration
|
|
// Present is false when the phase was skipped (e.g. no TLS for http://).
|
|
Present bool
|
|
}
|
|
|
|
// Result holds all timing phases for a single HTTP request.
|
|
type Result struct {
|
|
URL string
|
|
DNS Phase
|
|
Connect Phase
|
|
TLS Phase
|
|
TTFB Phase // server processing: WroteRequest → GotFirstResponseByte
|
|
Transfer Phase // body read: GotFirstResponseByte → body closed
|
|
Total Phase
|
|
|
|
// StatusCode is the HTTP response status code (0 on error).
|
|
StatusCode int
|
|
// Err is non-nil if the request failed.
|
|
Err error
|
|
}
|
|
|
|
// 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}
|
|
|
|
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
|
|
)
|
|
|
|
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}
|
|
return r
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
end := time.Now()
|
|
|
|
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
|
|
}
|