cuelang.org/go@v0.10.1/internal/httplog/client.go (about) 1 package httplog 2 3 import ( 4 "bytes" 5 "context" 6 "io" 7 "maps" 8 "net/http" 9 "net/url" 10 "slices" 11 "strings" 12 "sync/atomic" 13 "unicode/utf8" 14 ) 15 16 // DefaultMaxBodySize holds the maximum body size to include in 17 // logged requests when [TransportConfig.MaxBodySize] is <=0. 18 const DefaultMaxBodySize = 1024 19 20 // TransportConfig holds configuration for [Transport]. 21 type TransportConfig struct { 22 // Logger is used to log the requests. If it is nil, 23 // the zero [SlogLogger] will be used. 24 Logger Logger 25 26 // Transport is used as the underlying transport for 27 // making HTTP requests. If it is nil, 28 // [http.DefaultTransport] will be used. 29 Transport http.RoundTripper 30 31 // IncludeAllQueryParams causes all URL query parameters to be included 32 // rather than redacted using [RedactedURL]. 33 IncludeAllQueryParams bool 34 35 // MaxBodySize holds the maximum size of body data to include 36 // in the logged data. When a body is larger than this, only this 37 // amount of body will be included, and the "BodyTruncated" 38 // field will be set to true to indicate that this happened. 39 // 40 // If this is <=0, DefaultMaxBodySize will be used. 41 // Use [RedactRequestBody] or [RedactResponseBody] 42 // to cause body data to be omitted entirely. 43 MaxBodySize int 44 } 45 46 // Transport returns an [http.RoundTripper] implementation that 47 // logs HTTP requests. If cfg0 is nil, it's equivalent to a pointer 48 // to a zero-valued [TransportConfig]. 49 func Transport(cfg0 *TransportConfig) http.RoundTripper { 50 var cfg TransportConfig 51 if cfg0 != nil { 52 cfg = *cfg0 53 } 54 if cfg.Logger == nil { 55 cfg.Logger = SlogLogger{} 56 } 57 if cfg.Transport == nil { 58 cfg.Transport = http.DefaultTransport 59 } 60 if cfg.MaxBodySize <= 0 { 61 cfg.MaxBodySize = DefaultMaxBodySize 62 } 63 return &loggingTransport{ 64 cfg: cfg, 65 } 66 } 67 68 type loggingTransport struct { 69 cfg TransportConfig 70 } 71 72 var seq atomic.Int64 73 74 func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 75 ctx := req.Context() 76 id := seq.Add(1) 77 var reqURL string 78 if t.cfg.IncludeAllQueryParams { 79 reqURL = req.URL.String() 80 } else { 81 reqURL = RedactedURL(ctx, req.URL).String() 82 } 83 t.cfg.Logger.Log(ctx, KindClientSendRequest, fromHTTPRequest(ctx, id, reqURL, req, true, t.cfg.MaxBodySize)) 84 resp, err := t.cfg.Transport.RoundTrip(req) 85 if err != nil { 86 t.cfg.Logger.Log(ctx, KindClientRecvResponse, &Response{ 87 ID: id, 88 Method: req.Method, 89 URL: reqURL, 90 Error: err.Error(), 91 }) 92 return nil, err 93 } 94 logResp := &Response{ 95 ID: id, 96 Method: req.Method, 97 URL: reqURL, 98 Header: resp.Header, 99 StatusCode: resp.StatusCode, 100 } 101 resp.Body = logResp.BodyData.init(ctx, resp.Body, true, false, t.cfg.MaxBodySize) 102 t.cfg.Logger.Log(ctx, KindClientRecvResponse, logResp) 103 return resp, nil 104 } 105 106 func fromHTTPRequest(ctx context.Context, id int64, reqURL string, req *http.Request, closeBody bool, maxBodySize int) *Request { 107 logReq := &Request{ 108 ID: id, 109 URL: reqURL, 110 Method: req.Method, 111 Header: redactAuthorization(req.Header), 112 ContentLength: req.ContentLength, 113 } 114 req.Body = logReq.BodyData.init(ctx, req.Body, closeBody, true, maxBodySize) 115 116 return logReq 117 } 118 119 func redactAuthorization(h http.Header) http.Header { 120 auths, ok := h["Authorization"] 121 if !ok { 122 return h 123 } 124 h = maps.Clone(h) // shallow copy 125 auths = slices.Clone(auths) 126 for i, auth := range auths { 127 if kind, _, ok := strings.Cut(auth, " "); ok && (kind == "Basic" || kind == "Bearer") { 128 auths[i] = kind + " REDACTED" 129 } else { 130 auths[i] = "REDACTED" 131 } 132 } 133 h["Authorization"] = auths 134 return h 135 } 136 137 // init initializes body to contain information about the body data read from r. 138 // It returns a replacement reader to use instead of r. 139 func (body *BodyData) init(ctx context.Context, r io.ReadCloser, needClose, isRequest bool, maxBodySize int) io.ReadCloser { 140 if r == nil { 141 return nil 142 } 143 if reason := shouldRedactBody(ctx, isRequest); reason != "" { 144 body.BodyRedactedBecause = reason 145 return r 146 } 147 data, err := io.ReadAll(io.LimitReader(r, int64(maxBodySize+1))) 148 if len(data) > maxBodySize { 149 body.BodyTruncated = true 150 r = struct { 151 io.Reader 152 io.Closer 153 }{ 154 Reader: io.MultiReader( 155 bytes.NewReader(data), 156 r, 157 ), 158 Closer: r, 159 } 160 data = data[:maxBodySize] 161 } else { 162 if err != nil { 163 body.BodyTruncated = true 164 } 165 if needClose { 166 r.Close() 167 } 168 r = io.NopCloser(bytes.NewReader(data)) 169 } 170 if utf8.Valid(data) { 171 body.Body = string(data) 172 } else { 173 body.Body64 = data 174 } 175 return r 176 } 177 178 // RedactedURL returns u with query parameters redacted according 179 // to [ContextWithAllowedURLQueryParams]. 180 // If there is no allow function associated with the context, 181 // all query parameters will be redacted. 182 func RedactedURL(ctx context.Context, u *url.URL) *url.URL { 183 if u.RawQuery == "" { 184 return u 185 } 186 qs := u.Query() 187 allow := queryParamChecker(ctx) 188 changed := false 189 for k, v := range qs { 190 if allow(k) { 191 continue 192 } 193 changed = true 194 for i := range v { 195 v[i] = "REDACTED" 196 } 197 } 198 if !changed { 199 return u 200 } 201 r := *u 202 r.RawQuery = qs.Encode() 203 return &r 204 }