logger.go

v0.6.1
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 logs a shutdown message and closes the log file if open.
82
func CloseLogger() {
83
	if logFile != nil {
84
		proxyLog.Info("proxy logging stopped")
85
		logFile.Close()
86
	}
87
}
88
89
// plog returns the initialized proxy logger.
90
func plog() *slog.Logger {
91
	initLog()
92
	return proxyLog
93
}
94
95
// rawWrite writes pre-formatted data directly to the log file under the shared
96
// mutex, blocking slog writes for the duration.
97
func rawWrite(data string) {
98
	initLog()
99
	if logFile == nil {
100
		return
101
	}
102
	logMu.Lock()
103
	defer logMu.Unlock()
104
	fmt.Fprint(logFile, data)
105
}
106
107
func formatDumpHeader(label string) string {
108
	return "\n" + strings.Repeat("=", 80) + "\n" +
109
		fmt.Sprintf("[%s] %s\n", time.Now().Format(time.RFC3339Nano), label) +
110
		strings.Repeat("=", 80) + "\n"
111
}
112
113
func formatDumpFooter() string {
114
	return strings.Repeat("-", 80) + "\n"
115
}
116
117
func appendPrettyJSON(b *strings.Builder, data []byte) {
118
	var pretty bytes.Buffer
119
	if json.Indent(&pretty, data, "", "  ") == nil {
120
		b.WriteString("\n--- Pretty JSON ---\n")
121
		b.WriteString(pretty.String())
122
		b.WriteString("\n")
123
	}
124
}
125
126
// rawLogRequest dumps the full HTTP request to the log file.
127
func rawLogRequest(req *http.Request, body []byte, label string) {
128
	initLog()
129
	if logFile == nil {
130
		return
131
	}
132
133
	reqCopy := req.Clone(req.Context())
134
	if len(body) > 0 {
135
		reqCopy.Body = io.NopCloser(bytes.NewReader(body))
136
		reqCopy.ContentLength = int64(len(body))
137
	}
138
	dump, err := httputil.DumpRequest(reqCopy, true)
139
	if err != nil {
140
		plog().Warn("failed to dump request", "error", err)
141
		return
142
	}
143
144
	var b strings.Builder
145
	b.WriteString(formatDumpHeader(label))
146
	b.Write(dump)
147
	if len(body) > 0 {
148
		appendPrettyJSON(&b, body)
149
	}
150
	b.WriteString(formatDumpFooter())
151
152
	rawWrite(b.String())
153
}
154
155
// rawLogResponse dumps the full HTTP response to the log file.
156
func rawLogResponse(resp *http.Response, body []byte, label string) {
157
	initLog()
158
	if logFile == nil {
159
		return
160
	}
161
162
	respCopy := *resp
163
	if len(body) > 0 {
164
		respCopy.Body = io.NopCloser(bytes.NewReader(body))
165
		respCopy.ContentLength = int64(len(body))
166
	}
167
	dump, err := httputil.DumpResponse(&respCopy, true)
168
	if err != nil {
169
		plog().Warn("failed to dump response", "error", err)
170
		return
171
	}
172
173
	var b strings.Builder
174
	b.WriteString(formatDumpHeader(label))
175
	b.Write(dump)
176
	if len(body) > 0 {
177
		appendPrettyJSON(&b, body)
178
	}
179
	b.WriteString(formatDumpFooter())
180
181
	rawWrite(b.String())
182
}
183
184
// rawLogStream writes captured SSE stream data to the log file.
185
func rawLogStream(label string, streamData string, chunkCount int) {
186
	initLog()
187
	if logFile == nil {
188
		return
189
	}
190
191
	var b strings.Builder
192
	b.WriteString(formatDumpHeader(label))
193
	b.WriteString(streamData)
194
	fmt.Fprintf(&b, "\n[Total chunks processed: %d]\n", chunkCount)
195
	b.WriteString(formatDumpFooter())
196
197
	rawWrite(b.String())
198
}
199

Source Files