@@ -0,0 +1,125 @@
1+package log
2+
3+import (
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.
15+func 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.
27+type HTTPRoundTripLogger struct {
28+ Transport http.RoundTripper
29+}
30+
31+// RoundTrip implements http.RoundTripper interface with logging.
32+func (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+
81+func bodyToString(body io.ReadCloser) string {
82+ src, err := io.ReadAll(body)
83+ if err != nil {
84+ slog.Error("Failed to read body", "error", err)
85+ return ""
86+ }
87+ var b bytes.Buffer
88+ if json.Compact(&b, bytes.TrimSpace(src)) != nil {
89+ // not json probably
90+ return string(src)
91+ }
92+ return b.String()
93+}
94+
95+// formatHeaders formats HTTP headers for logging, filtering out sensitive information.
96+func formatHeaders(headers http.Header) map[string][]string {
97+ filtered := make(map[string][]string)
98+ for key, values := range headers {
99+ lowerKey := strings.ToLower(key)
100+ // Filter out sensitive headers
101+ if strings.Contains(lowerKey, "authorization") ||
102+ strings.Contains(lowerKey, "api-key") ||
103+ strings.Contains(lowerKey, "token") ||
104+ strings.Contains(lowerKey, "secret") {
105+ filtered[key] = []string{"[REDACTED]"}
106+ } else {
107+ filtered[key] = values
108+ }
109+ }
110+ return filtered
111+}
112+
113+func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
114+ if b == nil || b == http.NoBody {
115+ return http.NoBody, http.NoBody, nil
116+ }
117+ var buf bytes.Buffer
118+ if _, err = buf.ReadFrom(b); err != nil {
119+ return nil, b, err
120+ }
121+ if err = b.Close(); err != nil {
122+ return nil, b, err
123+ }
124+ return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
125+}