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}