Skip to content

Commit 900c1b9

Browse files
authored
httputil: enhance JSONDebugClient with SSE and header debugging (#1404)
- Add Server-Sent Events (SSE) streaming support to JSONDebugClient - Display HTTP request/response headers with sensitive value scrubbing - Parse and highlight token usage from streaming API events
1 parent 1bbaab1 commit 900c1b9

2 files changed

Lines changed: 165 additions & 31 deletions

File tree

httputil/doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
// // LoggingClient logs full HTTP requests and responses using slog
3232
// client := httputil.LoggingClient
3333
//
34-
// // JSONDebugClient pretty-prints JSON payloads with ANSI colors
34+
// // JSONDebugClient pretty-prints JSON payloads and SSE streams with ANSI colors
3535
// client := httputil.JSONDebugClient
3636
//
3737
// # Custom Transports

httputil/logging_transport.go

Lines changed: 164 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package httputil
22

33
import (
4+
"bufio"
45
"bytes"
56
"encoding/json"
67
"fmt"
78
"io"
89
"log/slog"
910
"net/http"
1011
"net/http/httputil"
12+
"os"
1113
"strings"
1214
)
1315

@@ -26,12 +28,18 @@ var LoggingClient = &http.Client{ //nolint:gochecknoglobals
2628
},
2729
}
2830

29-
// JSONDebugClient is an [http.Client] designed for debugging JSON APIs.
30-
// It pretty-prints JSON request and response bodies to stdout with ANSI colors:
31-
// requests are shown in blue, responses in green. This client is intended for
32-
// development and debugging purposes only.
31+
// JSONDebugClient is an [http.Client] designed for debugging JSON APIs and Server-Sent Events.
32+
// It provides comprehensive debugging output including HTTP headers, JSON payloads, and real-time
33+
// SSE event parsing. All debug output is written to stderr with ANSI colors:
34+
// requests in blue, responses in green, SSE events in green, and parsed data in purple/yellow.
3335
//
34-
// Unlike [LoggingClient], this client writes directly to stdout rather than
36+
// Key features:
37+
// - Pretty-prints JSON request and response bodies
38+
// - Displays HTTP headers with sensitive values scrubbed
39+
// - Streams SSE events in real-time as they arrive
40+
// - Parses token usage from streaming APIs
41+
//
42+
// Unlike [LoggingClient], this client writes directly to stderr rather than
3543
// using structured logging.
3644
var JSONDebugClient = &http.Client{ //nolint:gochecknoglobals
3745
Transport: &Transport{
@@ -102,56 +110,182 @@ func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
102110

103111
// ANSI color codes
104112
const (
105-
colorBlue = "\033[34m"
106-
colorGreen = "\033[32m"
107-
colorReset = "\033[0m"
113+
colorBlue = "\033[34m"
114+
colorGreen = "\033[32m"
115+
colorYellow = "\033[33m"
116+
colorPurple = "\033[35m"
117+
colorGrey = "\033[90m"
118+
colorReset = "\033[0m"
108119
)
109120

110121
type jsonDebugTransport struct {
111122
Transport http.RoundTripper
112123
}
113124

125+
func scrubSensitive(key, value string) string {
126+
key = strings.ToLower(key)
127+
if strings.Contains(key, "auth") || strings.Contains(key, "key") || strings.Contains(key, "token") ||
128+
strings.Contains(key, "cookie") || strings.Contains(key, "secret") {
129+
if len(value) > 8 {
130+
return value[:4] + "..." + value[len(value)-4:]
131+
}
132+
return "***"
133+
}
134+
return value
135+
}
136+
114137
func (t *jsonDebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
115138
transport := t.Transport
116139
if transport == nil {
117140
transport = http.DefaultTransport
118141
}
119142

120-
// Log JSON request if present
121-
if strings.Contains(req.Header.Get("Content-Type"), "application/json") && req.Body != nil {
122-
body, err := io.ReadAll(req.Body)
123-
if err != nil {
124-
return nil, err
143+
// Print request headers
144+
fmt.Fprintf(os.Stderr, "%sRequest %s %s%s\n", colorBlue, req.Method, req.URL, colorReset)
145+
fmt.Fprintf(os.Stderr, "%sRequest Headers:%s\n", colorGrey, colorReset)
146+
for key, values := range req.Header {
147+
for _, value := range values {
148+
fmt.Fprintf(os.Stderr, "%s %s: %s%s\n", colorGrey, key, scrubSensitive(key, value), colorReset)
125149
}
126-
req.Body = io.NopCloser(bytes.NewReader(body))
150+
}
127151

128-
// Pretty print request in blue
129-
var pretty bytes.Buffer
130-
if json.Indent(&pretty, body, "", " ") == nil {
131-
fmt.Printf("%sRequest to %s\n%s%s\n", colorBlue, req.URL, pretty.String(), colorReset)
132-
}
152+
if err := t.logJSON(req.Header.Get("Content-Type"), &req.Body, colorBlue, "Body:"); err != nil {
153+
return nil, err
133154
}
134155

135156
resp, err := transport.RoundTrip(req)
136157
if err != nil {
137158
return nil, err
138159
}
139160

140-
// Log JSON response if present
161+
// Print response headers
162+
fmt.Fprintf(os.Stderr, "%sResponse %d %s%s\n", colorGreen, resp.StatusCode, resp.Status, colorReset)
163+
fmt.Fprintf(os.Stderr, "%sResponse Headers:%s\n", colorGrey, colorReset)
164+
for key, values := range resp.Header {
165+
for _, value := range values {
166+
fmt.Fprintf(os.Stderr, "%s %s: %s%s\n", colorGrey, key, scrubSensitive(key, value), colorReset)
167+
}
168+
}
169+
170+
// Handle SSE streams
141171
contentType := resp.Header.Get("Content-Type")
142-
if strings.Contains(contentType, "application/json") && resp.Body != nil {
143-
body, err := io.ReadAll(resp.Body)
144-
if err != nil {
145-
return nil, err
172+
if strings.Contains(contentType, "text/event-stream") && resp.Body != nil {
173+
return t.wrapSSEResponse(resp), nil
174+
}
175+
176+
if err := t.logJSON(contentType, &resp.Body, colorGreen, "Body:"); err != nil {
177+
return resp, err
178+
}
179+
180+
return resp, nil
181+
}
182+
183+
func (t *jsonDebugTransport) wrapSSEResponse(resp *http.Response) *http.Response {
184+
pr, pw := io.Pipe()
185+
186+
go func() {
187+
defer pw.Close()
188+
defer resp.Body.Close()
189+
190+
fmt.Fprintf(os.Stderr, "%sSSE stream starting...%s\n", colorGreen, colorReset)
191+
192+
scanner := bufio.NewScanner(resp.Body)
193+
for scanner.Scan() {
194+
line := scanner.Text()
195+
196+
// Print raw SSE frame
197+
fmt.Fprintf(os.Stderr, "%s%s%s\n", colorGreen, line, colorReset)
198+
199+
if strings.HasPrefix(line, "data: ") {
200+
data := strings.TrimPrefix(line, "data: ")
201+
if !strings.Contains(data, `"type": "ping"`) {
202+
t.parseEvent(data)
203+
}
204+
}
205+
206+
// Write to pipe for normal consumption
207+
if _, err := pw.Write(append([]byte(line), '\n')); err != nil {
208+
return
209+
}
146210
}
147-
resp.Body = io.NopCloser(bytes.NewReader(body))
148211

149-
// Pretty print response in green
150-
var pretty bytes.Buffer
151-
if json.Indent(&pretty, body, "", " ") == nil {
152-
fmt.Printf("%sResponse %d\n%s%s\n", colorGreen, resp.StatusCode, pretty.String(), colorReset)
212+
if err := scanner.Err(); err != nil {
213+
fmt.Fprintf(os.Stderr, "%sSSE stream error: %v%s\n", colorGreen, err, colorReset)
153214
}
215+
216+
fmt.Fprintf(os.Stderr, "%sSSE stream ended.%s\n", colorGreen, colorReset)
217+
}()
218+
219+
newResp := *resp
220+
newResp.Body = pr
221+
return &newResp
222+
}
223+
224+
func (t *jsonDebugTransport) parseEvent(text string) {
225+
var data map[string]interface{}
226+
if json.Unmarshal([]byte(text), &data) != nil {
227+
return
154228
}
155229

156-
return resp, nil
230+
switch data["type"] {
231+
case "message_start":
232+
if msg, ok := data["message"].(map[string]interface{}); ok {
233+
if usage, ok := msg["usage"].(map[string]interface{}); ok {
234+
fmt.Fprintf(os.Stderr, "%s[message_start] Usage: ", colorPurple)
235+
t.printUsage(usage)
236+
fmt.Fprintf(os.Stderr, "%s\n", colorReset)
237+
}
238+
}
239+
case "message_delta":
240+
if usage, ok := data["usage"].(map[string]interface{}); ok {
241+
fmt.Fprintf(os.Stderr, "%s[message_delta] Final usage: ", colorPurple)
242+
t.printUsage(usage)
243+
fmt.Fprintf(os.Stderr, "%s\n", colorReset)
244+
}
245+
if delta, ok := data["delta"].(map[string]interface{}); ok {
246+
if sig := delta["signature"]; sig != nil {
247+
fmt.Fprintf(os.Stderr, "%s[signature] %+v%s\n", colorYellow, sig, colorReset)
248+
}
249+
}
250+
case "content_block_delta":
251+
if delta, ok := data["delta"].(map[string]interface{}); ok {
252+
if delta["type"] == "thinking_delta" {
253+
if text, ok := delta["text"].(string); ok {
254+
fmt.Fprintf(os.Stderr, "%s[thinking] %s%s\n", colorYellow, text, colorReset)
255+
}
256+
}
257+
}
258+
}
259+
}
260+
261+
func (t *jsonDebugTransport) logJSON(contentType string, body *io.ReadCloser, color, label string) error {
262+
if !strings.Contains(contentType, "application/json") || *body == nil {
263+
return nil
264+
}
265+
data, err := io.ReadAll(*body)
266+
if err != nil {
267+
return err
268+
}
269+
*body = io.NopCloser(bytes.NewReader(data))
270+
271+
var pretty bytes.Buffer
272+
if json.Indent(&pretty, data, "", " ") == nil {
273+
fmt.Fprintf(os.Stderr, "%s%s%s\n%s%s%s\n", color, label, colorReset, color, pretty.String(), colorReset)
274+
}
275+
return nil
276+
}
277+
278+
func (t *jsonDebugTransport) printUsage(usage map[string]interface{}) {
279+
if n, ok := usage["input_tokens"].(float64); ok {
280+
fmt.Fprintf(os.Stderr, "input=%d ", int(n))
281+
}
282+
if n, ok := usage["output_tokens"].(float64); ok {
283+
fmt.Fprintf(os.Stderr, "output=%d ", int(n))
284+
}
285+
if n, ok := usage["cache_creation_input_tokens"].(float64); ok {
286+
fmt.Fprintf(os.Stderr, "cache_create=%d ", int(n))
287+
}
288+
if n, ok := usage["cache_read_input_tokens"].(float64); ok {
289+
fmt.Fprintf(os.Stderr, "cache_read=%d ", int(n))
290+
}
157291
}

0 commit comments

Comments
 (0)