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 slog.Default().Enabled(req.Context(), slog.LevelDebug) {
 67		slog.Debug(
 68			"HTTP Response",
 69			"status_code", resp.StatusCode,
 70			"status", resp.Status,
 71			"headers", formatHeaders(resp.Header),
 72			"body", bodyToString(save),
 73			"content_length", resp.ContentLength,
 74			"duration_ms", duration.Milliseconds(),
 75			"error", err,
 76		)
 77	}
 78	return resp, err
 79}
 80
 81func bodyToString(body io.ReadCloser) string {
 82	if body == nil {
 83		return ""
 84	}
 85	src, err := io.ReadAll(body)
 86	if err != nil {
 87		slog.Error("Failed to read body", "error", err)
 88		return ""
 89	}
 90	var b bytes.Buffer
 91	if json.Indent(&b, bytes.TrimSpace(src), "", "  ") != nil {
 92		// not json probably
 93		return string(src)
 94	}
 95	return b.String()
 96}
 97
 98// formatHeaders formats HTTP headers for logging, filtering out sensitive information.
 99func formatHeaders(headers http.Header) map[string][]string {
100	filtered := make(map[string][]string)
101	for key, values := range headers {
102		lowerKey := strings.ToLower(key)
103		// Filter out sensitive headers
104		if strings.Contains(lowerKey, "authorization") ||
105			strings.Contains(lowerKey, "api-key") ||
106			strings.Contains(lowerKey, "token") ||
107			strings.Contains(lowerKey, "secret") {
108			filtered[key] = []string{"[REDACTED]"}
109		} else {
110			filtered[key] = values
111		}
112	}
113	return filtered
114}
115
116func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
117	if b == nil || b == http.NoBody {
118		return http.NoBody, http.NoBody, nil
119	}
120	var buf bytes.Buffer
121	if _, err = buf.ReadFrom(b); err != nil {
122		return nil, b, err
123	}
124	if err = b.Close(); err != nil {
125		return nil, b, err
126	}
127	return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
128}