├── go.mod ├── go.sum ├── _example ├── go.mod ├── go.sum └── main.go ├── context.go ├── curl.go ├── LICENSE ├── README.md ├── options.go ├── middleware.go └── schema.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-chi/httplog/v3 2 | 3 | // Package log/slog was added in Go 1.21. Don't change. 4 | go 1.21 5 | 6 | require github.com/go-chi/chi/v5 v5.1.0 7 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 2 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 | -------------------------------------------------------------------------------- /_example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-chi/httplog/v3/_example 2 | 3 | go 1.22.6 4 | 5 | replace github.com/go-chi/httplog/v3 => ../ 6 | 7 | require ( 8 | github.com/go-chi/chi/v5 v5.1.0 9 | github.com/go-chi/httplog/v3 v3.0.0-00010101000000-000000000000 10 | github.com/go-chi/traceid v0.3.0 11 | github.com/golang-cz/devslog v0.0.14 12 | ) 13 | 14 | require github.com/google/uuid v1.6.0 // indirect 15 | -------------------------------------------------------------------------------- /_example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 2 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 | github.com/go-chi/traceid v0.3.0 h1:BYITxMnIeQasU7/U7+InZWANWxVZeCUBGTAqWleQOeg= 4 | github.com/go-chi/traceid v0.3.0/go.mod h1:XFfEEYZjqgML4ySh+wYBU29eqJkc2um7oEzgIc63e74= 5 | github.com/golang-cz/devslog v0.0.9 h1:GU59V626sUccMjytKhewgN3teduXtx/itoQs78NaPdE= 6 | github.com/golang-cz/devslog v0.0.9/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= 7 | github.com/golang-cz/devslog v0.0.14 h1:hZY6VuZ/+MmG4djP9X1YDSmX/z5zPDDVgFlO0fyb+CY= 8 | github.com/golang-cz/devslog v0.0.14/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | const ( 9 | ErrorKey = "error" 10 | ) 11 | 12 | type ctxKeyLogAttrs struct{} 13 | 14 | func (c *ctxKeyLogAttrs) String() string { 15 | return "httplog attrs context" 16 | } 17 | 18 | // SetAttrs sets the attributes on the request log. 19 | func SetAttrs(ctx context.Context, attrs ...slog.Attr) { 20 | if ptr, ok := ctx.Value(ctxKeyLogAttrs{}).(*[]slog.Attr); ok && ptr != nil { 21 | *ptr = append(*ptr, attrs...) 22 | } 23 | } 24 | 25 | func getAttrs(ctx context.Context) []slog.Attr { 26 | if ptr, ok := ctx.Value(ctxKeyLogAttrs{}).(*[]slog.Attr); ok && ptr != nil { 27 | return *ptr 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // SetError sets the error attribute on the request log. 34 | func SetError(ctx context.Context, err error) error { 35 | if err != nil { 36 | SetAttrs(ctx, slog.Any(ErrorKey, err)) 37 | } 38 | 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /curl.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // CURL returns a curl command for the given request and body. 10 | func CURL(req *http.Request, reqBody string) string { 11 | var b strings.Builder 12 | 13 | fmt.Fprintf(&b, "curl") 14 | if req.Method != "GET" && req.Method != "POST" { 15 | fmt.Fprintf(&b, " -X %s", req.Method) 16 | } 17 | 18 | fmt.Fprintf(&b, " %s", singleQuoted(requestURL(req))) 19 | 20 | if req.Method == "POST" { 21 | fmt.Fprintf(&b, " --data-raw %s", singleQuoted(reqBody)) 22 | } 23 | 24 | for name, vals := range req.Header { 25 | for _, val := range vals { 26 | fmt.Fprintf(&b, " -H %s", singleQuoted(fmt.Sprintf("%s: %s", name, val))) 27 | } 28 | } 29 | 30 | return b.String() 31 | } 32 | 33 | func singleQuoted(v string) string { 34 | return fmt.Sprintf("'%s'", strings.ReplaceAll(v, "'", `'\''`)) 35 | } 36 | 37 | func scheme(r *http.Request) string { 38 | if r.TLS != nil { 39 | return "https" 40 | } 41 | return "http" 42 | } 43 | 44 | func requestURL(r *http.Request) string { 45 | return fmt.Sprintf("%s://%s%s", scheme(r), r.Host, r.URL) 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka) 2 | Copyright (c) 2024-present Golang.cz, https://github.com/go-chi authors 3 | 4 | MIT License 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httplog 2 | 3 | > Structured HTTP request logging middleware for Go, built on the standard library `log/slog` package 4 | 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/go-chi/httplog/v3.svg)](https://pkg.go.dev/github.com/go-chi/httplog/v3) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/go-chi/httplog)](https://goreportcard.com/report/github.com/go-chi/httplog) 7 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 8 | 9 | `httplog` is a lightweight, high-performance HTTP request logging middleware for Go web applications. Built on Go 1.21+'s standard `log/slog` package, it provides structured logging with zero external dependencies. 10 | 11 | ## Features 12 | 13 | - **🚀 High Performance**: Minimal overhead 14 | - **📋 Structured Logging**: Built on Go's standard `log/slog` package 15 | - **🎯 Smart Log Levels**: Auto-assigns levels by status code (5xx = error, 4xx = warn) 16 | - **📊 Schema Support**: Compatible with ECS, OTEL, and GCP logging formats 17 | - **🛡️ Panic Recovery**: Recovers panics with stack traces and HTTP 500 responses 18 | - **🔍 Body Logging**: Conditional request/response body capture with content-type filtering 19 | - **📝 Custom Attributes**: Add log attributes from handlers and middlewares 20 | - **🎨 Developer Friendly**: Concise mode and `curl` command generation 21 | - **🔗 Router Agnostic**: Works with [Chi](https://github.com/go-chi/chi), Gin, Echo, and standard `http.ServeMux` 22 | 23 | ## Usage 24 | 25 | `go get github.com/go-chi/httplog/v3@latest` 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | "errors" 32 | "fmt" 33 | "log" 34 | "log/slog" 35 | "net/http" 36 | "os" 37 | 38 | "github.com/go-chi/chi/v5" 39 | "github.com/go-chi/chi/v5/middleware" 40 | "github.com/go-chi/httplog/v3" 41 | ) 42 | 43 | func main() { 44 | logFormat := httplog.SchemaECS.Concise(isLocalhost) 45 | 46 | logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 47 | ReplaceAttr: logFormat.ReplaceAttr, 48 | })).With( 49 | slog.String("app", "example-app"), 50 | slog.String("version", "v1.0.0-a1fa420"), 51 | slog.String("env", "production"), 52 | ) 53 | 54 | r := chi.NewRouter() 55 | 56 | // Request logger 57 | r.Use(httplog.RequestLogger(logger, &httplog.Options{ 58 | // Level defines the verbosity of the request logs: 59 | // slog.LevelDebug - log all responses (incl. OPTIONS) 60 | // slog.LevelInfo - log responses (excl. OPTIONS) 61 | // slog.LevelWarn - log 4xx and 5xx responses only (except for 429) 62 | // slog.LevelError - log 5xx responses only 63 | Level: slog.LevelInfo, 64 | 65 | // Set log output to Elastic Common Schema (ECS) format. 66 | Schema: httplog.SchemaECS, 67 | 68 | // RecoverPanics recovers from panics occurring in the underlying HTTP handlers 69 | // and middlewares. It returns HTTP 500 unless response status was already set. 70 | // 71 | // NOTE: Panics are logged as errors automatically, regardless of this setting. 72 | RecoverPanics: true, 73 | 74 | // Optionally, filter out some request logs. 75 | Skip: func(req *http.Request, respStatus int) bool { 76 | return respStatus == 404 || respStatus == 405 77 | }, 78 | 79 | // Optionally, log selected request/response headers explicitly. 80 | LogRequestHeaders: []string{"Origin"}, 81 | LogResponseHeaders: []string{}, 82 | 83 | // Optionally, enable logging of request/response body based on custom conditions. 84 | // Useful for debugging payload issues in development. 85 | LogRequestBody: isDebugHeaderSet, 86 | LogResponseBody: isDebugHeaderSet, 87 | })) 88 | 89 | // Set request log attribute from within middleware. 90 | r.Use(func(next http.Handler) http.Handler { 91 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 92 | ctx := r.Context() 93 | 94 | httplog.SetAttrs(ctx, slog.String("user", "user1")) 95 | 96 | next.ServeHTTP(w, r.WithContext(ctx)) 97 | }) 98 | }) 99 | 100 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 101 | w.Write([]byte("hello world \n")) 102 | }) 103 | 104 | http.ListenAndServe("localhost:8000", r) 105 | } 106 | 107 | func isDebugHeaderSet(r *http.Request) bool { 108 | return r.Header.Get("Debug") == "reveal-body-logs" 109 | } 110 | ``` 111 | 112 | ## Example 113 | 114 | See [_example/main.go](./_example/main.go) and try it locally: 115 | ```sh 116 | $ cd _example 117 | 118 | # JSON logger (production) 119 | $ go run . 120 | 121 | # Pretty logger (localhost) 122 | $ ENV=localhost go run . 123 | ``` 124 | 125 | ## License 126 | [MIT license](./LICENSE) 127 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | ) 7 | 8 | type Options struct { 9 | // Level defines the verbosity of the request logs: 10 | // slog.LevelDebug - log both request starts & responses (incl. OPTIONS) 11 | // slog.LevelInfo - log responses (excl. OPTIONS) 12 | // slog.LevelWarn - log 4xx and 5xx responses only (except for 429) 13 | // slog.LevelError - log 5xx responses only 14 | // 15 | // You can override the level with a custom slog.Handler (e.g. on per-request basis). 16 | Level slog.Level 17 | 18 | // Schema defines the mapping of semantic log fields to their corresponding 19 | // field names in different logging systems and standards. 20 | // 21 | // This enables log output in different formats compatible with various logging 22 | // platforms and standards (ECS, OTEL, GCP, etc.) by providing the schema. 23 | // 24 | // httplog.SchemaECS (Elastic Common Schema) 25 | // httplog.SchemaOTEL (OpenTelemetry) 26 | // httplog.SchemaGCP (Google Cloud Platform) 27 | // 28 | // Append .Concise(true) to reduce log verbosity (e.g. for localhost development). 29 | Schema *Schema 30 | 31 | // RecoverPanics recovers from panics occurring in the underlying HTTP handlers 32 | // and middlewares and returns HTTP 500 unless response status was already set. 33 | // 34 | // NOTE: Panics are logged as errors automatically, regardless of this setting. 35 | RecoverPanics bool 36 | 37 | // Skip is an optional predicate function that determines whether to skip 38 | // recording logs for a given request. 39 | // 40 | // If nil, all requests are recorded. 41 | // If provided, requests where Skip returns true will not be recorded. 42 | Skip func(req *http.Request, respStatus int) bool 43 | 44 | // LogRequestHeaders is a list of headers to be logged as attributes. 45 | // If not provided, the default is ["Content-Type", "Origin"]. 46 | // 47 | // WARNING: Do not leak any request headers with sensitive information. 48 | LogRequestHeaders []string 49 | 50 | // LogRequestBody is an optional predicate function that controls logging of request body. 51 | // 52 | // If the function returns true, the request body will be logged. 53 | // If false, no request body will be logged. 54 | // 55 | // WARNING: Do not leak any request bodies with sensitive information. 56 | LogRequestBody func(req *http.Request) bool 57 | 58 | // LogResponseHeaders controls a list of headers to be logged as attributes. 59 | // 60 | // If not provided, there are no default headers. 61 | LogResponseHeaders []string 62 | 63 | // LogRequestBody is an optional predicate function that controls logging of request body. 64 | // 65 | // If the function returns true, the request body will be logged. 66 | // If false, no request body will be logged. 67 | // 68 | // WARNING: Do not leak any response bodies with sensitive information. 69 | LogResponseBody func(req *http.Request) bool 70 | 71 | // LogBodyContentTypes defines a list of body Content-Types that are safe to be logged 72 | // with LogRequestBody or LogResponseBody options. 73 | // 74 | // If not provided, the default is ["application/json", "application/xml", "text/plain", "text/csv", "application/x-www-form-urlencoded", ""]. 75 | LogBodyContentTypes []string 76 | 77 | // LogBodyMaxLen defines the maximum length of the body to be logged. 78 | // 79 | // If not provided, the default is 1024 bytes. Set to -1 to log the full body. 80 | LogBodyMaxLen int 81 | 82 | // LogExtraAttrs is an optional function that lets you add extra attributes to the 83 | // request log. 84 | // 85 | // Example: 86 | // 87 | // // Log all requests with invalid payload as curl command. 88 | // func(req *http.Request, reqBody string, respStatus int) []slog.Attr { 89 | // if respStatus == 400 || respStatus == 422 { 90 | // req.Header.Del("Authorization") 91 | // return []slog.Attr{slog.String("curl", httplog.CURL(req, reqBody))} 92 | // } 93 | // return nil 94 | // } 95 | // 96 | // WARNING: Be careful not to leak any sensitive information in the logs. 97 | LogExtraAttrs func(req *http.Request, reqBody string, respStatus int) []slog.Attr 98 | } 99 | 100 | var defaultOptions = Options{ 101 | Level: slog.LevelInfo, 102 | Schema: SchemaECS, 103 | RecoverPanics: true, 104 | LogRequestHeaders: []string{"Content-Type", "Origin"}, 105 | LogResponseHeaders: []string{"Content-Type"}, 106 | LogBodyContentTypes: []string{"application/json", "application/xml", "text/plain", "text/csv", "application/x-www-form-urlencoded", ""}, 107 | LogBodyMaxLen: 1024, 108 | } 109 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | "github.com/go-chi/httplog/v3" 17 | "github.com/go-chi/traceid" 18 | "github.com/golang-cz/devslog" 19 | ) 20 | 21 | func main() { 22 | isLocalhost := os.Getenv("ENV") == "localhost" 23 | 24 | logFormat := httplog.SchemaECS.Concise(isLocalhost) 25 | 26 | logger := slog.New(logHandler(isLocalhost, &slog.HandlerOptions{ 27 | AddSource: !isLocalhost, 28 | ReplaceAttr: logFormat.ReplaceAttr, 29 | })) 30 | 31 | if !isLocalhost { 32 | logger = logger.With( 33 | slog.String("app", "example-app"), 34 | slog.String("version", "v1.0.0-a1fa420"), 35 | slog.String("env", "production"), 36 | ) 37 | } 38 | 39 | // Set as a default logger for both slog and log. 40 | slog.SetDefault(logger) 41 | slog.SetLogLoggerLevel(slog.LevelError) 42 | 43 | r := chi.NewRouter() 44 | r.Use(middleware.Heartbeat("/ping")) 45 | 46 | // Propagate or create new TraceId header. 47 | r.Use(traceid.Middleware) 48 | 49 | // Request logger. 50 | r.Use(httplog.RequestLogger(logger, &httplog.Options{ 51 | // Level defines the verbosity of the request logs: 52 | // slog.LevelDebug - log all responses (incl. OPTIONS) 53 | // slog.LevelInfo - log all responses (excl. OPTIONS) 54 | // slog.LevelWarn - log 4xx and 5xx responses only (except for 429) 55 | // slog.LevelError - log 5xx responses only 56 | Level: slog.LevelInfo, 57 | 58 | // Log attributes using given schema/format. 59 | Schema: logFormat, 60 | 61 | // RecoverPanics recovers from panics occurring in the underlying HTTP handlers 62 | // and middlewares. It returns HTTP 500 unless response status was already set. 63 | // 64 | // NOTE: Panics are logged as errors automatically, regardless of this setting. 65 | RecoverPanics: true, 66 | 67 | // Filter out some request logs. 68 | Skip: func(req *http.Request, respStatus int) bool { 69 | return respStatus == 404 || respStatus == 405 70 | }, 71 | 72 | // Select request/response headers to be logged explicitly. 73 | LogRequestHeaders: []string{"Origin"}, 74 | LogResponseHeaders: []string{}, 75 | 76 | // You can log request/request body conditionally. Useful for debugging. 77 | LogRequestBody: isDebugHeaderSet, 78 | LogResponseBody: isDebugHeaderSet, 79 | 80 | // Log all requests with invalid payload as curl command. 81 | LogExtraAttrs: func(req *http.Request, reqBody string, respStatus int) []slog.Attr { 82 | if respStatus == 400 || respStatus == 422 { 83 | req.Header.Del("Authorization") 84 | return []slog.Attr{slog.String("curl", httplog.CURL(req, reqBody))} 85 | } 86 | return nil 87 | }, 88 | })) 89 | 90 | // Set request log attribute from within middleware. 91 | r.Use(func(next http.Handler) http.Handler { 92 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | ctx := r.Context() 94 | 95 | httplog.SetAttrs(ctx, slog.String("user", "user1")) 96 | 97 | next.ServeHTTP(w, r.WithContext(ctx)) 98 | }) 99 | }) 100 | 101 | r.Get("/slow", func(w http.ResponseWriter, r *http.Request) { 102 | select { 103 | case <-time.After(5 * time.Second): 104 | w.Write([]byte("slow operation completed \n")) 105 | 106 | case <-r.Context().Done(): 107 | // client disconnected 108 | } 109 | }) 110 | 111 | r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { 112 | panic("oh no") 113 | }) 114 | 115 | r.Get("/info", func(w http.ResponseWriter, r *http.Request) { 116 | logger.InfoContext(r.Context(), "info here") 117 | 118 | w.Header().Add("Content-Type", "text/plain") 119 | w.Write([]byte("info here \n")) 120 | }) 121 | 122 | r.Get("/warn", func(w http.ResponseWriter, r *http.Request) { 123 | ctx := r.Context() 124 | 125 | logger.WarnContext(ctx, "warn here") 126 | 127 | w.Header().Add("Content-Type", "text/plain") 128 | w.WriteHeader(200) 129 | w.Write([]byte("warn here \n")) 130 | }) 131 | 132 | r.Get("/err", func(w http.ResponseWriter, r *http.Request) { 133 | ctx := r.Context() 134 | 135 | // Log error explicitly. 136 | err := errors.New("err here") 137 | logger.ErrorContext(ctx, "msg here", slog.String("error", err.Error())) 138 | 139 | // Logging with the global logger also works. 140 | slog.Default().With(slog.Group("group", slog.String("account", "id"))).ErrorContext(ctx, "doesn't exist") 141 | slog.Default().ErrorContext(ctx, "oops, error occurred") 142 | 143 | // Or, set the error attribute on the request log. 144 | httplog.SetError(ctx, err) 145 | 146 | w.Header().Set("Content-Type", "application/json") 147 | w.WriteHeader(500) 148 | w.Write([]byte(fmt.Sprintf(`{"error": "%v"}`, err))) 149 | }) 150 | 151 | r.Post("/string/to/upper", func(w http.ResponseWriter, r *http.Request) { 152 | ctx := r.Context() 153 | w.Header().Set("Content-Type", "application/json") 154 | 155 | var payload struct { 156 | Data string `json:"data"` 157 | } 158 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 159 | err = fmt.Errorf("invalid json: %w", err) 160 | 161 | w.WriteHeader(400) 162 | w.Write([]byte(fmt.Sprintf(`{"error": %q}`, httplog.SetError(ctx, err)))) 163 | return 164 | } 165 | if payload.Data == "" { 166 | err := errors.New("data field is required") 167 | 168 | w.WriteHeader(422) 169 | w.Write([]byte(fmt.Sprintf(`{"error": %q}`, httplog.SetError(ctx, err)))) 170 | return 171 | } 172 | 173 | payload.Data = strings.ToUpper(payload.Data) 174 | w.Header().Set("Content-Type", "application/json") 175 | json.NewEncoder(w).Encode(payload) 176 | }) 177 | 178 | if !isLocalhost { 179 | fmt.Println("Enable pretty logs with:") 180 | fmt.Println(" ENV=localhost go run ./") 181 | fmt.Println() 182 | } 183 | 184 | fmt.Println("Try these commands from a new terminal window:") 185 | fmt.Println(" curl -v http://localhost:8000/info") 186 | fmt.Println(" curl -v http://localhost:8000/warn") 187 | fmt.Println(" curl -v http://localhost:8000/err") 188 | fmt.Println(" curl -v http://localhost:8000/panic") 189 | fmt.Println(" curl -v http://localhost:8000/slow") 190 | fmt.Println(` curl -v http://localhost:8000/string/to/upper -X POST --json '{"data": "valid payload"}'`) 191 | fmt.Println(` curl -v http://localhost:8000/string/to/upper -X POST --json '{"data": "valid payload"}' -H "Debug: reveal-body-logs"`) 192 | fmt.Println(` curl -v http://localhost:8000/string/to/upper -X POST --json '{"xx": "invalid payload"}'`) 193 | fmt.Println() 194 | 195 | if err := http.ListenAndServe("localhost:8000", r); err != http.ErrAbortHandler { 196 | log.Fatal(err) 197 | } 198 | } 199 | 200 | func logHandler(isLocalhost bool, handlerOpts *slog.HandlerOptions) slog.Handler { 201 | if isLocalhost { 202 | // Pretty logs for localhost development. 203 | return devslog.NewHandler(os.Stdout, &devslog.Options{ 204 | SortKeys: true, 205 | MaxErrorStackTrace: 5, 206 | MaxSlicePrintSize: 20, 207 | HandlerOptions: handlerOpts, 208 | }) 209 | } 210 | 211 | // JSON logs for production with "traceId". 212 | return traceid.LogHandler( 213 | slog.NewJSONHandler(os.Stdout, handlerOpts), 214 | ) 215 | } 216 | 217 | func isDebugHeaderSet(r *http.Request) bool { 218 | return r.Header.Get("Debug") == "reveal-body-logs" 219 | } 220 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "runtime" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/go-chi/chi/v5/middleware" 17 | ) 18 | 19 | var ( 20 | ErrClientAborted = fmt.Errorf("request aborted: client disconnected before response was sent") 21 | ) 22 | 23 | func RequestLogger(logger *slog.Logger, o *Options) func(http.Handler) http.Handler { 24 | if o == nil { 25 | o = &defaultOptions 26 | } 27 | if len(o.LogBodyContentTypes) == 0 { 28 | o.LogBodyContentTypes = defaultOptions.LogBodyContentTypes 29 | } 30 | if o.LogBodyMaxLen == 0 { 31 | o.LogBodyMaxLen = defaultOptions.LogBodyMaxLen 32 | } 33 | s := o.Schema 34 | if s == nil { 35 | s = SchemaECS 36 | } 37 | 38 | return func(next http.Handler) http.Handler { 39 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | ctx := context.WithValue(r.Context(), ctxKeyLogAttrs{}, &[]slog.Attr{}) 41 | 42 | logReqBody := o.LogRequestBody != nil && o.LogRequestBody(r) 43 | logRespBody := o.LogResponseBody != nil && o.LogResponseBody(r) 44 | 45 | var reqBody bytes.Buffer 46 | if logReqBody || o.LogExtraAttrs != nil { 47 | r.Body = io.NopCloser(io.TeeReader(r.Body, &reqBody)) 48 | } 49 | 50 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 51 | 52 | var respBody bytes.Buffer 53 | if o.LogResponseBody != nil && o.LogResponseBody(r) { 54 | ww.Tee(&respBody) 55 | } 56 | 57 | start := time.Now() 58 | 59 | defer func() { 60 | var logAttrs []slog.Attr 61 | 62 | if rec := recover(); rec != nil { 63 | // Return HTTP 500 if recover is enabled and no response status was set. 64 | if o.RecoverPanics && ww.Status() == 0 && r.Header.Get("Connection") != "Upgrade" { 65 | ww.WriteHeader(http.StatusInternalServerError) 66 | } 67 | 68 | if rec == http.ErrAbortHandler || !o.RecoverPanics { 69 | // Re-panic http.ErrAbortHandler unconditionally, and re-panic other errors if panic recovery is disabled. 70 | defer panic(rec) 71 | } 72 | 73 | logAttrs = appendAttrs(logAttrs, slog.String(s.ErrorMessage, fmt.Sprintf("panic: %v", rec))) 74 | 75 | if rec != http.ErrAbortHandler { 76 | pc := make([]uintptr, 10) // Capture up to 10 stack frames. 77 | n := runtime.Callers(3, pc) // Skip 3 frames (this middleware + runtime/panic.go). 78 | pc = pc[:n] 79 | 80 | // Process panic stack frames to print detailed information. 81 | frames := runtime.CallersFrames(pc) 82 | var stackValues []string 83 | for frame, more := frames.Next(); more; frame, more = frames.Next() { 84 | if !strings.Contains(frame.File, "runtime/panic.go") { 85 | stackValues = append(stackValues, fmt.Sprintf("%s:%d", frame.File, frame.Line)) 86 | } 87 | } 88 | logAttrs = appendAttrs(logAttrs, slog.Any(s.ErrorStackTrace, stackValues)) 89 | } 90 | } 91 | 92 | duration := time.Since(start) 93 | statusCode := ww.Status() 94 | if statusCode == 0 { 95 | // If the handler never calls w.WriteHeader(statusCode) explicitly, 96 | // Go's http package automatically sends HTTP 200 OK to the client. 97 | statusCode = 200 98 | } 99 | 100 | // Skip logging if the request is filtered by the Skip function. 101 | if o.Skip != nil && o.Skip(r, statusCode) { 102 | return 103 | } 104 | 105 | var lvl slog.Level 106 | switch { 107 | case statusCode >= 500: 108 | lvl = slog.LevelError 109 | case statusCode == 429: 110 | lvl = slog.LevelInfo 111 | case statusCode >= 400: 112 | lvl = slog.LevelWarn 113 | case r.Method == "OPTIONS": 114 | lvl = slog.LevelDebug 115 | default: 116 | lvl = slog.LevelInfo 117 | } 118 | 119 | // Skip logging if the message level is below the logger's level or the minimum level specified in options 120 | if !logger.Enabled(ctx, lvl) || lvl < o.Level { 121 | return 122 | } 123 | 124 | logAttrs = appendAttrs(logAttrs, 125 | slog.String(s.RequestURL, requestURL(r)), 126 | slog.String(s.RequestMethod, r.Method), 127 | slog.String(s.RequestPath, r.URL.Path), 128 | slog.String(s.RequestRemoteIP, r.RemoteAddr), 129 | slog.String(s.RequestHost, r.Host), 130 | slog.String(s.RequestScheme, scheme(r)), 131 | slog.String(s.RequestProto, r.Proto), 132 | slog.Any(s.RequestHeaders, slog.GroupValue(getHeaderAttrs(r.Header, o.LogRequestHeaders)...)), 133 | slog.Int64(s.RequestBytes, r.ContentLength), 134 | slog.String(s.RequestUserAgent, r.UserAgent()), 135 | slog.String(s.RequestReferer, r.Referer()), 136 | slog.Any(s.ResponseHeaders, slog.GroupValue(getHeaderAttrs(ww.Header(), o.LogResponseHeaders)...)), 137 | slog.Int(s.ResponseStatus, statusCode), 138 | responseDuration(s.ResponseDuration, duration), 139 | slog.Int(s.ResponseBytes, ww.BytesWritten()), 140 | ) 141 | 142 | if err := ctx.Err(); errors.Is(err, context.Canceled) { 143 | logAttrs = appendAttrs(logAttrs, slog.Any(ErrorKey, ErrClientAborted), slog.String(s.ErrorType, "ClientAborted")) 144 | } 145 | 146 | if logReqBody || o.LogExtraAttrs != nil { 147 | // Ensure the request body is fully read if the underlying HTTP handler didn't do so. 148 | n, _ := io.Copy(io.Discard, r.Body) 149 | if n > 0 { 150 | logAttrs = appendAttrs(logAttrs, slog.Any(s.RequestBytesUnread, n)) 151 | } 152 | } 153 | if logReqBody { 154 | logAttrs = appendAttrs(logAttrs, slog.String(s.RequestBody, logBody(&reqBody, r.Header, o))) 155 | } 156 | if logRespBody { 157 | logAttrs = appendAttrs(logAttrs, slog.String(s.ResponseBody, logBody(&respBody, ww.Header(), o))) 158 | } 159 | if o.LogExtraAttrs != nil { 160 | logAttrs = appendAttrs(logAttrs, o.LogExtraAttrs(r, reqBody.String(), statusCode)...) 161 | } 162 | logAttrs = appendAttrs(logAttrs, getAttrs(ctx)...) 163 | 164 | // Group attributes into nested objects, e.g. for GCP structured logs. 165 | if s.GroupDelimiter != "" { 166 | logAttrs = groupAttrs(logAttrs, s.GroupDelimiter) 167 | } 168 | 169 | msg := fmt.Sprintf("%s %s => HTTP %v (%v)", r.Method, r.URL, statusCode, duration) 170 | logger.LogAttrs(ctx, lvl, msg, logAttrs...) 171 | }() 172 | 173 | next.ServeHTTP(ww, r.WithContext(ctx)) 174 | }) 175 | } 176 | } 177 | 178 | func responseDuration(key string, duration time.Duration) slog.Attr { 179 | switch key { 180 | case ECSResponseDuration: 181 | // https://www.elastic.co/docs/reference/ecs/ecs-event#field-event-duration 182 | return slog.Int64(key, duration.Nanoseconds()) 183 | case OTELResponseDuration: 184 | // https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration 185 | return slog.Float64(key, duration.Seconds()) 186 | case GCPResponseDuration: 187 | // https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest.FIELDS.latency 188 | return slog.String(key, strconv.FormatFloat(duration.Seconds(), 'f', -1, 64)+"s") 189 | default: 190 | return slog.Float64(key, float64(duration.Milliseconds())) 191 | } 192 | } 193 | 194 | func appendAttrs(attrs []slog.Attr, newAttrs ...slog.Attr) []slog.Attr { 195 | for _, attr := range newAttrs { 196 | if attr.Key != "" { 197 | attrs = append(attrs, attr) 198 | } 199 | } 200 | return attrs 201 | } 202 | 203 | func groupAttrs(attrs []slog.Attr, delimiter string) []slog.Attr { 204 | var result []slog.Attr 205 | var nested = map[string][]slog.Attr{} 206 | 207 | for _, attr := range attrs { 208 | prefix, key, found := strings.Cut(attr.Key, delimiter) 209 | if !found { 210 | result = append(result, attr) 211 | continue 212 | } 213 | nested[prefix] = append(nested[prefix], slog.Attr{Key: key, Value: attr.Value}) 214 | } 215 | 216 | for prefix, attrs := range nested { 217 | result = append(result, slog.Any(prefix, slog.GroupValue(attrs...))) 218 | } 219 | 220 | return result 221 | } 222 | 223 | func getHeaderAttrs(header http.Header, headers []string) []slog.Attr { 224 | attrs := make([]slog.Attr, 0, len(headers)) 225 | for _, h := range headers { 226 | vals := header.Values(h) 227 | if len(vals) == 1 { 228 | attrs = append(attrs, slog.String(h, vals[0])) 229 | } else if len(vals) > 1 { 230 | attrs = append(attrs, slog.Any(h, vals)) 231 | } 232 | } 233 | return attrs 234 | } 235 | 236 | func logBody(body *bytes.Buffer, header http.Header, o *Options) string { 237 | if body.Len() == 0 { 238 | return "" 239 | } 240 | contentType := header.Get("Content-Type") 241 | for _, whitelisted := range o.LogBodyContentTypes { 242 | if strings.HasPrefix(contentType, whitelisted) { 243 | if o.LogBodyMaxLen <= 0 || o.LogBodyMaxLen >= body.Len() { 244 | return body.String() 245 | } 246 | return body.String()[:o.LogBodyMaxLen] + "... [trimmed]" 247 | } 248 | } 249 | return fmt.Sprintf("[body redacted for Content-Type: %s]", contentType) 250 | } 251 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const ( 10 | ECSResponseDuration = "event.duration" 11 | OTELResponseDuration = "http.server.request.duration" 12 | GCPResponseDuration = "httpRequest:latency" 13 | ) 14 | 15 | // Schema defines the mapping of semantic log fields to their corresponding 16 | // field names in different logging systems and standards. 17 | // 18 | // This enables log output in different formats compatible with various logging 19 | // platforms and standards (ECS, OTEL, GCP, etc.) by providing the schema. 20 | type Schema struct { 21 | // Base attributes for core logging information. 22 | Timestamp string // Timestamp of the log entry 23 | Level string // Log level (e.g. INFO, WARNING, ERROR) 24 | Message string // Primary log message 25 | ErrorMessage string // Error message when an error occurs 26 | ErrorType string // Low-cardinality error type (e.g. "ClientAborted", "ValidationError") 27 | ErrorStackTrace string // Stack trace for panic or error 28 | 29 | // Source code location attributes for tracking origin of log statements. 30 | SourceFile string // Source file name where the log originated 31 | SourceLine string // Line number in the source file 32 | SourceFunction string // Function name where the log originated 33 | 34 | // Request attributes for the incoming HTTP request. 35 | // NOTE: RequestQuery is intentionally not supported as it would likely leak sensitive data. 36 | RequestURL string // Full request URL 37 | RequestMethod string // HTTP method (e.g. GET, POST) 38 | RequestPath string // URL path component 39 | RequestRemoteIP string // Client IP address 40 | RequestHost string // Host header value 41 | RequestScheme string // URL scheme (http, https) 42 | RequestProto string // HTTP protocol version (e.g. HTTP/1.1, HTTP/2) 43 | RequestHeaders string // Selected request headers 44 | RequestBody string // Request body content, if logged. 45 | RequestBytes string // Size of request body in bytes 46 | RequestBytesUnread string // Unread bytes in request body 47 | RequestUserAgent string // User-Agent header value 48 | RequestReferer string // Referer header value 49 | 50 | // Response attributes for the HTTP response. 51 | ResponseHeaders string // Selected response headers 52 | ResponseBody string // Response body content, if logged. 53 | ResponseStatus string // HTTP status code 54 | ResponseDuration string // Request processing duration 55 | ResponseBytes string // Size of response body in bytes 56 | 57 | // GroupDelimiter is an optional delimiter for nested objects in some formats. 58 | // For example, GCP uses nested JSON objects like "httpRequest": {}. 59 | GroupDelimiter string 60 | } 61 | 62 | var ( 63 | // SchemaECS represents the Elastic Common Schema (ECS) version 9.0.0. 64 | // This schema is widely used with Elasticsearch and the Elastic Stack. 65 | // 66 | // Reference: https://www.elastic.co/guide/en/ecs/current/ecs-http.html 67 | SchemaECS = &Schema{ 68 | Timestamp: "@timestamp", 69 | Level: "log.level", 70 | Message: "message", 71 | ErrorMessage: "error.message", 72 | ErrorType: "error.type", 73 | ErrorStackTrace: "error.stack_trace", 74 | SourceFile: "log.origin.file.name", 75 | SourceLine: "log.origin.file.line", 76 | SourceFunction: "log.origin.function", 77 | RequestURL: "url.full", 78 | RequestMethod: "http.request.method", 79 | RequestPath: "url.path", 80 | RequestRemoteIP: "client.ip", 81 | RequestHost: "url.domain", 82 | RequestScheme: "url.scheme", 83 | RequestProto: "http.version", 84 | RequestHeaders: "http.request.headers", 85 | RequestBody: "http.request.body.content", 86 | RequestBytes: "http.request.body.bytes", 87 | RequestBytesUnread: "http.request.body.unread.bytes", 88 | RequestUserAgent: "user_agent.original", 89 | RequestReferer: "http.request.referrer", 90 | ResponseHeaders: "http.response.headers", 91 | ResponseBody: "http.response.body.content", 92 | ResponseStatus: "http.response.status_code", 93 | ResponseDuration: ECSResponseDuration, 94 | ResponseBytes: "http.response.body.bytes", 95 | } 96 | 97 | // SchemaOTEL represents OpenTelemetry (OTEL) semantic conventions version 1.34.0. 98 | // This schema follows OpenTelemetry standards for observability data. 99 | // 100 | // Reference: https://opentelemetry.io/docs/specs/semconv/http/http-metrics 101 | SchemaOTEL = &Schema{ 102 | Timestamp: "timestamp", 103 | Level: "severity_text", 104 | Message: "body", 105 | ErrorMessage: "error.message", 106 | ErrorType: "error.type", 107 | ErrorStackTrace: "exception.stacktrace", 108 | SourceFile: "code.filepath", 109 | SourceLine: "code.lineno", 110 | SourceFunction: "code.function", 111 | RequestURL: "url.full", 112 | RequestMethod: "http.request.method", 113 | RequestPath: "url.path", 114 | RequestRemoteIP: "client.address", 115 | RequestHost: "server.address", 116 | RequestScheme: "url.scheme", 117 | RequestProto: "network.protocol.version", 118 | RequestHeaders: "http.request.header", 119 | RequestBody: "http.request.body.content", 120 | RequestBytes: "http.request.body.size", 121 | RequestBytesUnread: "http.request.body.unread.size", 122 | RequestUserAgent: "user_agent.original", 123 | RequestReferer: "http.request.header.referer", 124 | ResponseHeaders: "http.response.header", 125 | ResponseBody: "http.response.body.content", 126 | ResponseStatus: "http.response.status_code", 127 | ResponseDuration: OTELResponseDuration, 128 | ResponseBytes: "http.response.body.size", 129 | } 130 | 131 | // SchemaGCP represents Google Cloud Platform's structured logging format. 132 | // This schema is optimized for Google Cloud Logging service. 133 | // 134 | // References: 135 | // - https://cloud.google.com/logging/docs/structured-logging 136 | // - https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest 137 | SchemaGCP = &Schema{ 138 | Timestamp: "timestamp", 139 | Level: "severity", 140 | Message: "message", 141 | ErrorMessage: "error", 142 | ErrorType: "error_type", 143 | ErrorStackTrace: "stack_trace", 144 | SourceFile: "logging.googleapis.com/sourceLocation:file", 145 | SourceLine: "logging.googleapis.com/sourceLocation:line", 146 | SourceFunction: "logging.googleapis.com/sourceLocation:function", 147 | RequestURL: "httpRequest:requestUrl", 148 | RequestMethod: "httpRequest:requestMethod", 149 | RequestPath: "httpRequest:requestPath", 150 | RequestRemoteIP: "httpRequest:remoteIp", 151 | RequestHost: "httpRequest:host", 152 | RequestScheme: "httpRequest:scheme", 153 | RequestProto: "httpRequest:protocol", 154 | RequestHeaders: "httpRequest:requestHeaders", 155 | RequestBody: "httpRequest:requestBody", 156 | RequestBytes: "httpRequest:requestSize", 157 | RequestBytesUnread: "httpRequest:requestUnreadSize", 158 | RequestUserAgent: "httpRequest:userAgent", 159 | RequestReferer: "httpRequest:referer", 160 | ResponseHeaders: "httpRequest:responseHeaders", 161 | ResponseBody: "httpRequest:responseBody", 162 | ResponseStatus: "httpRequest:status", 163 | ResponseDuration: GCPResponseDuration, 164 | ResponseBytes: "httpRequest:responseSize", 165 | GroupDelimiter: ":", 166 | } 167 | ) 168 | 169 | // ReplaceAttr returns transforms standard slog attribute names to the schema format. 170 | func (s *Schema) ReplaceAttr(groups []string, a slog.Attr) slog.Attr { 171 | if len(groups) > 0 { 172 | return a 173 | } 174 | 175 | switch a.Key { 176 | case slog.TimeKey: 177 | if s.Timestamp == "" { 178 | return a 179 | } 180 | return slog.String(s.Timestamp, a.Value.Time().Format(time.RFC3339)) 181 | case slog.LevelKey: 182 | if s.Level == "" { 183 | return a 184 | } 185 | return slog.String(s.Level, a.Value.String()) 186 | case slog.MessageKey: 187 | if s.Message == "" { 188 | return a 189 | } 190 | return slog.String(s.Message, a.Value.String()) 191 | case slog.SourceKey: 192 | source, ok := a.Value.Any().(*slog.Source) 193 | if !ok { 194 | return a 195 | } 196 | 197 | if s.SourceFile == "" { 198 | // Ignore httplog.RequestLogger middleware source. 199 | if strings.Contains(source.File, "/go-chi/httplog/") { 200 | return slog.Attr{} 201 | } 202 | return a 203 | } 204 | 205 | if s.GroupDelimiter == "" { 206 | return slog.Group("", slog.String(s.SourceFile, source.File), slog.Int(s.SourceLine, source.Line), slog.String(s.SourceFunction, source.Function)) 207 | } 208 | 209 | grp, file, _ := strings.Cut(s.SourceFile, s.GroupDelimiter) 210 | _, line, _ := strings.Cut(s.SourceLine, s.GroupDelimiter) 211 | _, fn, _ := strings.Cut(s.SourceFunction, s.GroupDelimiter) 212 | return slog.Group(grp, slog.String(file, source.File), slog.Int(line, source.Line), slog.String(fn, source.Function)) 213 | 214 | case ErrorKey: 215 | if s.GroupDelimiter == "" { 216 | return slog.Attr{Key: s.ErrorMessage, Value: a.Value} 217 | } 218 | 219 | grp, errMsg, found := strings.Cut(s.ErrorMessage, s.GroupDelimiter) 220 | if !found { 221 | return slog.Attr{Key: s.ErrorMessage, Value: a.Value} 222 | } 223 | 224 | return slog.Group(grp, slog.Attr{Key: errMsg, Value: a.Value}) 225 | } 226 | 227 | return a 228 | } 229 | 230 | // Concise returns a simplified schema with essential fields only. 231 | // If concise is true, it reduces log verbosity. 232 | // 233 | // This is useful for localhost development to reduce log verbosity. 234 | func (s *Schema) Concise(concise bool) *Schema { 235 | if !concise { 236 | return s 237 | } 238 | 239 | return &Schema{ 240 | ErrorMessage: s.ErrorMessage, 241 | ErrorStackTrace: s.ErrorStackTrace, 242 | RequestHeaders: s.RequestHeaders, 243 | RequestBody: s.RequestBody, 244 | RequestBytesUnread: s.RequestBytesUnread, 245 | ResponseHeaders: s.ResponseHeaders, 246 | ResponseBody: s.ResponseBody, 247 | GroupDelimiter: s.GroupDelimiter, 248 | } 249 | } 250 | --------------------------------------------------------------------------------