├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── dump.go ├── example ├── example.go ├── go.mod └── go.sum ├── filters.go ├── go.mod ├── go.sum └── middleware.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [samber] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | golangci: 9 | name: lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/setup-go@v6 13 | with: 14 | go-version: 1.21 15 | stable: false 16 | - uses: actions/checkout@v5 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v8 19 | with: 20 | args: --timeout 120s --max-same-issues 50 21 | 22 | - name: Bearer 23 | uses: bearer/bearer-action@v2 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | semver: 7 | type: string 8 | description: 'Semver (eg: v1.2.3)' 9 | required: true 10 | 11 | jobs: 12 | release: 13 | if: github.triggering_actor == 'samber' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version: 1.21 22 | stable: false 23 | 24 | - name: Test 25 | run: make test 26 | 27 | # remove tests in order to clean dependencies 28 | - name: Remove xxx_test.go files 29 | run: rm -rf *_test.go ./examples ./images 30 | 31 | # cleanup test dependencies 32 | - name: Cleanup dependencies 33 | run: go mod tidy 34 | 35 | - name: List files 36 | run: tree -Cfi 37 | - name: Write new go.mod into logs 38 | run: cat go.mod 39 | - name: Write new go.sum into logs 40 | run: cat go.sum 41 | 42 | - name: Create tag 43 | run: | 44 | git config --global user.name '${{ github.triggering_actor }}' 45 | git config --global user.email "${{ github.triggering_actor}}@users.noreply.github.com" 46 | 47 | git add . 48 | git commit --allow-empty -m 'bump ${{ inputs.semver }}' 49 | git tag ${{ inputs.semver }} 50 | git push origin ${{ inputs.semver }} 51 | 52 | - name: Release 53 | uses: softprops/action-gh-release@v2 54 | with: 55 | name: ${{ inputs.semver }} 56 | tag_name: ${{ inputs.semver }} 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | tags: 6 | branches: 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go: 16 | - '1.21' 17 | - '1.22' 18 | - '1.23' 19 | - '1.24' 20 | - '1.x' 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v6 26 | with: 27 | go-version: ${{ matrix.go }} 28 | stable: false 29 | 30 | - name: Build 31 | run: make build 32 | 33 | - name: Test 34 | run: make test 35 | 36 | - name: Test 37 | run: make coverage 38 | 39 | - name: Codecov 40 | uses: codecov/codecov-action@v5 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | file: ./cover.out 44 | flags: unittests 45 | verbose: true 46 | if: matrix.go == '1.21' 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/go 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 4 | 5 | ### Go ### 6 | # If you prefer the allow list template instead of the deny list, see community template: 7 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 8 | # 9 | # Binaries for programs and plugins 10 | *.exe 11 | *.exe~ 12 | *.dll 13 | *.so 14 | *.dylib 15 | 16 | # Test binary, built with `go test -c` 17 | *.test 18 | 19 | # Output of the go coverage tool, specifically when used with LiteIDE 20 | *.out 21 | 22 | # Dependency directories (remove the comment below to include it) 23 | # vendor/ 24 | 25 | # Go workspace file 26 | go.work 27 | 28 | ### Go Patch ### 29 | /vendor/ 30 | /Godeps/ 31 | 32 | # End of https://www.toptal.com/developers/gitignore/api/go 33 | 34 | cover.out 35 | cover.html 36 | .vscode 37 | 38 | .idea/ 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Samuel Berthe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | build: 3 | go build -v ./... 4 | 5 | test: 6 | go test -race -v ./... 7 | watch-test: 8 | reflex -t 50ms -s -- sh -c 'gotest -race -v ./...' 9 | 10 | bench: 11 | go test -benchmem -count 3 -bench ./... 12 | watch-bench: 13 | reflex -t 50ms -s -- sh -c 'go test -benchmem -count 3 -bench ./...' 14 | 15 | coverage: 16 | go test -v -coverprofile=cover.out -covermode=atomic ./... 17 | go tool cover -html=cover.out -o cover.html 18 | 19 | tools: 20 | go install github.com/cespare/reflex@latest 21 | go install github.com/rakyll/gotest@latest 22 | go install github.com/psampaz/go-mod-outdated@latest 23 | go install github.com/jondot/goweight@latest 24 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 25 | go get -t -u golang.org/x/tools/cmd/cover 26 | go install github.com/sonatype-nexus-community/nancy@latest 27 | go mod tidy 28 | 29 | lint: 30 | golangci-lint run --timeout 60s --max-same-issues 50 ./... 31 | lint-fix: 32 | golangci-lint run --timeout 60s --max-same-issues 50 --fix ./... 33 | 34 | audit: 35 | go list -json -m all | nancy sleuth 36 | 37 | outdated: 38 | go list -u -m -json all | go-mod-outdated -update -direct 39 | 40 | weight: 41 | goweight 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # slog: Chi middleware 3 | 4 | [![tag](https://img.shields.io/github/tag/samber/slog-chi.svg)](https://github.com/samber/slog-chi/releases) 5 | ![Go Version](https://img.shields.io/badge/Go-%3E%3D%201.21-%23007d9c) 6 | [![GoDoc](https://godoc.org/github.com/samber/slog-chi?status.svg)](https://pkg.go.dev/github.com/samber/slog-chi) 7 | ![Build Status](https://github.com/samber/slog-chi/actions/workflows/test.yml/badge.svg) 8 | [![Go report](https://goreportcard.com/badge/github.com/samber/slog-chi)](https://goreportcard.com/report/github.com/samber/slog-chi) 9 | [![Coverage](https://img.shields.io/codecov/c/github/samber/slog-chi)](https://codecov.io/gh/samber/slog-chi) 10 | [![Contributors](https://img.shields.io/github/contributors/samber/slog-chi)](https://github.com/samber/slog-chi/graphs/contributors) 11 | [![License](https://img.shields.io/github/license/samber/slog-chi)](./LICENSE) 12 | 13 | [Chi](https://github.com/go-chi/chi) middleware to log http requests using [slog](https://pkg.go.dev/log/slog). 14 | 15 |
16 |
17 | Sponsored by: 18 |
19 | 20 |
21 | Dash0 22 |
23 |
24 | 100% OpenTelemetry-native observability platform
Simple to use, built on open standards, and designed for full cost control 25 |
26 |
27 |
28 |
29 | 30 | **See also:** 31 | 32 | - [slog-multi](https://github.com/samber/slog-multi): `slog.Handler` chaining, fanout, routing, failover, load balancing... 33 | - [slog-formatter](https://github.com/samber/slog-formatter): `slog` attribute formatting 34 | - [slog-sampling](https://github.com/samber/slog-sampling): `slog` sampling policy 35 | - [slog-mock](https://github.com/samber/slog-mock): `slog.Handler` for test purposes 36 | 37 | **HTTP middlewares:** 38 | 39 | - [slog-gin](https://github.com/samber/slog-gin): Gin middleware for `slog` logger 40 | - [slog-echo](https://github.com/samber/slog-echo): Echo middleware for `slog` logger 41 | - [slog-fiber](https://github.com/samber/slog-fiber): Fiber middleware for `slog` logger 42 | - [slog-chi](https://github.com/samber/slog-chi): Chi middleware for `slog` logger 43 | - [slog-http](https://github.com/samber/slog-http): `net/http` middleware for `slog` logger 44 | 45 | **Loggers:** 46 | 47 | - [slog-zap](https://github.com/samber/slog-zap): A `slog` handler for `Zap` 48 | - [slog-zerolog](https://github.com/samber/slog-zerolog): A `slog` handler for `Zerolog` 49 | - [slog-logrus](https://github.com/samber/slog-logrus): A `slog` handler for `Logrus` 50 | 51 | **Log sinks:** 52 | 53 | - [slog-datadog](https://github.com/samber/slog-datadog): A `slog` handler for `Datadog` 54 | - [slog-betterstack](https://github.com/samber/slog-betterstack): A `slog` handler for `Betterstack` 55 | - [slog-rollbar](https://github.com/samber/slog-rollbar): A `slog` handler for `Rollbar` 56 | - [slog-loki](https://github.com/samber/slog-loki): A `slog` handler for `Loki` 57 | - [slog-sentry](https://github.com/samber/slog-sentry): A `slog` handler for `Sentry` 58 | - [slog-syslog](https://github.com/samber/slog-syslog): A `slog` handler for `Syslog` 59 | - [slog-logstash](https://github.com/samber/slog-logstash): A `slog` handler for `Logstash` 60 | - [slog-fluentd](https://github.com/samber/slog-fluentd): A `slog` handler for `Fluentd` 61 | - [slog-graylog](https://github.com/samber/slog-graylog): A `slog` handler for `Graylog` 62 | - [slog-quickwit](https://github.com/samber/slog-quickwit): A `slog` handler for `Quickwit` 63 | - [slog-slack](https://github.com/samber/slog-slack): A `slog` handler for `Slack` 64 | - [slog-telegram](https://github.com/samber/slog-telegram): A `slog` handler for `Telegram` 65 | - [slog-mattermost](https://github.com/samber/slog-mattermost): A `slog` handler for `Mattermost` 66 | - [slog-microsoft-teams](https://github.com/samber/slog-microsoft-teams): A `slog` handler for `Microsoft Teams` 67 | - [slog-webhook](https://github.com/samber/slog-webhook): A `slog` handler for `Webhook` 68 | - [slog-kafka](https://github.com/samber/slog-kafka): A `slog` handler for `Kafka` 69 | - [slog-nats](https://github.com/samber/slog-nats): A `slog` handler for `NATS` 70 | - [slog-parquet](https://github.com/samber/slog-parquet): A `slog` handler for `Parquet` + `Object Storage` 71 | - [slog-channel](https://github.com/samber/slog-channel): A `slog` handler for Go channels 72 | 73 | ## 🚀 Install 74 | 75 | ```sh 76 | go get github.com/samber/slog-chi 77 | ``` 78 | 79 | **Compatibility**: go >= 1.21 80 | 81 | No breaking changes will be made to exported APIs before v2.0.0. 82 | 83 | ## 💡 Usage 84 | 85 | ### Handler options 86 | 87 | ```go 88 | type Config struct { 89 | DefaultLevel slog.Level 90 | ClientErrorLevel slog.Level 91 | ServerErrorLevel slog.Level 92 | 93 | WithUserAgent bool 94 | WithRequestID bool 95 | WithRequestBody bool 96 | WithRequestHeader bool 97 | WithResponseBody bool 98 | WithResponseHeader bool 99 | WithSpanID bool 100 | WithTraceID bool 101 | 102 | Filters []Filter 103 | } 104 | ``` 105 | 106 | Attributes will be injected in log payload. 107 | 108 | Other global parameters: 109 | 110 | ```go 111 | slogchi.TraceIDKey = "trace_id" 112 | slogchi.SpanIDKey = "span_id" 113 | slogchi.RequestIDKey = "id" 114 | slogchi.RequestBodyMaxSize = 64 * 1024 // 64KB 115 | slogchi.ResponseBodyMaxSize = 64 * 1024 // 64KB 116 | slogchi.HiddenRequestHeaders = map[string]struct{}{ ... } 117 | slogchi.HiddenResponseHeaders = map[string]struct{}{ ... } 118 | ``` 119 | 120 | ### Minimal 121 | 122 | ```go 123 | import ( 124 | "net/http" 125 | "os" 126 | "time" 127 | 128 | "github.com/go-chi/chi/v5" 129 | "github.com/go-chi/chi/v5/middleware" 130 | slogchi "github.com/samber/slog-chi" 131 | "log/slog" 132 | ) 133 | 134 | // Create a slog logger, which: 135 | // - Logs to stdout. 136 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 137 | 138 | // Chi instance 139 | router := chi.NewRouter() 140 | 141 | // Middleware 142 | router.Use(slogchi.New(logger)) 143 | router.Use(middleware.Recoverer) 144 | 145 | // Routes 146 | router.GET("/", func(w http.ResponseWriter, r *http.Request) { 147 | w.Write([]byte("Hello, World!")) 148 | }) 149 | router.GET("/error", func(w http.ResponseWriter, r *http.Request) { 150 | http.Error(w, http.StatusText(400), 400) 151 | }) 152 | 153 | // Start server 154 | err := http.ListenAndServe(":4242", router) 155 | 156 | // output: 157 | // time=2023-10-15T20:32:58.926+02:00 level=INFO msg="200: OK" env=production request.time=2023-10-15T20:32:58.626+02:00 request.method=GET request.path=/ request.route="" request.ip=127.0.0.1:63932 request.length=0 response.time=2023-10-15T20:32:58.926+02:00 response.latency=100ms response.status=200 response.length=7 id="" 158 | ``` 159 | 160 | ### OTEL 161 | 162 | ```go 163 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 164 | 165 | config := slogchi.Config{ 166 | WithSpanID: true, 167 | WithTraceID: true, 168 | } 169 | 170 | router := chi.NewRouter() 171 | router.Use(slogchi.NewWithConfig(logger, config)) 172 | router.Use(middleware.Recoverer) 173 | ``` 174 | 175 | ### Custom log levels 176 | 177 | ```go 178 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 179 | 180 | config := slogchi.Config{ 181 | DefaultLevel: slog.LevelInfo, 182 | ClientErrorLevel: slog.LevelWarn, 183 | ServerErrorLevel: slog.LevelError, 184 | } 185 | 186 | router := chi.NewRouter() 187 | router.Use(slogchi.NewWithConfig(logger, config)) 188 | router.Use(middleware.Recoverer) 189 | ``` 190 | 191 | ### Verbose 192 | 193 | ```go 194 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 195 | 196 | config := slogchi.Config{ 197 | WithRequestBody: true, 198 | WithResponseBody: true, 199 | WithRequestHeader: true, 200 | WithResponseHeader: true, 201 | } 202 | 203 | router := chi.NewRouter() 204 | router.Use(slogchi.NewWithConfig(logger, config)) 205 | router.Use(middleware.Recoverer) 206 | ``` 207 | 208 | ### Filters 209 | 210 | ```go 211 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 212 | 213 | router := chi.NewRouter() 214 | router.Use( 215 | slogchi.NewWithFilters( 216 | logger, 217 | slogchi.Accept(func (ww middleware.WrapResponseWriter, r *http.Request) bool { 218 | return xxx 219 | }), 220 | slogchi.IgnoreStatus(401, 404), 221 | ), 222 | ) 223 | router.Use(middleware.Recoverer) 224 | ``` 225 | 226 | Available filters: 227 | - Accept / Ignore 228 | - AcceptMethod / IgnoreMethod 229 | - AcceptStatus / IgnoreStatus 230 | - AcceptStatusGreaterThan / IgnoreStatusGreaterThan 231 | - AcceptStatusLessThan / IgnoreStatusLessThan 232 | - AcceptStatusGreaterThanOrEqual / IgnoreStatusGreaterThanOrEqual 233 | - AcceptStatusLessThanOrEqual / IgnoreStatusLessThanOrEqual 234 | - AcceptPath / IgnorePath 235 | - AcceptPathContains / IgnorePathContains 236 | - AcceptPathPrefix / IgnorePathPrefix 237 | - AcceptPathSuffix / IgnorePathSuffix 238 | - AcceptPathMatch / IgnorePathMatch 239 | - AcceptHost / IgnoreHost 240 | - AcceptHostContains / IgnoreHostContains 241 | - AcceptHostPrefix / IgnoreHostPrefix 242 | - AcceptHostSuffix / IgnoreHostSuffix 243 | - AcceptHostMatch / IgnoreHostMatch 244 | 245 | ### Using custom time formatters 246 | 247 | ```go 248 | import ( 249 | "github.com/go-chi/chi/v5" 250 | "github.com/go-chi/chi/v5/middleware" 251 | slogchi "github.com/samber/slog-chi" 252 | slogformatter "github.com/samber/slog-formatter" 253 | "log/slog" 254 | ) 255 | 256 | // Create a slog logger, which: 257 | // - Logs to stdout. 258 | // - RFC3339 with UTC time format. 259 | logger := slog.New( 260 | slogformatter.NewFormatterHandler( 261 | slogformatter.TimezoneConverter(time.UTC), 262 | slogformatter.TimeFormatter(time.DateTime, nil), 263 | )( 264 | slog.NewTextHandler(os.Stdout, nil), 265 | ), 266 | ) 267 | 268 | // Chi instance 269 | router := chi.NewRouter() 270 | 271 | // Middleware 272 | router.Use(slogchi.New(logger)) 273 | router.Use(middleware.Recoverer) 274 | 275 | // Routes 276 | router.GET("/", func(w http.ResponseWriter, r *http.Request) { 277 | w.Write([]byte("Hello, World!")) 278 | }) 279 | router.GET("/error", func(w http.ResponseWriter, r *http.Request) { 280 | http.Error(w, http.StatusText(400), 400) 281 | }) 282 | 283 | // Start server 284 | err := http.ListenAndServe(":4242", router) 285 | 286 | // output: 287 | // time=2023-10-15T20:32:58.926+02:00 level=INFO msg="200: OK" env=production request.time=2023-10-15T20:32:58.626Z request.method=GET request.path=/ request.route="" request.ip=127.0.0.1:63932 request.length=0 response.time=2023-10-15T20:32:58Z response.latency=100ms response.status=200 response.length=7 id="" 288 | ``` 289 | 290 | ### Using custom logger sub-group 291 | 292 | ```go 293 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 294 | 295 | // Chi instance 296 | router := chi.NewRouter() 297 | 298 | // Middleware 299 | router.Use(slogchi.New(logger.WithGroup("http"))) 300 | router.Use(middleware.Recoverer) 301 | 302 | // Routes 303 | router.GET("/", func(w http.ResponseWriter, r *http.Request) { 304 | w.Write([]byte("Hello, World!")) 305 | }) 306 | router.GET("/error", func(w http.ResponseWriter, r *http.Request) { 307 | http.Error(w, http.StatusText(400), 400) 308 | }) 309 | 310 | // Start server 311 | err := http.ListenAndServe(":4242", router) 312 | 313 | // output: 314 | // time=2023-10-15T20:32:58.926+02:00 level=INFO msg="200: OK" env=production http.request.time=2023-10-15T20:32:58.626+02:00 http.request.method=GET http.request.path=/ http.request.route="" http.request.ip=127.0.0.1:63932 http.request.length=0 http.response.time=2023-10-15T20:32:58.926+02:00 http.response.latency=100ms http.response.status=200 http.response.length=7 http.id="" 315 | ``` 316 | 317 | ### Adding custom attributes 318 | 319 | ```go 320 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 321 | 322 | // Add an attribute to all log entries made through this logger. 323 | logger = logger.With("env", "production") 324 | 325 | // Chi instance 326 | router := chi.NewRouter() 327 | 328 | // Middleware 329 | router.Use(slogchi.New(logger)) 330 | router.Use(middleware.Recoverer) 331 | 332 | // Routes 333 | router.GET("/", func(w http.ResponseWriter, r *http.Request) { 334 | // Add an attribute to a single log entry. 335 | slogchi.AddCustomAttributes(r, slog.String("foo", "bar")) 336 | w.Write([]byte("Hello, World!")) 337 | }) 338 | 339 | // Start server 340 | err := http.ListenAndServe(":4242", router) 341 | 342 | // output: 343 | // time=2023-10-15T20:32:58.926+02:00 level=INFO msg="200: OK" env=production request.time=2023-10-15T20:32:58.626+02:00 request.method=GET request.path=/ request.route="" request.ip=127.0.0.1:63932 request.length=0 response.time=2023-10-15T20:32:58.926+02:00 response.latency=100ms response.status=200 response.length=7 id="" foo=bar 344 | ``` 345 | 346 | ### JSON output 347 | 348 | ```go 349 | logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 350 | 351 | // Chi instance 352 | router := chi.NewRouter() 353 | 354 | // Middleware 355 | router.Use(slogchi.New(logger)) 356 | router.Use(middleware.Recoverer) 357 | 358 | // Routes 359 | router.GET("/", func(w http.ResponseWriter, r *http.Request) { 360 | w.Write([]byte("Hello, World!")) 361 | }) 362 | 363 | // Start server 364 | err := http.ListenAndServe(":4242", router) 365 | 366 | // output: 367 | // {"time":"2023-10-15T20:32:58.926+02:00","level":"INFO","msg":"200: OK","env":"production","http":{"request":{"time":"2023-10-15T20:32:58.626+02:00","method":"GET","path":"/","route":"","ip":"127.0.0.1:55296","length":0},"response":{"time":"2023-10-15T20:32:58.926+02:00","latency":100000,"status":200,"length":7},"id":""}} 368 | ``` 369 | 370 | ## 🤝 Contributing 371 | 372 | - Ping me on twitter [@samuelberthe](https://twitter.com/samuelberthe) (DMs, mentions, whatever :)) 373 | - Fork the [project](https://github.com/samber/slog-chi) 374 | - Fix [open issues](https://github.com/samber/slog-chi/issues) or request new features 375 | 376 | Don't hesitate ;) 377 | 378 | ```bash 379 | # Install some dev dependencies 380 | make tools 381 | 382 | # Run tests 383 | make test 384 | # or 385 | make watch-test 386 | ``` 387 | 388 | ## 👤 Contributors 389 | 390 | ![Contributors](https://contrib.rocks/image?repo=samber/slog-chi) 391 | 392 | ## 💫 Show your support 393 | 394 | Give a ⭐️ if this project helped you! 395 | 396 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/samber?style=for-the-badge)](https://github.com/sponsors/samber) 397 | 398 | ## 📝 License 399 | 400 | Copyright © 2023 [Samuel Berthe](https://github.com/samber). 401 | 402 | This project is [MIT](./LICENSE) licensed. 403 | -------------------------------------------------------------------------------- /dump.go: -------------------------------------------------------------------------------- 1 | package slogchi 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | type bodyWriter struct { 9 | body *bytes.Buffer 10 | maxSize int 11 | } 12 | 13 | // implements io.Writer 14 | func (w *bodyWriter) Write(b []byte) (int, error) { 15 | length := len(b) 16 | 17 | if w.body.Len()+length > w.maxSize { 18 | w.body.Truncate(min(w.maxSize, length, w.body.Len())) 19 | return w.body.Write(b[:min(w.maxSize-w.body.Len(), length)]) 20 | } 21 | return w.body.Write(b) 22 | } 23 | 24 | func newBodyWriter(maxSize int) *bodyWriter { 25 | return &bodyWriter{ 26 | body: bytes.NewBufferString(""), 27 | maxSize: maxSize, 28 | } 29 | } 30 | 31 | type bodyReader struct { 32 | io.ReadCloser 33 | body *bytes.Buffer 34 | maxSize int 35 | bytes int 36 | } 37 | 38 | // implements io.Reader 39 | func (r *bodyReader) Read(b []byte) (int, error) { 40 | n, err := r.ReadCloser.Read(b) 41 | if r.body != nil && r.body.Len() < r.maxSize { 42 | if r.body.Len()+n > r.maxSize { 43 | r.body.Write(b[:min(r.maxSize-r.body.Len(), n)]) 44 | } else { 45 | r.body.Write(b[:n]) 46 | } 47 | } 48 | r.bytes += n 49 | return n, err 50 | } 51 | 52 | func newBodyReader(reader io.ReadCloser, maxSize int, recordBody bool) *bodyReader { 53 | var body *bytes.Buffer 54 | if recordBody { 55 | body = bytes.NewBufferString("") 56 | } 57 | return &bodyReader{ 58 | ReadCloser: reader, 59 | body: body, 60 | maxSize: maxSize, 61 | bytes: 0, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /example/example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "log/slog" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/chi/v5/middleware" 13 | slogchi "github.com/samber/slog-chi" 14 | slogformatter "github.com/samber/slog-formatter" 15 | ) 16 | 17 | func main() { 18 | // Create a slog logger, which: 19 | // - Logs to stdout. 20 | // - RFC3339 with UTC time format. 21 | logger := slog.New( 22 | slogformatter.NewFormatterHandler( 23 | slogformatter.TimezoneConverter(time.UTC), 24 | slogformatter.TimeFormatter(time.RFC3339, nil), 25 | )( 26 | slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}), 27 | ), 28 | ) 29 | 30 | // Add an attribute to all log entries made through this logger. 31 | logger = logger.With("env", "production") 32 | 33 | // Chi instance 34 | r := chi.NewRouter() 35 | 36 | // Middleware 37 | // config := slogchi.Config{WithRequestBody: true, WithResponseBody: true, WithRequestHeader: true, WithResponseHeader: true} 38 | // r.Use(slogchi.NewWithConfig(logger, config)) 39 | r.Use(slogchi.New(logger.WithGroup("http"))) 40 | r.Use(middleware.Recoverer) 41 | 42 | // Routes 43 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 44 | w.Write([]byte("welcome")) 45 | }) 46 | r.Post("/foobar/{id}", func(w http.ResponseWriter, r *http.Request) { 47 | _, _ = io.ReadAll(r.Body) 48 | slogchi.AddCustomAttributes(r, slog.String("foo", "bar")) 49 | w.Write([]byte("welcome")) 50 | }) 51 | r.Get("/error", func(w http.ResponseWriter, r *http.Request) { 52 | http.Error(w, http.StatusText(400), 400) 53 | }) 54 | 55 | // Start server 56 | err := http.ListenAndServe(":4242", r) 57 | if err != nil { 58 | logger.Error(err.Error()) 59 | } 60 | 61 | // output: 62 | // time=2023-10-15T20:32:58.926+02:00 level=INFO msg=OK env=production http.time=2023-10-15T18:32:58Z http.latency=20.834µs http.method=GET http.path=/ http.status=200 http.user-agent=curl/7.77.0 63 | } 64 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.21 4 | 5 | replace github.com/samber/slog-chi => ../ 6 | 7 | require ( 8 | github.com/go-chi/chi/v5 v5.2.2 9 | github.com/samber/slog-chi v1.0.0 10 | github.com/samber/slog-formatter v1.0.0 11 | ) 12 | 13 | require ( 14 | github.com/samber/lo v1.47.0 // indirect 15 | github.com/samber/slog-multi v1.0.0 // indirect 16 | go.opentelemetry.io/otel v1.29.0 // indirect 17 | go.opentelemetry.io/otel/trace v1.29.0 // indirect 18 | golang.org/x/text v0.16.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= 4 | github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 10 | github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 11 | github.com/samber/slog-formatter v1.0.0 h1:ULxHV+jNqi6aFP8xtzGHl2ejFRMl2+jI2UhCpgoXTDA= 12 | github.com/samber/slog-formatter v1.0.0/go.mod h1:c7pRfwhCfZQNzJz+XirmTveElxXln7M0Y8Pq781uxlo= 13 | github.com/samber/slog-multi v1.0.0 h1:snvP/P5GLQ8TQh5WSqdRaxDANW8AAA3egwEoytLsqvc= 14 | github.com/samber/slog-multi v1.0.0/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo= 15 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 16 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 17 | go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 18 | go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 19 | go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 20 | go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 21 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 22 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /filters.go: -------------------------------------------------------------------------------- 1 | package slogchi 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/go-chi/chi/v5/middleware" 10 | ) 11 | 12 | type Filter func(ww middleware.WrapResponseWriter, r *http.Request) bool 13 | 14 | // Basic 15 | func Accept(filter Filter) Filter { return filter } 16 | func Ignore(filter Filter) Filter { 17 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { return !filter(ww, r) } 18 | } 19 | 20 | // Method 21 | func AcceptMethod(methods ...string) Filter { 22 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 23 | reqMethod := strings.ToLower(r.Method) 24 | 25 | for _, method := range methods { 26 | if strings.ToLower(method) == reqMethod { 27 | return true 28 | } 29 | } 30 | 31 | return false 32 | } 33 | } 34 | 35 | func IgnoreMethod(methods ...string) Filter { 36 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 37 | reqMethod := strings.ToLower(r.Method) 38 | 39 | for _, method := range methods { 40 | if strings.ToLower(method) == reqMethod { 41 | return false 42 | } 43 | } 44 | 45 | return true 46 | } 47 | } 48 | 49 | // Status 50 | func AcceptStatus(statuses ...int) Filter { 51 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 52 | return slices.Contains(statuses, ww.Status()) 53 | } 54 | } 55 | 56 | func IgnoreStatus(statuses ...int) Filter { 57 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 58 | return !slices.Contains(statuses, ww.Status()) 59 | } 60 | } 61 | 62 | func AcceptStatusGreaterThan(status int) Filter { 63 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 64 | return ww.Status() > status 65 | } 66 | } 67 | 68 | func AcceptStatusGreaterThanOrEqual(status int) Filter { 69 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 70 | return ww.Status() >= status 71 | } 72 | } 73 | 74 | func AcceptStatusLessThan(status int) Filter { 75 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 76 | return ww.Status() < status 77 | } 78 | } 79 | 80 | func AcceptStatusLessThanOrEqual(status int) Filter { 81 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 82 | return ww.Status() <= status 83 | } 84 | } 85 | 86 | func IgnoreStatusGreaterThan(status int) Filter { 87 | return AcceptStatusLessThanOrEqual(status) 88 | } 89 | 90 | func IgnoreStatusGreaterThanOrEqual(status int) Filter { 91 | return AcceptStatusLessThan(status) 92 | } 93 | 94 | func IgnoreStatusLessThan(status int) Filter { 95 | return AcceptStatusGreaterThanOrEqual(status) 96 | } 97 | 98 | func IgnoreStatusLessThanOrEqual(status int) Filter { 99 | return AcceptStatusGreaterThan(status) 100 | } 101 | 102 | // Path 103 | func AcceptPath(urls ...string) Filter { 104 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 105 | return slices.Contains(urls, r.URL.Path) 106 | } 107 | } 108 | 109 | func IgnorePath(urls ...string) Filter { 110 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 111 | return !slices.Contains(urls, r.URL.Path) 112 | } 113 | } 114 | 115 | func AcceptPathContains(parts ...string) Filter { 116 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 117 | for _, part := range parts { 118 | if strings.Contains(r.URL.Path, part) { 119 | return true 120 | } 121 | } 122 | 123 | return false 124 | } 125 | } 126 | 127 | func IgnorePathContains(parts ...string) Filter { 128 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 129 | for _, part := range parts { 130 | if strings.Contains(r.URL.Path, part) { 131 | return false 132 | } 133 | } 134 | 135 | return true 136 | } 137 | } 138 | 139 | func AcceptPathPrefix(prefixs ...string) Filter { 140 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 141 | for _, prefix := range prefixs { 142 | if strings.HasPrefix(r.URL.Path, prefix) { 143 | return true 144 | } 145 | } 146 | 147 | return false 148 | } 149 | } 150 | 151 | func IgnorePathPrefix(prefixs ...string) Filter { 152 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 153 | for _, prefix := range prefixs { 154 | if strings.HasPrefix(r.URL.Path, prefix) { 155 | return false 156 | } 157 | } 158 | 159 | return true 160 | } 161 | } 162 | 163 | func AcceptPathSuffix(prefixs ...string) Filter { 164 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 165 | for _, prefix := range prefixs { 166 | if strings.HasPrefix(r.URL.Path, prefix) { 167 | return true 168 | } 169 | } 170 | 171 | return false 172 | } 173 | } 174 | 175 | func IgnorePathSuffix(suffixs ...string) Filter { 176 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 177 | for _, suffix := range suffixs { 178 | if strings.HasSuffix(r.URL.Path, suffix) { 179 | return false 180 | } 181 | } 182 | 183 | return true 184 | } 185 | } 186 | 187 | func AcceptPathMatch(regs ...regexp.Regexp) Filter { 188 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 189 | for _, reg := range regs { 190 | if reg.Match([]byte(r.URL.Path)) { 191 | return true 192 | } 193 | } 194 | 195 | return false 196 | } 197 | } 198 | 199 | func IgnorePathMatch(regs ...regexp.Regexp) Filter { 200 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 201 | for _, reg := range regs { 202 | if reg.Match([]byte(r.URL.Path)) { 203 | return false 204 | } 205 | } 206 | 207 | return true 208 | } 209 | } 210 | 211 | // Host 212 | func AcceptHost(hosts ...string) Filter { 213 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 214 | return slices.Contains(hosts, r.URL.Host) 215 | } 216 | } 217 | 218 | func IgnoreHost(hosts ...string) Filter { 219 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 220 | return !slices.Contains(hosts, r.URL.Host) 221 | } 222 | } 223 | 224 | func AcceptHostContains(parts ...string) Filter { 225 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 226 | for _, part := range parts { 227 | if strings.Contains(r.URL.Host, part) { 228 | return true 229 | } 230 | } 231 | 232 | return false 233 | } 234 | } 235 | 236 | func IgnoreHostContains(parts ...string) Filter { 237 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 238 | for _, part := range parts { 239 | if strings.Contains(r.URL.Host, part) { 240 | return false 241 | } 242 | } 243 | 244 | return true 245 | } 246 | } 247 | 248 | func AcceptHostPrefix(prefixs ...string) Filter { 249 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 250 | for _, prefix := range prefixs { 251 | if strings.HasPrefix(r.URL.Host, prefix) { 252 | return true 253 | } 254 | } 255 | 256 | return false 257 | } 258 | } 259 | 260 | func IgnoreHostPrefix(prefixs ...string) Filter { 261 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 262 | for _, prefix := range prefixs { 263 | if strings.HasPrefix(r.URL.Host, prefix) { 264 | return false 265 | } 266 | } 267 | 268 | return true 269 | } 270 | } 271 | 272 | func AcceptHostSuffix(prefixs ...string) Filter { 273 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 274 | for _, prefix := range prefixs { 275 | if strings.HasPrefix(r.URL.Host, prefix) { 276 | return true 277 | } 278 | } 279 | 280 | return false 281 | } 282 | } 283 | 284 | func IgnoreHostSuffix(suffixs ...string) Filter { 285 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 286 | for _, suffix := range suffixs { 287 | if strings.HasSuffix(r.URL.Host, suffix) { 288 | return false 289 | } 290 | } 291 | 292 | return true 293 | } 294 | } 295 | 296 | func AcceptHostMatch(regs ...regexp.Regexp) Filter { 297 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 298 | for _, reg := range regs { 299 | if reg.Match([]byte(r.URL.Host)) { 300 | return true 301 | } 302 | } 303 | 304 | return false 305 | } 306 | } 307 | 308 | func IgnoreHostMatch(regs ...regexp.Regexp) Filter { 309 | return func(ww middleware.WrapResponseWriter, r *http.Request) bool { 310 | for _, reg := range regs { 311 | if reg.Match([]byte(r.URL.Host)) { 312 | return false 313 | } 314 | } 315 | 316 | return true 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samber/slog-chi 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.2.3 7 | go.opentelemetry.io/otel/trace v1.29.0 8 | ) 9 | 10 | require go.opentelemetry.io/otel v1.29.0 // indirect 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 4 | github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 12 | go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 13 | go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 14 | go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package slogchi 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/go-chi/chi/v5/middleware" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | type customAttributesCtxKeyType struct{} 18 | 19 | var customAttributesCtxKey = customAttributesCtxKeyType{} 20 | 21 | var ( 22 | TraceIDKey = "trace_id" 23 | SpanIDKey = "span_id" 24 | RequestIDKey = "id" 25 | 26 | RequestBodyMaxSize = 64 * 1024 // 64KB 27 | ResponseBodyMaxSize = 64 * 1024 // 64KB 28 | 29 | HiddenRequestHeaders = map[string]struct{}{ 30 | "authorization": {}, 31 | "cookie": {}, 32 | "set-cookie": {}, 33 | "x-auth-token": {}, 34 | "x-csrf-token": {}, 35 | "x-xsrf-token": {}, 36 | } 37 | HiddenResponseHeaders = map[string]struct{}{ 38 | "set-cookie": {}, 39 | } 40 | ) 41 | 42 | type Config struct { 43 | DefaultLevel slog.Level 44 | ClientErrorLevel slog.Level 45 | ServerErrorLevel slog.Level 46 | 47 | WithUserAgent bool 48 | WithRequestID bool 49 | WithRequestBody bool 50 | WithRequestHeader bool 51 | WithResponseBody bool 52 | WithResponseHeader bool 53 | WithSpanID bool 54 | WithTraceID bool 55 | 56 | Filters []Filter 57 | } 58 | 59 | // New returns a `func(http.Handler) http.Handler` (middleware) that logs requests using slog. 60 | // 61 | // Requests with errors are logged using slog.Error(). 62 | // Requests without errors are logged using slog.Info(). 63 | func New(logger *slog.Logger) func(http.Handler) http.Handler { 64 | return NewWithConfig(logger, Config{ 65 | DefaultLevel: slog.LevelInfo, 66 | ClientErrorLevel: slog.LevelWarn, 67 | ServerErrorLevel: slog.LevelError, 68 | 69 | WithUserAgent: false, 70 | WithRequestID: true, 71 | WithRequestBody: false, 72 | WithRequestHeader: false, 73 | WithResponseBody: false, 74 | WithResponseHeader: false, 75 | WithSpanID: false, 76 | WithTraceID: false, 77 | 78 | Filters: []Filter{}, 79 | }) 80 | } 81 | 82 | // NewWithFilters returns a `func(http.Handler) http.Handler` (middleware) that logs requests using slog. 83 | // 84 | // Requests with errors are logged using slog.Error(). 85 | // Requests without errors are logged using slog.Info(). 86 | func NewWithFilters(logger *slog.Logger, filters ...Filter) func(http.Handler) http.Handler { 87 | return NewWithConfig(logger, Config{ 88 | DefaultLevel: slog.LevelInfo, 89 | ClientErrorLevel: slog.LevelWarn, 90 | ServerErrorLevel: slog.LevelError, 91 | 92 | WithUserAgent: false, 93 | WithRequestID: true, 94 | WithRequestBody: false, 95 | WithRequestHeader: false, 96 | WithResponseBody: false, 97 | WithResponseHeader: false, 98 | WithSpanID: false, 99 | WithTraceID: false, 100 | 101 | Filters: filters, 102 | }) 103 | } 104 | 105 | // NewWithConfig returns a `func(http.Handler) http.Handler` (middleware) that logs requests using slog. 106 | func NewWithConfig(logger *slog.Logger, config Config) func(http.Handler) http.Handler { 107 | return func(next http.Handler) http.Handler { 108 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 109 | start := time.Now() 110 | path := r.URL.Path 111 | query := r.URL.RawQuery 112 | 113 | // dump request body 114 | br := newBodyReader(r.Body, RequestBodyMaxSize, config.WithRequestBody) 115 | r.Body = br 116 | 117 | // dump response body 118 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 119 | var bw *bodyWriter 120 | if config.WithResponseBody { 121 | bw = newBodyWriter(ResponseBodyMaxSize) 122 | ww.Tee(bw) 123 | } 124 | 125 | // Make sure we create a map only once per request (in case we have multiple middleware instances) 126 | if v := r.Context().Value(customAttributesCtxKey); v == nil { 127 | r = r.WithContext(context.WithValue(r.Context(), customAttributesCtxKey, &sync.Map{})) 128 | } 129 | 130 | defer func() { 131 | // Pass thru filters and skip early the code below, to prevent unnecessary processing. 132 | for _, filter := range config.Filters { 133 | if !filter(ww, r) { 134 | return 135 | } 136 | } 137 | 138 | params := map[string]string{} 139 | for i, k := range chi.RouteContext(r.Context()).URLParams.Keys { 140 | params[k] = chi.RouteContext(r.Context()).URLParams.Values[i] 141 | } 142 | 143 | status := ww.Status() 144 | method := r.Method 145 | host := r.Host 146 | route := chi.RouteContext(r.Context()).RoutePattern() 147 | end := time.Now() 148 | latency := end.Sub(start) 149 | userAgent := r.UserAgent() 150 | ip := r.RemoteAddr 151 | referer := r.Referer() 152 | 153 | baseAttributes := []slog.Attr{} 154 | 155 | requestAttributes := []slog.Attr{ 156 | slog.Time("time", start.UTC()), 157 | slog.String("method", method), 158 | slog.String("host", host), 159 | slog.String("path", path), 160 | slog.String("query", query), 161 | slog.Any("params", params), 162 | slog.String("route", route), 163 | slog.String("ip", ip), 164 | slog.String("referer", referer), 165 | } 166 | 167 | responseAttributes := []slog.Attr{ 168 | slog.Time("time", end.UTC()), 169 | slog.Duration("latency", latency), 170 | slog.Int("status", status), 171 | } 172 | 173 | if config.WithRequestID { 174 | baseAttributes = append(baseAttributes, slog.String(RequestIDKey, middleware.GetReqID(r.Context()))) 175 | } 176 | 177 | // otel 178 | baseAttributes = append(baseAttributes, extractTraceSpanID(r.Context(), config.WithTraceID, config.WithSpanID)...) 179 | 180 | // request body 181 | requestAttributes = append(requestAttributes, slog.Int("length", br.bytes)) 182 | if config.WithRequestBody { 183 | requestAttributes = append(requestAttributes, slog.String("body", br.body.String())) 184 | } 185 | 186 | // request headers 187 | if config.WithRequestHeader { 188 | kv := []any{} 189 | 190 | for k, v := range r.Header { 191 | if _, found := HiddenRequestHeaders[strings.ToLower(k)]; found { 192 | continue 193 | } 194 | kv = append(kv, slog.Any(k, v)) 195 | } 196 | 197 | requestAttributes = append(requestAttributes, slog.Group("header", kv...)) 198 | } 199 | 200 | if config.WithUserAgent { 201 | requestAttributes = append(requestAttributes, slog.String("user-agent", userAgent)) 202 | } 203 | 204 | // response body 205 | responseAttributes = append(responseAttributes, slog.Int("length", ww.BytesWritten())) 206 | if config.WithResponseBody { 207 | responseAttributes = append(responseAttributes, slog.String("body", bw.body.String())) 208 | } 209 | 210 | // response headers 211 | if config.WithResponseHeader { 212 | kv := []any{} 213 | 214 | for k, v := range w.Header() { 215 | if _, found := HiddenResponseHeaders[strings.ToLower(k)]; found { 216 | continue 217 | } 218 | kv = append(kv, slog.Any(k, v)) 219 | } 220 | 221 | responseAttributes = append(responseAttributes, slog.Group("header", kv...)) 222 | } 223 | 224 | attributes := append( 225 | []slog.Attr{ 226 | { 227 | Key: "request", 228 | Value: slog.GroupValue(requestAttributes...), 229 | }, 230 | { 231 | Key: "response", 232 | Value: slog.GroupValue(responseAttributes...), 233 | }, 234 | }, 235 | baseAttributes..., 236 | ) 237 | 238 | // custom context values 239 | if v := r.Context().Value(customAttributesCtxKey); v != nil { 240 | if m, ok := v.(*sync.Map); ok { 241 | m.Range(func(key, value any) bool { 242 | attributes = append(attributes, slog.Attr{Key: key.(string), Value: value.(slog.Value)}) 243 | return true 244 | }) 245 | } 246 | } 247 | 248 | level := config.DefaultLevel 249 | if status >= http.StatusInternalServerError { 250 | level = config.ServerErrorLevel 251 | } else if status >= http.StatusBadRequest && status < http.StatusInternalServerError { 252 | level = config.ClientErrorLevel 253 | } 254 | 255 | logger.LogAttrs(r.Context(), level, strconv.Itoa(status)+": "+http.StatusText(status), attributes...) 256 | }() 257 | 258 | next.ServeHTTP(ww, r) 259 | }) 260 | } 261 | } 262 | 263 | // AddCustomAttributes adds custom attributes to the request context. This func can be called from any handler or middleware, as long as the slog-chi middleware is already mounted. 264 | func AddCustomAttributes(r *http.Request, attrs ...slog.Attr) { 265 | AddContextAttributes(r.Context(), attrs...) 266 | } 267 | 268 | // AddContextAttributes is the same as AddCustomAttributes, but it doesn't need access to the request struct. 269 | func AddContextAttributes(ctx context.Context, attrs ...slog.Attr) { 270 | if v := ctx.Value(customAttributesCtxKey); v != nil { 271 | if m, ok := v.(*sync.Map); ok { 272 | for _, attr := range attrs { 273 | m.Store(attr.Key, attr.Value) 274 | } 275 | } 276 | } 277 | } 278 | 279 | func extractTraceSpanID(ctx context.Context, withTraceID bool, withSpanID bool) []slog.Attr { 280 | if !withTraceID && !withSpanID { 281 | return []slog.Attr{} 282 | } 283 | 284 | span := trace.SpanFromContext(ctx) 285 | if !span.IsRecording() { 286 | return []slog.Attr{} 287 | } 288 | 289 | attrs := []slog.Attr{} 290 | spanCtx := span.SpanContext() 291 | 292 | if withTraceID && spanCtx.HasTraceID() { 293 | traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String() 294 | attrs = append(attrs, slog.String(TraceIDKey, traceID)) 295 | } 296 | 297 | if withSpanID && spanCtx.HasSpanID() { 298 | spanID := spanCtx.SpanID().String() 299 | attrs = append(attrs, slog.String(SpanIDKey, spanID)) 300 | } 301 | 302 | return attrs 303 | } 304 | --------------------------------------------------------------------------------