11package httputil
22
33import (
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.
3644var 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
104112const (
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
110121type 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+
114137func (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