├── LICENSE ├── README.md ├── _example └── main.go ├── color.go ├── go.mod ├── go.sum ├── helpers.go ├── httplog.go ├── httplog_test.go ├── options.go ├── text_handler.go ├── trace.go └── util.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka). 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | httplog 2 | ======= 3 | 4 | Small but powerful structured logging package for HTTP request logging built 5 | on the Go 1.21+ stdlib `slog` package. 6 | 7 | ``` 8 | go get -u github.com/go-chi/httplog/v2 9 | ``` 10 | 11 | ## Example 12 | 13 | (see [_example/](./_example/main.go)) 14 | 15 | ```go 16 | package main 17 | 18 | import ( 19 | "log/slog" 20 | "net/http" 21 | "github.com/go-chi/chi/v5" 22 | "github.com/go-chi/chi/v5/middleware" 23 | "github.com/go-chi/httplog/v2" 24 | ) 25 | 26 | func main() { 27 | // Logger 28 | logger := httplog.NewLogger("httplog-example", httplog.Options{ 29 | // JSON: true, 30 | LogLevel: slog.LevelDebug, 31 | Concise: true, 32 | RequestHeaders: true, 33 | MessageFieldName: "message", 34 | // TimeFieldFormat: time.RFC850, 35 | Tags: map[string]string{ 36 | "version": "v1.0-81aa4244d9fc8076a", 37 | "env": "dev", 38 | }, 39 | QuietDownRoutes: []string{ 40 | "/", 41 | "/ping", 42 | }, 43 | QuietDownPeriod: 10 * time.Second, 44 | // SourceFieldName: "source", 45 | }) 46 | 47 | // Service 48 | r := chi.NewRouter() 49 | r.Use(httplog.RequestLogger(logger)) 50 | r.Use(middleware.Heartbeat("/ping")) 51 | 52 | r.Use(func(next http.Handler) http.Handler { 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | ctx := r.Context() 55 | httplog.LogEntrySetField(ctx, "user", slog.StringValue("user1")) 56 | next.ServeHTTP(w, r.WithContext(ctx)) 57 | }) 58 | }) 59 | 60 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 61 | w.Write([]byte("hello world")) 62 | }) 63 | 64 | r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { 65 | panic("oh no") 66 | }) 67 | 68 | r.Get("/info", func(w http.ResponseWriter, r *http.Request) { 69 | oplog := httplog.LogEntry(r.Context()) 70 | w.Header().Add("Content-Type", "text/plain") 71 | oplog.Info("info here") 72 | w.Write([]byte("info here")) 73 | }) 74 | 75 | r.Get("/warn", func(w http.ResponseWriter, r *http.Request) { 76 | oplog := httplog.LogEntry(r.Context()) 77 | oplog.Warn("warn here") 78 | w.WriteHeader(400) 79 | w.Write([]byte("warn here")) 80 | }) 81 | 82 | r.Get("/err", func(w http.ResponseWriter, r *http.Request) { 83 | oplog := httplog.LogEntry(r.Context()) 84 | oplog.Error("msg here", "err", errors.New("err here")) 85 | w.WriteHeader(500) 86 | w.Write([]byte("oops, err")) 87 | }) 88 | 89 | http.ListenAndServe("localhost:8000", r) 90 | } 91 | 92 | ``` 93 | 94 | ## License 95 | 96 | MIT 97 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/go-chi/chi/v5/middleware" 11 | "github.com/go-chi/httplog/v2" 12 | ) 13 | 14 | func main() { 15 | // Logger 16 | logger := httplog.NewLogger("httplog-example", httplog.Options{ 17 | LogLevel: slog.LevelDebug, 18 | // JSON: true, 19 | Concise: true, 20 | // RequestHeaders: true, 21 | // ResponseHeaders: true, 22 | MessageFieldName: "message", 23 | LevelFieldName: "severity", 24 | TimeFieldFormat: time.RFC3339, 25 | Tags: map[string]string{ 26 | "version": "v1.0-81aa4244d9fc8076a", 27 | "env": "dev", 28 | }, 29 | QuietDownRoutes: []string{ 30 | "/", 31 | "/ping", 32 | }, 33 | QuietDownPeriod: 10 * time.Second, 34 | // SourceFieldName: "source", 35 | }) 36 | 37 | // Service 38 | r := chi.NewRouter() 39 | r.Use(httplog.RequestLogger(logger, []string{"/ping"})) 40 | r.Use(middleware.Heartbeat("/ping")) 41 | 42 | r.Use(func(next http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | ctx := r.Context() 45 | 46 | // Set a single log field 47 | httplog.LogEntrySetField(ctx, "user", slog.StringValue("user1")) 48 | 49 | // Set multiple fields 50 | fields := map[string]any{ 51 | "remote": "example.com", 52 | "action": "update", 53 | } 54 | httplog.LogEntrySetFields(ctx, fields) 55 | next.ServeHTTP(w, r.WithContext(ctx)) 56 | }) 57 | }) 58 | 59 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 60 | w.Write([]byte("hello world")) 61 | }) 62 | 63 | r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { 64 | panic("oh no") 65 | }) 66 | 67 | r.Get("/info", func(w http.ResponseWriter, r *http.Request) { 68 | oplog := httplog.LogEntry(r.Context()) 69 | w.Header().Add("Content-Type", "text/plain") 70 | oplog.Info("info here") 71 | w.Write([]byte("info here")) 72 | }) 73 | 74 | r.Get("/warn", func(w http.ResponseWriter, r *http.Request) { 75 | oplog := httplog.LogEntry(r.Context()) 76 | oplog.Warn("warn here") 77 | w.WriteHeader(400) 78 | w.Write([]byte("warn here")) 79 | }) 80 | 81 | r.Get("/err", func(w http.ResponseWriter, r *http.Request) { 82 | oplog := httplog.LogEntry(r.Context()) 83 | 84 | // two varianets of syntax to specify "err" attr. 85 | err := errors.New("err here") 86 | // oplog.Error("msg here", "err", err) 87 | oplog.Error("msg here", httplog.ErrAttr(err)) 88 | 89 | // logging with the global logger also works 90 | slog.Default().With(slog.Group("ImpGroup", slog.String("account", "id"))).Error("doesn't exist") 91 | slog.Default().Error("oops, err occured") 92 | w.WriteHeader(500) 93 | w.Write([]byte("oops, err")) 94 | }) 95 | 96 | http.ListenAndServe("localhost:8000", r) 97 | } 98 | -------------------------------------------------------------------------------- /color.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | // Ported from Goji's middleware, source: 4 | // https://github.com/zenazn/goji/tree/master/web/middleware 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | var ( 13 | // Normal colors 14 | nBlack = []byte{'\033', '[', '3', '0', 'm'} 15 | nRed = []byte{'\033', '[', '3', '1', 'm'} 16 | nGreen = []byte{'\033', '[', '3', '2', 'm'} 17 | nYellow = []byte{'\033', '[', '3', '3', 'm'} 18 | nBlue = []byte{'\033', '[', '3', '4', 'm'} 19 | nMagenta = []byte{'\033', '[', '3', '5', 'm'} 20 | nCyan = []byte{'\033', '[', '3', '6', 'm'} 21 | nWhite = []byte{'\033', '[', '3', '7', 'm'} 22 | // Bright colors 23 | bBlack = []byte{'\033', '[', '3', '0', ';', '1', 'm'} 24 | bRed = []byte{'\033', '[', '3', '1', ';', '1', 'm'} 25 | bGreen = []byte{'\033', '[', '3', '2', ';', '1', 'm'} 26 | bYellow = []byte{'\033', '[', '3', '3', ';', '1', 'm'} 27 | bBlue = []byte{'\033', '[', '3', '4', ';', '1', 'm'} 28 | bMagenta = []byte{'\033', '[', '3', '5', ';', '1', 'm'} 29 | bCyan = []byte{'\033', '[', '3', '6', ';', '1', 'm'} 30 | bWhite = []byte{'\033', '[', '3', '7', ';', '1', 'm'} 31 | 32 | reset = []byte{'\033', '[', '0', 'm'} 33 | ) 34 | 35 | var IsTTY bool 36 | 37 | func init() { 38 | // This is sort of cheating: if stdout is a character device, we assume 39 | // that means it's a TTY. Unfortunately, there are many non-TTY 40 | // character devices, but fortunately stdout is rarely set to any of 41 | // them. 42 | // 43 | // We could solve this properly by pulling in a dependency on 44 | // code.google.com/p/go.crypto/ssh/terminal, for instance, but as a 45 | // heuristic for whether to print in color or in black-and-white, I'd 46 | // really rather not. 47 | fi, err := os.Stdout.Stat() 48 | if err == nil { 49 | m := os.ModeDevice | os.ModeCharDevice 50 | IsTTY = fi.Mode()&m == m 51 | } 52 | } 53 | 54 | // colorWrite 55 | func cW(w *bytes.Buffer, useColor bool, color []byte, s string, args ...interface{}) { 56 | if IsTTY && useColor { 57 | w.Write(color) 58 | } 59 | fmt.Fprintf(w, s, args...) 60 | if IsTTY && useColor { 61 | w.Write(reset) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-chi/httplog/v2 2 | 3 | go 1.21 4 | 5 | require github.com/go-chi/chi/v5 v5.0.10 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= 2 | github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "reflect" 7 | ) 8 | 9 | // StructValue will convert a struct or slice of structs to a slog.Value 10 | func StructValue(v interface{}) slog.Value { 11 | var out interface{} 12 | 13 | rv := reflect.ValueOf(v) 14 | if rv.Kind() == reflect.Slice { 15 | // assume slice of objects 16 | out = []map[string]interface{}{} 17 | } else { 18 | // assume single object 19 | out = map[string]interface{}{} 20 | } 21 | 22 | b, _ := json.Marshal(v) 23 | json.Unmarshal(b, &out) 24 | 25 | return slog.AnyValue(out) 26 | } 27 | -------------------------------------------------------------------------------- /httplog.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | ) 17 | 18 | type Logger struct { 19 | *slog.Logger 20 | Options Options 21 | } 22 | 23 | func NewLogger(serviceName string, options ...Options) *Logger { 24 | logger := &Logger{} 25 | if len(options) > 0 { 26 | logger.Configure(options[0]) 27 | } else { 28 | logger.Configure(defaultOptions) 29 | } 30 | 31 | slogger := logger.Logger.With(slog.Attr{Key: "service", Value: slog.StringValue(serviceName)}) 32 | 33 | if !logger.Options.Concise && len(logger.Options.Tags) > 0 { 34 | group := []any{} 35 | for k, v := range logger.Options.Tags { 36 | group = append(group, slog.Attr{Key: k, Value: slog.StringValue(v)}) 37 | } 38 | slogger = slogger.With(slog.Group("tags", group...)) 39 | } 40 | 41 | logger.Logger = slogger 42 | return logger 43 | } 44 | 45 | // RequestLogger is an http middleware to log http requests and responses. 46 | // 47 | // NOTE: for simplicity, RequestLogger automatically makes use of the chi RequestID and 48 | // Recoverer middleware. 49 | func RequestLogger(logger *Logger, skipPaths ...[]string) func(next http.Handler) http.Handler { 50 | return chi.Chain( 51 | middleware.RequestID, 52 | Handler(logger, skipPaths...), 53 | middleware.Recoverer, 54 | ).Handler 55 | } 56 | 57 | func Handler(logger *Logger, optSkipPaths ...[]string) func(next http.Handler) http.Handler { 58 | var f middleware.LogFormatter = &requestLogger{logger.Logger, logger.Options} 59 | 60 | skipPaths := map[string]struct{}{} 61 | if len(optSkipPaths) > 0 { 62 | for _, path := range optSkipPaths[0] { 63 | skipPaths[path] = struct{}{} 64 | } 65 | } 66 | 67 | return func(next http.Handler) http.Handler { 68 | fn := func(w http.ResponseWriter, r *http.Request) { 69 | // Skip the logger if the path is in the skip list 70 | if len(skipPaths) > 0 { 71 | _, skip := skipPaths[r.URL.Path] 72 | if skip { 73 | next.ServeHTTP(w, r) 74 | return 75 | } 76 | } 77 | 78 | if rInCooldown(r, &logger.Options) { 79 | next.ServeHTTP(w, r) 80 | return 81 | } 82 | 83 | ctx := r.Context() 84 | if logger.Options.Trace != nil { 85 | traceID := r.Header.Get(logger.Options.Trace.HeaderTrace) 86 | if traceID == "" { 87 | traceID = newID() 88 | } 89 | w.Header().Set(logger.Options.Trace.HeaderTrace, traceID) 90 | ctx = context.WithValue(ctx, _contextKeyTrace, traceID) 91 | ctx = context.WithValue(ctx, _contextKeySpan, newID()) 92 | } 93 | 94 | r = r.WithContext(ctx) 95 | 96 | entry := f.NewLogEntry(r) 97 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 98 | 99 | buf := newLimitBuffer(512) 100 | ww.Tee(buf) 101 | 102 | t1 := time.Now() 103 | defer func() { 104 | var respBody []byte 105 | if ww.Status() >= 400 { 106 | respBody, _ = io.ReadAll(buf) 107 | } 108 | entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), respBody) 109 | }() 110 | 111 | next.ServeHTTP(ww, middleware.WithLogEntry(r, entry)) 112 | } 113 | return http.HandlerFunc(fn) 114 | } 115 | } 116 | 117 | type requestLogger struct { 118 | Logger *slog.Logger 119 | Options Options 120 | } 121 | 122 | func (l *requestLogger) NewLogEntry(r *http.Request) middleware.LogEntry { 123 | entry := &RequestLoggerEntry{l.Logger, l.Options, ""} 124 | msg := fmt.Sprintf("Request: %s %s", r.Method, r.URL.Path) 125 | 126 | logger := l.Logger 127 | 128 | if traceID, ok := r.Context().Value(_contextKeyTrace).(string); ok { 129 | logger = logger.With(slog.Attr{Key: l.Options.Trace.LogFieldTrace, Value: slog.StringValue(traceID)}) 130 | } 131 | if spanID, ok := r.Context().Value(_contextKeySpan).(string); ok { 132 | logger = logger.With(slog.Attr{Key: l.Options.Trace.LogFieldSpan, Value: slog.StringValue(spanID)}) 133 | } 134 | 135 | entry.Logger = logger.With(requestLogFields(r, l.Options, l.Options.RequestHeaders)) 136 | 137 | if !l.Options.Concise { 138 | entry.Logger.Info(msg) 139 | } 140 | return entry 141 | } 142 | 143 | type RequestLoggerEntry struct { 144 | Logger *slog.Logger 145 | Options Options 146 | msg string 147 | } 148 | 149 | func (l *RequestLoggerEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 150 | msg := fmt.Sprintf("Response: %d %s", status, statusLabel(status)) 151 | if l.msg != "" { 152 | msg = fmt.Sprintf("%s - %s", msg, l.msg) 153 | } 154 | 155 | responseLog := []any{ 156 | slog.Attr{Key: "status", Value: slog.IntValue(status)}, 157 | slog.Attr{Key: "bytes", Value: slog.IntValue(bytes)}, 158 | slog.Attr{Key: "elapsed", Value: slog.Float64Value(float64(elapsed.Nanoseconds()) / 1000000.0)}, // in milliseconds 159 | } 160 | 161 | if !l.Options.Concise { 162 | // Include response header, as well for error status codes (>400) we include 163 | // the response body so we may inspect the log message sent back to the client. 164 | if status >= 400 { 165 | body, _ := extra.([]byte) 166 | responseLog = append(responseLog, slog.Attr{Key: "body", Value: slog.StringValue(string(body))}) 167 | } 168 | if l.Options.ResponseHeaders && len(header) > 0 { 169 | responseLog = append(responseLog, slog.Group("header", attrsToAnys(headerLogField(header, l.Options))...)) 170 | } 171 | } 172 | 173 | l.Logger.With(slog.Group("httpResponse", responseLog...)).Log(context.Background(), statusLevel(status), msg) 174 | } 175 | 176 | func (l *RequestLoggerEntry) Panic(v interface{}, stack []byte) { 177 | stacktrace := "#" 178 | if l.Options.JSON { 179 | stacktrace = string(stack) 180 | } 181 | l.Logger = l.Logger.With( 182 | slog.Attr{ 183 | Key: "stacktrace", 184 | Value: slog.StringValue(stacktrace)}, 185 | slog.Attr{ 186 | Key: "panic", 187 | Value: slog.StringValue(fmt.Sprintf("%+v", v)), 188 | }) 189 | 190 | l.msg = fmt.Sprintf("%+v", v) 191 | 192 | if !l.Options.JSON { 193 | middleware.PrintPrettyStack(v) 194 | } 195 | } 196 | 197 | var coolDownMu sync.RWMutex 198 | var coolDowns = map[string]time.Time{} 199 | 200 | func rInCooldown(r *http.Request, options *Options) bool { 201 | routePath := r.URL.EscapedPath() 202 | if routePath == "" { 203 | routePath = "/" 204 | } 205 | if !inArray(options.QuietDownRoutes, routePath) { 206 | return false 207 | } 208 | coolDownMu.RLock() 209 | coolDownTime, ok := coolDowns[routePath] 210 | coolDownMu.RUnlock() 211 | if ok { 212 | if time.Since(coolDownTime) < options.QuietDownPeriod { 213 | return true 214 | } 215 | } 216 | coolDownMu.Lock() 217 | defer coolDownMu.Unlock() 218 | coolDowns[routePath] = time.Now().Add(options.QuietDownPeriod) 219 | return false 220 | } 221 | 222 | func inArray(arr []string, val string) bool { 223 | for _, v := range arr { 224 | if v == val { 225 | return true 226 | } 227 | } 228 | return false 229 | } 230 | 231 | func requestLogFields(r *http.Request, options Options, requestHeaders bool) slog.Attr { 232 | scheme := "http" 233 | if r.TLS != nil { 234 | scheme = "https" 235 | } 236 | requestURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) 237 | 238 | requestFields := []any{ 239 | slog.Attr{Key: "url", Value: slog.StringValue(requestURL)}, 240 | slog.Attr{Key: "method", Value: slog.StringValue(r.Method)}, 241 | slog.Attr{Key: "path", Value: slog.StringValue(r.URL.Path)}, 242 | slog.Attr{Key: "remoteIP", Value: slog.StringValue(r.RemoteAddr)}, 243 | slog.Attr{Key: "proto", Value: slog.StringValue(r.Proto)}, 244 | } 245 | if reqID := middleware.GetReqID(r.Context()); reqID != "" { 246 | requestFields = append(requestFields, slog.Attr{Key: "requestID", Value: slog.StringValue(reqID)}) 247 | } 248 | 249 | if !options.RequestHeaders { 250 | return slog.Group("httpRequest", requestFields...) 251 | } 252 | 253 | // include request headers 254 | requestFields = append(requestFields, slog.Attr{Key: "scheme", Value: slog.StringValue(scheme)}) 255 | if len(r.Header) > 0 { 256 | requestFields = append(requestFields, 257 | slog.Attr{ 258 | Key: "header", 259 | Value: slog.GroupValue(headerLogField(r.Header, options)...), 260 | }) 261 | } 262 | 263 | return slog.Group("httpRequest", requestFields...) 264 | } 265 | 266 | func headerLogField(header http.Header, options Options) []slog.Attr { 267 | headerField := []slog.Attr{} 268 | for k, v := range header { 269 | k = strings.ToLower(k) 270 | switch { 271 | case len(v) == 0: 272 | continue 273 | case len(v) == 1: 274 | headerField = append(headerField, slog.Attr{Key: k, Value: slog.StringValue(v[0])}) 275 | default: 276 | headerField = append(headerField, slog.Attr{Key: k, 277 | Value: slog.StringValue(fmt.Sprintf("[%s]", strings.Join(v, "], [")))}) 278 | } 279 | if k == "authorization" || k == "cookie" || k == "set-cookie" { 280 | headerField[len(headerField)-1] = slog.Attr{ 281 | Key: k, 282 | Value: slog.StringValue("***"), 283 | } 284 | } 285 | 286 | for _, skip := range options.HideRequestHeaders { 287 | if k == skip { 288 | headerField[len(headerField)-1] = slog.Attr{ 289 | Key: k, 290 | Value: slog.StringValue("***"), 291 | } 292 | break 293 | } 294 | } 295 | } 296 | return headerField 297 | } 298 | 299 | func attrsToAnys(attr []slog.Attr) []any { 300 | attrs := make([]any, len(attr)) 301 | for i, a := range attr { 302 | attrs[i] = a 303 | } 304 | return attrs 305 | } 306 | 307 | func statusLevel(status int) slog.Level { 308 | switch { 309 | case status <= 0: 310 | return slog.LevelWarn 311 | case status < 400: // for codes in 100s, 200s, 300s 312 | return slog.LevelInfo 313 | case status >= 400 && status < 500: 314 | // switching to info level to be less noisy 315 | return slog.LevelInfo 316 | case status >= 500: 317 | return slog.LevelError 318 | default: 319 | return slog.LevelInfo 320 | } 321 | } 322 | 323 | func statusLabel(status int) string { 324 | switch { 325 | case status >= 100 && status < 300: 326 | return "OK" 327 | case status >= 300 && status < 400: 328 | return "Redirect" 329 | case status >= 400 && status < 500: 330 | return "Client Error" 331 | case status >= 500: 332 | return "Server Error" 333 | default: 334 | return "Unknown" 335 | } 336 | } 337 | 338 | func ErrAttr(err error) slog.Attr { 339 | return slog.Any("err", err) 340 | } 341 | 342 | // Helper methods used by the application to get the request-scoped 343 | // logger entry and set additional fields between handlers. 344 | // 345 | // This is a useful pattern to use to set state on the entry as it 346 | // passes through the handler chain, which at any point can be logged 347 | // with a call to .Print(), .Info(), etc. 348 | 349 | func LogEntry(ctx context.Context) *slog.Logger { 350 | entry, ok := ctx.Value(middleware.LogEntryCtxKey).(*RequestLoggerEntry) 351 | if !ok || entry == nil { 352 | handlerOpts := &slog.HandlerOptions{ 353 | AddSource: true, 354 | // LevelError+1 will be higher than all levels 355 | // hence logs would be skipped 356 | Level: slog.LevelError + 1, 357 | // ReplaceAttr: func(attr slog.Attr) slog.Attr , 358 | } 359 | return slog.New(slog.NewTextHandler(os.Stdout, handlerOpts)) 360 | } else { 361 | return entry.Logger 362 | } 363 | } 364 | 365 | func LogEntrySetField(ctx context.Context, key string, value slog.Value) { 366 | if entry, ok := ctx.Value(middleware.LogEntryCtxKey).(*RequestLoggerEntry); ok { 367 | entry.Logger = entry.Logger.With(slog.Attr{Key: key, Value: value}) 368 | } 369 | } 370 | 371 | func LogEntrySetFields(ctx context.Context, fields map[string]interface{}) { 372 | if entry, ok := ctx.Value(middleware.LogEntryCtxKey).(*RequestLoggerEntry); ok { 373 | attrs := make([]any, len(fields)) 374 | i := 0 375 | for k, v := range fields { 376 | attrs[i] = slog.Attr{Key: k, Value: slog.AnyValue(v)} 377 | i++ 378 | } 379 | entry.Logger = entry.Logger.With(attrs...) 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /httplog_test.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/go-chi/chi/v5/middleware" 10 | ) 11 | 12 | func TestLogEntrySetFields(t *testing.T) { 13 | 14 | type args struct { 15 | handler *testHandler 16 | fields map[string]interface{} 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | }{ 22 | { 23 | name: "test_fields_set", 24 | args: args{ 25 | handler: &testHandler{}, 26 | fields: map[string]interface{}{ 27 | "foo": 1000, 28 | "bar": "account", 29 | }, 30 | }, 31 | }, 32 | { 33 | name: "test_empty", 34 | args: args{ 35 | handler: &testHandler{}, 36 | fields: make(map[string]interface{}), 37 | }, 38 | }, 39 | { 40 | name: "test_fields_nil", 41 | args: args{ 42 | handler: &testHandler{}, 43 | fields: nil, 44 | }, 45 | }, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | entry := &RequestLoggerEntry{ 50 | Logger: slog.New(tt.args.handler), 51 | } 52 | req := middleware.WithLogEntry(httptest.NewRequest("GET", "/", nil), entry) 53 | LogEntrySetFields(req.Context(), tt.args.fields) 54 | 55 | if len(tt.args.handler.attrs) != len(tt.args.fields) { 56 | t.Fatalf("expected %v, got %v", len(tt.args.handler.attrs), len(tt.args.fields)) 57 | } 58 | // Ensure all fields are present in the handler 59 | for k, v := range tt.args.fields { 60 | for i, attr := range tt.args.handler.attrs { 61 | if attr.Key == k { 62 | if !attr.Value.Equal(slog.AnyValue(v)) { 63 | t.Fatalf("expected %v, got %v", attr.Value, v) 64 | } 65 | break 66 | } 67 | if i == len(tt.args.handler.attrs)-1 { 68 | t.Fatalf("expected %v, got %v", k, attr.Key) 69 | } 70 | } 71 | } 72 | }) 73 | } 74 | } 75 | 76 | type testHandler struct { 77 | attrs []slog.Attr 78 | } 79 | 80 | func (*testHandler) Enabled(_ context.Context, l slog.Level) bool { return true } 81 | 82 | func (h *testHandler) Handle(ctx context.Context, r slog.Record) error { return nil } 83 | 84 | func (h *testHandler) WithAttrs(as []slog.Attr) slog.Handler { 85 | h.attrs = as 86 | return h 87 | } 88 | 89 | func (h *testHandler) WithGroup(name string) slog.Handler { return h } 90 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "cmp" 5 | "io" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "log/slog" 11 | ) 12 | 13 | var defaultOptions = Options{ 14 | LogLevel: slog.LevelInfo, 15 | LevelFieldName: "level", 16 | JSON: false, 17 | Concise: true, 18 | Tags: nil, 19 | RequestHeaders: true, 20 | HideRequestHeaders: nil, 21 | QuietDownRoutes: nil, 22 | QuietDownPeriod: 0, 23 | TimeFieldFormat: time.RFC3339Nano, 24 | TimeFieldName: "timestamp", 25 | MessageFieldName: "message", 26 | } 27 | 28 | type Options struct { 29 | // LogLevel defines the minimum level of severity that app should log. 30 | // Must be one of: 31 | // slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError 32 | LogLevel slog.Level 33 | 34 | // LevelFieldName sets the field name for the log level or severity. 35 | // Some providers parse and search for different field names. 36 | LevelFieldName string 37 | 38 | // MessageFieldName sets the field name for the message. 39 | // Default is "msg". 40 | MessageFieldName string 41 | 42 | // JSON enables structured logging output in json. Make sure to enable this 43 | // in production mode so log aggregators can receive data in parsable format. 44 | // 45 | // In local development mode, its appropriate to set this value to false to 46 | // receive pretty output and stacktraces to stdout. 47 | JSON bool 48 | 49 | // Concise mode includes fewer log details during the request flow. For example 50 | // excluding details like request content length, user-agent and other details. 51 | // This is useful if during development your console is too noisy. 52 | Concise bool 53 | 54 | // Tags are additional fields included at the root level of all logs. 55 | // These can be useful for example the commit hash of a build, or an environment 56 | // name like prod/stg/dev 57 | Tags map[string]string 58 | 59 | // RequestHeaders enables logging of all request headers, however sensitive 60 | // headers like authorization, cookie and set-cookie are hidden. 61 | RequestHeaders bool 62 | 63 | // HideRequestHeaders are additional requests headers which are redacted from the logs 64 | HideRequestHeaders []string 65 | 66 | // ResponseHeaders enables logging of all response headers. 67 | ResponseHeaders bool 68 | 69 | // QuietDownRoutes are routes which are temporarily excluded from logging for a QuietDownPeriod after it occurs 70 | // for the first time 71 | // to cancel noise from logging for routes that are known to be noisy. 72 | QuietDownRoutes []string 73 | 74 | // QuietDownPeriod is the duration for which a route is excluded from logging after it occurs for the first time 75 | // if the route is in QuietDownRoutes 76 | QuietDownPeriod time.Duration 77 | 78 | // TimeFieldFormat defines the time format of the Time field, defaulting to "time.RFC3339Nano" see options at: 79 | // https://pkg.go.dev/time#pkg-constants 80 | TimeFieldFormat string 81 | 82 | // TimeFieldName sets the field name for the time field. 83 | // Some providers parse and search for different field names. 84 | TimeFieldName string 85 | 86 | // SourceFieldName sets the field name for the source field which logs 87 | // the location in the program source code where the logger was called. 88 | // If set to "" then it'll be disabled. 89 | SourceFieldName string 90 | 91 | // Writer is the log writer, default is os.Stdout 92 | Writer io.Writer 93 | 94 | // ReplaceAttrsOverride allows to add custom logic to replace attributes 95 | // in addition to the default logic set in this package. 96 | ReplaceAttrsOverride func(groups []string, a slog.Attr) slog.Attr 97 | 98 | // Trace is the configuration for distributed tracing. 99 | Trace *TraceOptions 100 | } 101 | 102 | // TraceOptions are the configuration options for distributed tracing. 103 | type TraceOptions struct { 104 | // HeaderTrace is the header key used to read the trace id from the incoming request. 105 | // Default is "X-Trace-ID". 106 | HeaderTrace string 107 | // LogFieldTrace is the field name used to log the trace id. 108 | // Default is "trace_id". 109 | LogFieldTrace string 110 | // LogFieldSpan is the field name used to log the span id. 111 | // Default is "span_id". 112 | LogFieldSpan string 113 | } 114 | 115 | // Configure will set new options for the httplog instance and behaviour 116 | // of underlying slog pkg and its global logger. 117 | func (l *Logger) Configure(opts Options) { 118 | // if opts.LogLevel is not set 119 | // it would be 0 which is LevelInfo 120 | 121 | if opts.LevelFieldName == "" { 122 | opts.LevelFieldName = "level" 123 | } 124 | 125 | if opts.TimeFieldFormat == "" { 126 | opts.TimeFieldFormat = time.RFC3339Nano 127 | } 128 | 129 | if opts.TimeFieldName == "" { 130 | opts.TimeFieldName = "timestamp" 131 | } 132 | 133 | if len(opts.QuietDownRoutes) > 0 { 134 | if opts.QuietDownPeriod == 0 { 135 | opts.QuietDownPeriod = 5 * time.Minute 136 | } 137 | } 138 | 139 | // Pre-downcase all SkipHeaders 140 | for i, header := range opts.HideRequestHeaders { 141 | opts.HideRequestHeaders[i] = strings.ToLower(header) 142 | } 143 | 144 | l.Options = opts 145 | 146 | var addSource bool 147 | if opts.SourceFieldName != "" { 148 | addSource = true 149 | } 150 | 151 | replaceAttrs := func(groups []string, a slog.Attr) slog.Attr { 152 | switch a.Key { 153 | case slog.LevelKey: 154 | a.Key = opts.LevelFieldName 155 | case slog.TimeKey: 156 | a.Key = opts.TimeFieldName 157 | a.Value = slog.StringValue(a.Value.Time().Format(opts.TimeFieldFormat)) 158 | case slog.MessageKey: 159 | if opts.MessageFieldName != "" { 160 | a.Key = opts.MessageFieldName 161 | } 162 | case slog.SourceKey: 163 | if opts.SourceFieldName != "" { 164 | a.Key = opts.SourceFieldName 165 | } 166 | } 167 | 168 | if opts.ReplaceAttrsOverride != nil { 169 | return opts.ReplaceAttrsOverride(groups, a) 170 | } 171 | return a 172 | } 173 | 174 | handlerOpts := &slog.HandlerOptions{ 175 | Level: opts.LogLevel, 176 | ReplaceAttr: replaceAttrs, 177 | AddSource: addSource, 178 | } 179 | 180 | writer := opts.Writer 181 | if writer == nil { 182 | writer = os.Stdout 183 | } 184 | 185 | if !opts.JSON { 186 | l.Logger = slog.New(NewPrettyHandler(writer, handlerOpts)) 187 | } else { 188 | l.Logger = slog.New(slog.NewJSONHandler(writer, handlerOpts)) 189 | } 190 | 191 | l.Options.Trace = opts.Trace 192 | if l.Options.Trace != nil { 193 | l.Options.Trace.HeaderTrace = cmp.Or(l.Options.Trace.HeaderTrace, _headerTraceID) 194 | l.Options.Trace.LogFieldTrace = cmp.Or(l.Options.Trace.LogFieldTrace, _logFieldTrace) 195 | l.Options.Trace.LogFieldSpan = cmp.Or(l.Options.Trace.LogFieldSpan, _logFieldSpan) 196 | } 197 | } 198 | 199 | func LevelByName(name string) slog.Level { 200 | switch strings.ToUpper(name) { 201 | case "DEBUG": 202 | return slog.LevelDebug 203 | case "INFO": 204 | return slog.LevelInfo 205 | case "WARN": 206 | return slog.LevelWarn 207 | case "ERROR": 208 | return slog.LevelError 209 | default: 210 | return 0 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /text_handler.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "log/slog" 8 | "runtime" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type PrettyHandler struct { 14 | opts *slog.HandlerOptions 15 | w io.Writer 16 | preformattedAttrs *bytes.Buffer 17 | groupPrefix *string 18 | groupOpen bool 19 | mu sync.Mutex 20 | } 21 | 22 | var DefaultHandlerConfig = &slog.HandlerOptions{ 23 | Level: slog.LevelInfo, 24 | AddSource: true, 25 | } 26 | 27 | func NewPrettyHandler(w io.Writer, options ...*slog.HandlerOptions) *PrettyHandler { 28 | var opts *slog.HandlerOptions 29 | if len(options) == 0 { 30 | opts = DefaultHandlerConfig 31 | } else { 32 | opts = options[0] 33 | } 34 | 35 | return &PrettyHandler{ 36 | opts: opts, 37 | w: w, 38 | preformattedAttrs: &bytes.Buffer{}, 39 | mu: sync.Mutex{}, 40 | } 41 | } 42 | 43 | var _ slog.Handler = &PrettyHandler{} 44 | 45 | func (h *PrettyHandler) Enabled(ctx context.Context, level slog.Level) bool { 46 | minLevel := slog.LevelInfo 47 | if h.opts != nil && h.opts.Level != nil { 48 | minLevel = h.opts.Level.Level() 49 | } 50 | return level >= minLevel 51 | } 52 | 53 | func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error { 54 | buf := &bytes.Buffer{} 55 | 56 | if !r.Time.IsZero() { 57 | timeAttr := slog.Attr{ 58 | Key: slog.TimeKey, 59 | Value: slog.TimeValue(r.Time), 60 | } 61 | if h.opts != nil && h.opts.ReplaceAttr != nil { 62 | timeAttr = h.opts.ReplaceAttr([]string{}, timeAttr) 63 | } else { 64 | timeAttr.Value = slog.StringValue(timeAttr.Value.Time().Format(time.RFC3339Nano)) 65 | } 66 | // write time, level and source to buf 67 | cW(buf, false, bBlack, "%s", timeAttr.Value.String()) 68 | buf.WriteString(" ") 69 | } 70 | 71 | levelAttr := slog.Attr{ 72 | Key: slog.LevelKey, 73 | Value: slog.StringValue(r.Level.String()), 74 | } 75 | if h.opts != nil && h.opts.ReplaceAttr != nil { 76 | levelAttr = h.opts.ReplaceAttr([]string{}, levelAttr) 77 | } 78 | cW(buf, true, levelColor(r.Level), "%s", levelAttr.Value.String()) 79 | buf.WriteString(" ") 80 | 81 | if h.opts != nil && h.opts.AddSource { 82 | s := source(r) 83 | file := s.File 84 | line := s.Line 85 | function := s.Function 86 | cW(buf, true, nGreen, "%s:%s:%d", function, file, line) 87 | buf.WriteString(" ") 88 | } 89 | 90 | // write message to buf 91 | cW(buf, true, bWhite, "%s", r.Message) 92 | buf.WriteString(" ") 93 | // write preformatted attrs to buf 94 | buf.Write(h.preformattedAttrs.Bytes()) 95 | // close group in preformatted attrs if open\ 96 | if h.groupOpen { 97 | cW(h.preformattedAttrs, true, nWhite, "%s", "}") 98 | } 99 | 100 | // write record level attrs to buf 101 | attrs := []slog.Attr{} 102 | r.Attrs(func(attr slog.Attr) bool { 103 | attrs = append(attrs, attr) 104 | return true 105 | }) 106 | writeAttrs(buf, attrs, false) 107 | 108 | buf.WriteString("\n") 109 | h.mu.Lock() 110 | defer h.mu.Unlock() 111 | h.w.Write(buf.Bytes()) 112 | return nil 113 | } 114 | 115 | func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 116 | h2 := h.clone() 117 | writeAttrs(h2.preformattedAttrs, attrs, false) 118 | return h2 119 | } 120 | 121 | func source(r slog.Record) *slog.Source { 122 | fs := runtime.CallersFrames([]uintptr{r.PC}) 123 | f, _ := fs.Next() 124 | return &slog.Source{ 125 | Function: f.Function, 126 | File: f.File, 127 | Line: f.Line, 128 | } 129 | } 130 | 131 | func writeAttrs(w *bytes.Buffer, attrs []slog.Attr, insideGroup bool) { 132 | for i, attr := range attrs { 133 | cW(w, true, nYellow, "%s: ", attr.Key) 134 | if insideGroup && i == len(attrs)-1 { 135 | writeAttrValue(w, attr.Value, false) 136 | } else { 137 | writeAttrValue(w, attr.Value, true) 138 | } 139 | } 140 | } 141 | 142 | func writeAttrValue(w *bytes.Buffer, value slog.Value, appendSpace bool) { 143 | if appendSpace { 144 | defer w.WriteString(" ") 145 | } 146 | switch v := value.Kind(); v { 147 | case slog.KindString: 148 | cW(w, true, nCyan, "%q", value.String()) 149 | case slog.KindBool: 150 | cW(w, true, nCyan, "%t", value.Bool()) 151 | case slog.KindInt64: 152 | cW(w, true, nCyan, "%d", value.Int64()) 153 | case slog.KindDuration: 154 | cW(w, true, nCyan, "%s", value.Duration().String()) 155 | case slog.KindFloat64: 156 | cW(w, true, nCyan, "%f", value.Float64()) 157 | case slog.KindTime: 158 | cW(w, true, nCyan, "%s", value.Time().Format(time.RFC3339)) 159 | case slog.KindUint64: 160 | cW(w, true, nCyan, "%d", value.Uint64()) 161 | case slog.KindGroup: 162 | cW(w, true, nWhite, "{") 163 | writeAttrs(w, value.Group(), true) 164 | cW(w, true, nWhite, "%s", "}") 165 | default: 166 | cW(w, true, nCyan, "%s", value.String()) 167 | } 168 | } 169 | 170 | func levelColor(l slog.Level) []byte { 171 | switch l { 172 | case slog.LevelDebug: 173 | return nYellow 174 | case slog.LevelInfo: 175 | return nGreen 176 | case slog.LevelWarn: 177 | return nRed 178 | case slog.LevelError: 179 | return bRed 180 | default: 181 | return bWhite 182 | } 183 | } 184 | 185 | func (h *PrettyHandler) WithGroup(name string) slog.Handler { 186 | h2 := h.clone() 187 | if h2.groupPrefix != nil { 188 | // end old group 189 | cW(h2.preformattedAttrs, true, nWhite, "}") 190 | } 191 | h2.groupOpen = true 192 | h2.groupPrefix = &name 193 | cW(h2.preformattedAttrs, true, bMagenta, "%s: {", name) 194 | return h 195 | } 196 | 197 | func (h *PrettyHandler) clone() *PrettyHandler { 198 | newBuffer := &bytes.Buffer{} 199 | newBuffer.Write(h.preformattedAttrs.Bytes()) 200 | 201 | return &PrettyHandler{ 202 | opts: h.opts, 203 | w: h.w, 204 | groupPrefix: h.groupPrefix, 205 | preformattedAttrs: newBuffer, 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /trace.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "cmp" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "net/http" 8 | ) 9 | 10 | const ( 11 | _headerTraceID = "X-Trace-ID" 12 | _logFieldTrace = "trace_id" 13 | _logFieldSpan = "span_id" 14 | ) 15 | 16 | type contextKey struct { 17 | name string 18 | } 19 | 20 | func (k *contextKey) String() string { 21 | return "httplog context value " + k.name 22 | } 23 | 24 | var ( 25 | _contextKeyTrace = &contextKey{"trace_id"} 26 | _contextKeySpan = &contextKey{"span_id"} 27 | ) 28 | 29 | // NeTransport returns a new http.RoundTripper that propagates the TraceID. 30 | func NewTransport(header string, base http.RoundTripper) http.RoundTripper { 31 | if base == nil { 32 | base = http.DefaultTransport 33 | } 34 | return traceTransport{ 35 | Header: cmp.Or(header, _headerTraceID), 36 | Base: base, 37 | } 38 | } 39 | 40 | type traceTransport struct { 41 | Header string 42 | Base http.RoundTripper 43 | } 44 | 45 | func (t traceTransport) RoundTrip(r *http.Request) (*http.Response, error) { 46 | if id, ok := r.Context().Value(_contextKeyTrace).(string); ok { 47 | r.Header.Set(cmp.Or(t.Header, _headerTraceID), id) 48 | } 49 | return t.Base.RoundTrip(r) 50 | } 51 | 52 | func newID() string { 53 | b := make([]byte, 16) 54 | rand.Read(b) 55 | return hex.EncodeToString(b) 56 | } 57 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // limitBuffer is used to pipe response body information from the 9 | // response writer to a certain limit amount. The idea is to read 10 | // a portion of the response body such as an error response so we 11 | // may log it. 12 | type limitBuffer struct { 13 | *bytes.Buffer 14 | limit int 15 | } 16 | 17 | func newLimitBuffer(size int) io.ReadWriter { 18 | return limitBuffer{ 19 | Buffer: bytes.NewBuffer(make([]byte, 0, size)), 20 | limit: size, 21 | } 22 | } 23 | 24 | func (b limitBuffer) Write(p []byte) (n int, err error) { 25 | if b.Buffer.Len() >= b.limit { 26 | return len(p), nil 27 | } 28 | limit := b.limit 29 | if len(p) < limit { 30 | limit = len(p) 31 | } 32 | return b.Buffer.Write(p[:limit]) 33 | } 34 | 35 | func (b limitBuffer) Read(p []byte) (n int, err error) { 36 | return b.Buffer.Read(p) 37 | } 38 | --------------------------------------------------------------------------------