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	slog.Debug(
 43		"HTTP Request",
 44		"method", req.Method,
 45		"url", req.URL,
 46		"body", bodyToString(save),
 47	)
 48
 49	start := time.Now()
 50	resp, err := h.Transport.RoundTrip(req)
 51	duration := time.Since(start)
 52	if err != nil {
 53		slog.Error(
 54			"HTTP request failed",
 55			"method", req.Method,
 56			"url", req.URL,
 57			"duration_ms", duration.Milliseconds(),
 58			"error", err,
 59		)
 60		return resp, err
 61	}
 62
 63	save, resp.Body, err = drainBody(resp.Body)
 64	slog.Debug(
 65		"HTTP Response",
 66		"status_code", resp.StatusCode,
 67		"status", resp.Status,
 68		"headers", formatHeaders(resp.Header),
 69		"body", bodyToString(save),
 70		"content_length", resp.ContentLength,
 71		"duration_ms", duration.Milliseconds(),
 72		"error", err,
 73	)
 74	return resp, err
 75}
 76
 77func bodyToString(body io.ReadCloser) string {
 78	if body == nil {
 79		return ""
 80	}
 81	src, err := io.ReadAll(body)
 82	if err != nil {
 83		slog.Error("Failed to read body", "error", err)
 84		return ""
 85	}
 86	var b bytes.Buffer
 87	if json.Compact(&b, bytes.TrimSpace(src)) != nil {
 88		// not json probably
 89		return string(src)
 90	}
 91	return b.String()
 92}
 93
 94// formatHeaders formats HTTP headers for logging, filtering out sensitive information.
 95func formatHeaders(headers http.Header) map[string][]string {
 96	filtered := make(map[string][]string)
 97	for key, values := range headers {
 98		lowerKey := strings.ToLower(key)
 99		// Filter out sensitive headers
100		if strings.Contains(lowerKey, "authorization") ||
101			strings.Contains(lowerKey, "api-key") ||
102			strings.Contains(lowerKey, "token") ||
103			strings.Contains(lowerKey, "secret") {
104			filtered[key] = []string{"[REDACTED]"}
105		} else {
106			filtered[key] = values
107		}
108	}
109	return filtered
110}
111
112func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
113	if b == nil || b == http.NoBody {
114		return http.NoBody, http.NoBody, nil
115	}
116	var buf bytes.Buffer
117	if _, err = buf.ReadFrom(b); err != nil {
118		return nil, b, err
119	}
120	if err = b.Close(); err != nil {
121		return nil, b, err
122	}
123	return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
124}