// Package probe measures per-phase HTTP request latency. package probe import ( "context" "crypto/tls" "errors" "io" "net" "net/http" "net/http/httptrace" "time" ) // Options configures a single Measure call. type Options struct { Timeout time.Duration // 0 = no timeout } // 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://) // or did not complete before a failure. 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 network error). StatusCode int // FailPhase is the phase where the request broke: "dns", "connect", // "timeout", "tls", "transfer", or "request" (bad URL). Empty on success. FailPhase string // Err is non-nil if the request failed. Err error } // Measure performs an HTTP GET to url and returns a Result with all completed // phases populated. Partial phases are preserved when the request fails. func Measure(url string, opts Options) 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 dnsErr error ) trace := &httptrace.ClientTrace{ DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() }, DNSDone: func(info httptrace.DNSDoneInfo) { dnsDone = time.Now() dnsErr = info.Err }, 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) if opts.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.Timeout) defer cancel() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { r.Err = err r.FailPhase = "request" return r } start := time.Now() resp, err := http.DefaultClient.Do(req) if err != nil { end := time.Now() r.Err = err r.FailPhase = classifyErr(err, dnsErr, tlsStart, tlsDone) r.Total = Phase{Duration: end.Sub(start), Present: true} r.DNS = makePhase(dnsStart, dnsDone) r.Connect = makePhase(connectStart, connectDone) r.TLS = makePhase(tlsStart, tlsDone) 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.FailPhase = "transfer" } r.Total = Phase{Duration: end.Sub(start), Present: true} r.DNS = makePhase(dnsStart, dnsDone) r.Connect = makePhase(connectStart, connectDone) r.TLS = makePhase(tlsStart, tlsDone) r.TTFB = makePhase(wroteRequest, firstByte) if !firstByte.IsZero() { r.Transfer = Phase{Duration: end.Sub(firstByte), Present: true} } return r } func makePhase(start, end time.Time) Phase { if start.IsZero() || end.IsZero() { return Phase{} } return Phase{Duration: end.Sub(start), Present: true} } func classifyErr(err, dnsErr error, tlsStart, tlsDone time.Time) string { if dnsErr != nil { return "dns" } var dnsError *net.DNSError if errors.As(err, &dnsError) { return "dns" } if errors.Is(err, context.DeadlineExceeded) { return "timeout" } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return "timeout" } // TLS started but handshake never completed if !tlsStart.IsZero() && tlsDone.IsZero() { return "tls" } return "connect" }