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