Skip to content

Commit d7a6f56

Browse files
mislavsamcoe
andauthored
Expand logging support for HTTP traffic (#60)
- Respect the GH_DEBUG environment variable by default, but add the option to skip it; - Add option to opt into colorized logging; - Add option to explicitly log HTTP headers and bodies—the new default is off; - Colorize JSON payloads when logging HTTP bodies; - Log form-encoded payloads; - Pretty-print GraphQL queries when logging HTTP requests. Co-authored-by: Sam Coe <[email protected]>
1 parent f649e37 commit d7a6f56

File tree

8 files changed

+341
-21
lines changed

8 files changed

+341
-21
lines changed

internal/api/cache_test.go

+8-6
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ func TestCacheResponse(t *testing.T) {
3434

3535
httpClient := NewHTTPClient(
3636
&api.ClientOptions{
37-
Transport: fakeHTTP,
38-
EnableCache: true,
39-
CacheDir: cacheDir,
37+
Transport: fakeHTTP,
38+
EnableCache: true,
39+
CacheDir: cacheDir,
40+
LogIgnoreEnv: true,
4041
})
4142

4243
do := func(method, url string, body io.Reader) (string, error) {
@@ -114,9 +115,10 @@ func TestCacheResponseRequestCacheOptions(t *testing.T) {
114115

115116
httpClient := NewHTTPClient(
116117
&api.ClientOptions{
117-
Transport: fakeHTTP,
118-
EnableCache: false,
119-
CacheDir: cacheDir,
118+
Transport: fakeHTTP,
119+
EnableCache: false,
120+
CacheDir: cacheDir,
121+
LogIgnoreEnv: true,
120122
})
121123

122124
do := func(method, url string, body io.Reader) (string, error) {

internal/api/http.go

+23-8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/cli/go-gh/pkg/api"
15+
"github.com/cli/go-gh/pkg/term"
1516
"github.com/henvic/httpretty"
1617
"github.com/thlib/go-timezone-local/tzlocal"
1718
)
@@ -54,17 +55,29 @@ func NewHTTPClient(opts *api.ClientOptions) http.Client {
5455
c := cache{dir: opts.CacheDir, ttl: opts.CacheTTL}
5556
transport = c.RoundTripper(transport)
5657

58+
if opts.Log == nil && !opts.LogIgnoreEnv {
59+
ghDebug := os.Getenv("GH_DEBUG")
60+
switch ghDebug {
61+
case "", "0", "false", "no":
62+
// no logging
63+
default:
64+
opts.Log = os.Stderr
65+
opts.LogColorize = !term.IsColorDisabled() && term.IsTerminal(os.Stderr)
66+
opts.LogVerboseHTTP = strings.Contains(ghDebug, "api")
67+
}
68+
}
69+
5770
if opts.Log != nil {
5871
logger := &httpretty.Logger{
5972
Time: true,
6073
TLS: false,
61-
Colors: false,
62-
RequestHeader: true,
63-
RequestBody: true,
64-
ResponseHeader: true,
65-
ResponseBody: true,
66-
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
67-
MaxResponseBody: 10000,
74+
Colors: opts.LogColorize,
75+
RequestHeader: opts.LogVerboseHTTP,
76+
RequestBody: opts.LogVerboseHTTP,
77+
ResponseHeader: opts.LogVerboseHTTP,
78+
ResponseBody: opts.LogVerboseHTTP,
79+
Formatters: []httpretty.Formatter{&jsonFormatter{colorize: opts.LogColorize}},
80+
MaxResponseBody: 100000,
6881
}
6982
logger.SetOutput(opts.Log)
7083
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
@@ -85,7 +98,9 @@ func NewHTTPClient(opts *api.ClientOptions) http.Client {
8598
}
8699

87100
func inspectableMIMEType(t string) bool {
88-
return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
101+
return strings.HasPrefix(t, "text/") ||
102+
strings.HasPrefix(t, "application/x-www-form-urlencoded") ||
103+
jsonTypeRE.MatchString(t)
89104
}
90105

91106
func isSameDomain(requestHost, domain string) bool {

internal/api/http_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ func TestNewHTTPClient(t *testing.T) {
120120
Headers: tt.headers,
121121
SkipDefaultHeaders: tt.skipHeaders,
122122
Transport: reflectHTTP,
123+
LogIgnoreEnv: true,
123124
}
124125
if tt.enableLog {
125126
opts.Log = tt.log

internal/api/log_formatter.go

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"strings"
9+
10+
"github.com/cli/go-gh/pkg/jsonpretty"
11+
)
12+
13+
type graphqlBody struct {
14+
Query string `json:"query"`
15+
OperationName string `json:"operationName"`
16+
Variables json.RawMessage `json:"variables"`
17+
}
18+
19+
// jsonFormatter is a httpretty.Formatter that prettifies JSON payloads and GraphQL queries.
20+
type jsonFormatter struct {
21+
colorize bool
22+
}
23+
24+
func (f *jsonFormatter) Format(w io.Writer, src []byte) error {
25+
var graphqlQuery graphqlBody
26+
// TODO: find more precise way to detect a GraphQL query from the JSON payload alone
27+
if err := json.Unmarshal(src, &graphqlQuery); err == nil && graphqlQuery.Query != "" && len(graphqlQuery.Variables) > 0 {
28+
colorHighlight := "\x1b[35;1m"
29+
colorReset := "\x1b[m"
30+
if !f.colorize {
31+
colorHighlight = ""
32+
colorReset = ""
33+
}
34+
if _, err := fmt.Fprintf(w, "%sGraphQL query:%s\n%s\n", colorHighlight, colorReset, strings.ReplaceAll(strings.TrimSpace(graphqlQuery.Query), "\t", " ")); err != nil {
35+
return err
36+
}
37+
if _, err := fmt.Fprintf(w, "%sGraphQL variables:%s %s\n", colorHighlight, colorReset, string(graphqlQuery.Variables)); err != nil {
38+
return err
39+
}
40+
return nil
41+
}
42+
return jsonpretty.Format(w, bytes.NewReader(src), " ", f.colorize)
43+
}
44+
45+
func (f *jsonFormatter) Match(t string) bool {
46+
return jsonTypeRE.MatchString(t)
47+
}

pkg/api/client.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,22 @@ type ClientOptions struct {
3434
// Host is the default host that API requests will be sent to.
3535
Host string
3636

37-
// Log specifies a writer to write API request logs to.
38-
// Default is no logging.
37+
// Log specifies a writer to write API request logs to. Default is to respect the GH_DEBUG environment
38+
// variable, and no logging otherwise.
3939
Log io.Writer
4040

41+
// LogIgnoreEnv disables respecting the GH_DEBUG environment variable. This can be useful in test mode
42+
// or when the extension already offers its own controls for logging to the user.
43+
LogIgnoreEnv bool
44+
45+
// LogColorize enables colorized logging to Log for display in a terminal.
46+
// Default is no coloring.
47+
LogColorize bool
48+
49+
// LogVerboseHTTP enables logging HTTP headers and bodies to Log.
50+
// Default is only logging request URLs and response statuses.
51+
LogVerboseHTTP bool
52+
4153
// SkipDefaultHeaders disables setting of the default headers.
4254
SkipDefaultHeaders bool
4355

pkg/jsonpretty/format.go

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Package jsonpretty implements a terminal pretty-printer for JSON.
2+
package jsonpretty
3+
4+
import (
5+
"bytes"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"strings"
10+
)
11+
12+
const (
13+
colorDelim = "\x1b[1;38m" // bright white
14+
colorKey = "\x1b[1;34m" // bright blue
15+
colorNull = "\x1b[36m" // cyan
16+
colorString = "\x1b[32m" // green
17+
colorBool = "\x1b[33m" // yellow
18+
colorReset = "\x1b[m"
19+
)
20+
21+
// Format reads JSON from r and writes a prettified version of it to w.
22+
func Format(w io.Writer, r io.Reader, indent string, colorize bool) error {
23+
dec := json.NewDecoder(r)
24+
dec.UseNumber()
25+
26+
c := func(ansi string) string {
27+
if !colorize {
28+
return ""
29+
}
30+
return ansi
31+
}
32+
33+
var idx int
34+
var stack []json.Delim
35+
36+
for {
37+
t, err := dec.Token()
38+
if err == io.EOF {
39+
break
40+
}
41+
if err != nil {
42+
return err
43+
}
44+
45+
switch tt := t.(type) {
46+
case json.Delim:
47+
switch tt {
48+
case '{', '[':
49+
stack = append(stack, tt)
50+
idx = 0
51+
if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil {
52+
return err
53+
}
54+
if dec.More() {
55+
if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack))); err != nil {
56+
return err
57+
}
58+
}
59+
continue
60+
case '}', ']':
61+
stack = stack[:len(stack)-1]
62+
idx = 0
63+
if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil {
64+
return err
65+
}
66+
}
67+
default:
68+
b, err := marshalJSON(tt)
69+
if err != nil {
70+
return err
71+
}
72+
73+
isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0
74+
idx++
75+
76+
var color string
77+
if isKey {
78+
color = colorKey
79+
} else if tt == nil {
80+
color = colorNull
81+
} else {
82+
switch t.(type) {
83+
case string:
84+
color = colorString
85+
case bool:
86+
color = colorBool
87+
}
88+
}
89+
90+
if color != "" {
91+
if _, err := fmt.Fprint(w, c(color)); err != nil {
92+
return err
93+
}
94+
}
95+
if _, err := w.Write(b); err != nil {
96+
return err
97+
}
98+
if color != "" {
99+
if _, err := fmt.Fprint(w, c(colorReset)); err != nil {
100+
return err
101+
}
102+
}
103+
104+
if isKey {
105+
if _, err := fmt.Fprint(w, c(colorDelim), ":", c(colorReset), " "); err != nil {
106+
return err
107+
}
108+
continue
109+
}
110+
}
111+
112+
if dec.More() {
113+
if _, err := fmt.Fprint(w, c(colorDelim), ",", c(colorReset), "\n", strings.Repeat(indent, len(stack))); err != nil {
114+
return err
115+
}
116+
} else if len(stack) > 0 {
117+
if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1)); err != nil {
118+
return err
119+
}
120+
} else {
121+
if _, err := fmt.Fprint(w, "\n"); err != nil {
122+
return err
123+
}
124+
}
125+
}
126+
127+
return nil
128+
}
129+
130+
// marshalJSON works like json.Marshal, but with HTML-escaping disabled.
131+
func marshalJSON(v interface{}) ([]byte, error) {
132+
buf := bytes.Buffer{}
133+
enc := json.NewEncoder(&buf)
134+
enc.SetEscapeHTML(false)
135+
if err := enc.Encode(v); err != nil {
136+
return nil, err
137+
}
138+
bb := buf.Bytes()
139+
// omit trailing newline added by json.Encoder
140+
if len(bb) > 0 && bb[len(bb)-1] == '\n' {
141+
return bb[:len(bb)-1], nil
142+
}
143+
return bb, nil
144+
}

0 commit comments

Comments
 (0)