1package log
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"io"
  8	"log/slog"
  9	"net/http"
 10	"strings"
 11	"time"
 12)
 13
 14// NewHTTPClient creates an HTTP client with debug logging enabled when debug mode is on.
 15func NewHTTPClient() *http.Client {
 16	if !slog.Default().Enabled(context.TODO(), slog.LevelDebug) {
 17		return http.DefaultClient
 18	}
 19	return &http.Client{
 20		Transport: &HTTPRoundTripLogger{
 21			Transport: http.DefaultTransport,
 22		},
 23	}
 24}
 25
 26// HTTPRoundTripLogger is an http.RoundTripper that logs requests and responses.
 27type HTTPRoundTripLogger struct {
 28	Transport http.RoundTripper
 29}
 30
 31// RoundTrip implements http.RoundTripper interface with logging.
 32func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, error) {
 33	var err error
 34	var save io.ReadCloser
 35	save, req.Body, err = drainBody(req.Body)
 36	if err != nil {
 37		slog.Error(
 38			"HTTP request failed",
 39			"method", req.Method,
 40			"url", req.URL,
 41			"error", err,
 42		)
 43		return nil, err
 44	}
 45
 46	slog.Debug(
 47		"HTTP Request",
 48		"method", req.Method,
 49		"url", req.URL,
 50		"body", bodyToString(save),
 51	)
 52
 53	start := time.Now()
 54	resp, err := h.Transport.RoundTrip(req)
 55	duration := time.Since(start)
 56	if err != nil {
 57		slog.Error(
 58			"HTTP request failed",
 59			"method", req.Method,
 60			"url", req.URL,
 61			"duration_ms", duration.Milliseconds(),
 62			"error", err,
 63		)
 64		return resp, err
 65	}
 66
 67	save, resp.Body, err = drainBody(resp.Body)
 68	slog.Debug(
 69		"HTTP Response",
 70		"status_code", resp.StatusCode,
 71		"status", resp.Status,
 72		"headers", formatHeaders(resp.Header),
 73		"body", bodyToString(save),
 74		"content_length", resp.ContentLength,
 75		"duration_ms", duration.Milliseconds(),
 76		"error", err,
 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.Compact(&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}