http.go

  1package log
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"io"
  7	"log/slog"
  8	"net/http"
  9	"strings"
 10	"time"
 11)
 12
 13// NewHTTPClient creates an HTTP client with debug logging enabled when debug mode is on.
 14func NewHTTPClient() *http.Client {
 15	return &http.Client{
 16		Transport: &HTTPRoundTripLogger{
 17			Transport: http.DefaultTransport,
 18		},
 19	}
 20}
 21
 22// HTTPRoundTripLogger is an http.RoundTripper that logs requests and responses.
 23type HTTPRoundTripLogger struct {
 24	Transport http.RoundTripper
 25}
 26
 27// RoundTrip implements http.RoundTripper interface with logging.
 28func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, error) {
 29	var err error
 30	var save io.ReadCloser
 31	save, req.Body, err = drainBody(req.Body)
 32	if err != nil {
 33		slog.Error(
 34			"HTTP request failed",
 35			"method", req.Method,
 36			"url", req.URL,
 37			"error", err,
 38		)
 39		return nil, err
 40	}
 41
 42	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
 43		slog.Debug(
 44			"HTTP Request",
 45			"method", req.Method,
 46			"url", req.URL,
 47			"body", bodyToString(save),
 48		)
 49	}
 50
 51	start := time.Now()
 52	resp, err := h.Transport.RoundTrip(req)
 53	duration := time.Since(start)
 54	if err != nil {
 55		slog.Error(
 56			"HTTP request failed",
 57			"method", req.Method,
 58			"url", req.URL,
 59			"duration_ms", duration.Milliseconds(),
 60			"error", err,
 61		)
 62		return resp, err
 63	}
 64
 65	save, resp.Body, err = drainBody(resp.Body)
 66	if err != nil {
 67		slog.Error("Failed to drain response body", "error", err)
 68		return resp, err
 69	}
 70	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
 71		slog.Debug(
 72			"HTTP Response",
 73			"status_code", resp.StatusCode,
 74			"status", resp.Status,
 75			"headers", formatHeaders(resp.Header),
 76			"body", bodyToString(save),
 77			"content_length", resp.ContentLength,
 78			"duration_ms", duration.Milliseconds(),
 79		)
 80	}
 81	return resp, nil
 82}
 83
 84func bodyToString(body io.ReadCloser) string {
 85	if body == nil {
 86		return ""
 87	}
 88	src, err := io.ReadAll(body)
 89	if err != nil {
 90		slog.Error("Failed to read body", "error", err)
 91		return ""
 92	}
 93	var b bytes.Buffer
 94	if json.Indent(&b, bytes.TrimSpace(src), "", "  ") != nil {
 95		// not json probably
 96		return string(src)
 97	}
 98	return b.String()
 99}
100
101// formatHeaders formats HTTP headers for logging, filtering out sensitive information.
102func formatHeaders(headers http.Header) map[string][]string {
103	filtered := make(map[string][]string)
104	for key, values := range headers {
105		lowerKey := strings.ToLower(key)
106		// Filter out sensitive headers
107		if strings.Contains(lowerKey, "authorization") ||
108			strings.Contains(lowerKey, "api-key") ||
109			strings.Contains(lowerKey, "token") ||
110			strings.Contains(lowerKey, "secret") {
111			filtered[key] = []string{"[REDACTED]"}
112		} else {
113			filtered[key] = values
114		}
115	}
116	return filtered
117}
118
119func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
120	if b == nil || b == http.NoBody {
121		return http.NoBody, http.NoBody, nil
122	}
123	var buf bytes.Buffer
124	if _, err = buf.ReadFrom(b); err != nil {
125		return nil, b, err
126	}
127	if err = b.Close(); err != nil {
128		return nil, b, err
129	}
130	return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
131}