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}