logger.go

v0.4.0
Doc Versions Source
1
package proxy
2
3
import (
4
	"bytes"
5
	"encoding/json"
6
	"fmt"
7
	"io"
8
	"log/slog"
9
	"net/http"
10
	"net/http/httputil"
11
	"os"
12
	"path/filepath"
13
	"strings"
14
	"sync"
15
	"time"
16
)
17
18
var (
19
	proxyLog      *slog.Logger
20
	logFile       *os.File
21
	logOnce       sync.Once
22
	logMu         sync.Mutex // serializes all writes (slog + raw) to the log file
23
	logEnabled    bool       // set via SetHTTPDebug before first log call
24
	logOutputPath string     // custom log file path; empty means default
25
)
26
27
// lockedWriter wraps an io.Writer with a shared mutex so that slog writes
28
// and raw dump writes never interleave.
29
type lockedWriter struct {
30
	mu *sync.Mutex
31
	w  io.Writer
32
}
33
34
func (lw *lockedWriter) Write(p []byte) (int, error) {
35
	lw.mu.Lock()
36
	defer lw.mu.Unlock()
37
	return lw.w.Write(p)
38
}
39
40
// SetHTTPDebug enables or disables proxy logging with an optional output path.
41
// Must be called before any logging happens (i.e. before Start/StartPassthrough).
42
// If outputPath is empty, defaults to "./claude-proxy.log".
43
func SetHTTPDebug(enabled bool, outputPath string) {
44
	logEnabled = enabled
45
	logOutputPath = outputPath
46
}
47
48
func initLog() {
49
	logOnce.Do(func() {
50
		if !logEnabled {
51
			proxyLog = slog.New(slog.NewTextHandler(io.Discard, nil))
52
			return
53
		}
54
55
		logPath := logOutputPath
56
		if logPath == "" {
57
			logPath = "./claude-proxy.log"
58
		}
59
		if dir := filepath.Dir(logPath); dir != "." {
60
			if err := os.MkdirAll(dir, 0755); err != nil {
61
				proxyLog = slog.New(slog.NewTextHandler(io.Discard, nil))
62
				return
63
			}
64
		}
65
		f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
66
		if err != nil {
67
			proxyLog = slog.New(slog.NewTextHandler(io.Discard, nil))
68
			return
69
		}
70
		logFile = f
71
72
		lw := &lockedWriter{mu: &logMu, w: f}
73
		proxyLog = slog.New(slog.NewTextHandler(lw, &slog.HandlerOptions{
74
			Level: slog.LevelDebug,
75
		}))
76
77
		proxyLog.Info("proxy logging initialized", "path", logPath)
78
	})
79
}
80
81
// CloseLogger closes the log file if open.
82
func CloseLogger() {
83
	if logFile != nil {
84
		logFile.Close()
85
	}
86
}
87
88
// plog returns the initialized proxy logger.
89
func plog() *slog.Logger {
90
	initLog()
91
	return proxyLog
92
}
93
94
// rawWrite writes pre-formatted data directly to the log file under the shared
95
// mutex, blocking slog writes for the duration.
96
func rawWrite(data string) {
97
	initLog()
98
	if logFile == nil {
99
		return
100
	}
101
	logMu.Lock()
102
	defer logMu.Unlock()
103
	fmt.Fprint(logFile, data)
104
}
105
106
func formatDumpHeader(label string) string {
107
	return "\n" + strings.Repeat("=", 80) + "\n" +
108
		fmt.Sprintf("[%s] %s\n", time.Now().Format(time.RFC3339Nano), label) +
109
		strings.Repeat("=", 80) + "\n"
110
}
111
112
func formatDumpFooter() string {
113
	return strings.Repeat("-", 80) + "\n"
114
}
115
116
func appendPrettyJSON(b *strings.Builder, data []byte) {
117
	var pretty bytes.Buffer
118
	if json.Indent(&pretty, data, "", "  ") == nil {
119
		b.WriteString("\n--- Pretty JSON ---\n")
120
		b.WriteString(pretty.String())
121
		b.WriteString("\n")
122
	}
123
}
124
125
// rawLogRequest dumps the full HTTP request to the log file.
126
func rawLogRequest(req *http.Request, body []byte, label string) {
127
	initLog()
128
	if logFile == nil {
129
		return
130
	}
131
132
	reqCopy := req.Clone(req.Context())
133
	if len(body) > 0 {
134
		reqCopy.Body = io.NopCloser(bytes.NewReader(body))
135
		reqCopy.ContentLength = int64(len(body))
136
	}
137
	dump, err := httputil.DumpRequest(reqCopy, true)
138
	if err != nil {
139
		plog().Warn("failed to dump request", "error", err)
140
		return
141
	}
142
143
	var b strings.Builder
144
	b.WriteString(formatDumpHeader(label))
145
	b.Write(dump)
146
	if len(body) > 0 {
147
		appendPrettyJSON(&b, body)
148
	}
149
	b.WriteString(formatDumpFooter())
150
151
	rawWrite(b.String())
152
}
153
154
// rawLogResponse dumps the full HTTP response to the log file.
155
func rawLogResponse(resp *http.Response, body []byte, label string) {
156
	initLog()
157
	if logFile == nil {
158
		return
159
	}
160
161
	respCopy := *resp
162
	if len(body) > 0 {
163
		respCopy.Body = io.NopCloser(bytes.NewReader(body))
164
		respCopy.ContentLength = int64(len(body))
165
	}
166
	dump, err := httputil.DumpResponse(&respCopy, true)
167
	if err != nil {
168
		plog().Warn("failed to dump response", "error", err)
169
		return
170
	}
171
172
	var b strings.Builder
173
	b.WriteString(formatDumpHeader(label))
174
	b.Write(dump)
175
	if len(body) > 0 {
176
		appendPrettyJSON(&b, body)
177
	}
178
	b.WriteString(formatDumpFooter())
179
180
	rawWrite(b.String())
181
}
182
183
// rawLogStream writes captured SSE stream data to the log file.
184
func rawLogStream(label string, streamData string, chunkCount int) {
185
	initLog()
186
	if logFile == nil {
187
		return
188
	}
189
190
	var b strings.Builder
191
	b.WriteString(formatDumpHeader(label))
192
	b.WriteString(streamData)
193
	fmt.Fprintf(&b, "\n[Total chunks processed: %d]\n", chunkCount)
194
	b.WriteString(formatDumpFooter())
195
196
	rawWrite(b.String())
197
}
198

Source Files