├── .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 | [](https://github.com/samber/slog-chi/releases)
5 | 
6 | [](https://pkg.go.dev/github.com/samber/slog-chi)
7 | 
8 | [](https://goreportcard.com/report/github.com/samber/slog-chi)
9 | [](https://codecov.io/gh/samber/slog-chi)
10 | [](https://github.com/samber/slog-chi/graphs/contributors)
11 | [](./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 |
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 | 
391 |
392 | ## 💫 Show your support
393 |
394 | Give a ⭐️ if this project helped you!
395 |
396 | [](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 |
--------------------------------------------------------------------------------