├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── test.yml │ └── release.yml ├── main_test.go ├── .gitignore ├── go.mod ├── LICENSE ├── Makefile ├── example └── example.go ├── go.sum ├── filters.go ├── middleware.go └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [samber] 2 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package slogfiber 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.uber.org/goleak" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | goleak.VerifyTestMain(m) 11 | } 12 | -------------------------------------------------------------------------------- /.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@v6 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samber/slog-fiber 2 | 3 | go 1.21 4 | 5 | require golang.org/x/text v0.22.0 // indirect 6 | 7 | require ( 8 | github.com/andybalholm/brotli v1.1.1 // indirect 9 | github.com/klauspost/compress v1.17.11 // indirect 10 | github.com/mattn/go-colorable v0.1.13 // indirect 11 | github.com/mattn/go-isatty v0.0.20 // indirect 12 | github.com/mattn/go-runewidth v0.0.16 // indirect 13 | github.com/rivo/uniseg v0.2.0 // indirect 14 | github.com/samber/lo v1.49.1 // indirect 15 | github.com/samber/slog-formatter v1.2.0 16 | github.com/samber/slog-multi v1.3.3 // indirect 17 | github.com/valyala/bytebufferpool v1.0.0 // indirect 18 | github.com/valyala/fasthttp v1.59.0 19 | go.opentelemetry.io/otel v1.29.0 // indirect 20 | golang.org/x/sys v0.30.0 // indirect 21 | ) 22 | 23 | require ( 24 | github.com/gofiber/fiber/v2 v2.52.10 25 | github.com/google/uuid v1.6.0 26 | go.opentelemetry.io/otel/trace v1.29.0 27 | go.uber.org/goleak v1.3.0 28 | ) 29 | -------------------------------------------------------------------------------- /.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@v6 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 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 | -------------------------------------------------------------------------------- /example/example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "log/slog" 9 | 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/gofiber/fiber/v2/middleware/recover" 12 | slogfiber "github.com/samber/slog-fiber" 13 | slogformatter "github.com/samber/slog-formatter" 14 | ) 15 | 16 | func main() { 17 | // Create a slog logger, which: 18 | // - Logs to stdout. 19 | // - RFC3339 with UTC time format. 20 | logger := slog.New( 21 | slogformatter.NewFormatterHandler( 22 | slogformatter.TimezoneConverter(time.UTC), 23 | slogformatter.TimeFormatter(time.RFC3339, nil), 24 | )( 25 | slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}), 26 | ), 27 | ) 28 | 29 | // Add an attribute to all log entries made through this logger. 30 | logger = logger.With("env", "production") 31 | 32 | app := fiber.New() 33 | 34 | app.Use(slogfiber.New(logger.WithGroup("http"))) 35 | // config := slogfiber.Config{WithRequestBody: true, WithResponseBody: true, WithRequestHeader: true, WithResponseHeader: true} 36 | // app.Use(slogfiber.NewWithConfig(logger, config)) 37 | app.Use(recover.New()) 38 | 39 | app.Get("/", func(c *fiber.Ctx) error { 40 | slogfiber.AddCustomAttributes(c, slog.String("foo", "bar")) 41 | return c.SendString("Hello, World 👋!") 42 | }) 43 | 44 | app.Get("/crashme", func(c *fiber.Ctx) error { 45 | return c.Status(400).SendString("Oops i crashed :(") 46 | }) 47 | 48 | app.Get("/foobar/:id", func(c *fiber.Ctx) error { 49 | return c.SendString("Hello, World 👋!") 50 | }) 51 | 52 | app.Post("/bad", func(c *fiber.Ctx) error { 53 | return c.SendStatus(fiber.StatusBadRequest) 54 | }) 55 | app.Get("/die", func(c *fiber.Ctx) error { 56 | panic("killed") 57 | }) 58 | app.Post("/force", func(c *fiber.Ctx) error { 59 | return fiber.NewError(fiber.StatusUnauthorized) 60 | }) 61 | 62 | // 404 Handler 63 | app.Use(func(c *fiber.Ctx) error { 64 | return c.SendStatus(fiber.StatusNotFound) 65 | }) 66 | 67 | err := app.Listen(":4242") 68 | if err != nil { 69 | fmt.Println(err.Error()) 70 | } 71 | 72 | // output: 73 | // time=2023-04-10T14:00:00.000+00:00 level=INFO msg="Incoming request" env=production http.status=200 http.method=GET http.path=/ http.route=/ http.ip=::1 http.latency=25.958µs http.user-agent=curl/7.77.0 http.time=2023-04-10T14:00:00Z http.request-id=229c7fc8-64f5-4467-bc4a-940700503b0d 74 | } 75 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 2 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= 6 | github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 12 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 13 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 14 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 15 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 16 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 17 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 18 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 19 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 23 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 24 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= 25 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 26 | github.com/samber/slog-formatter v1.2.0 h1:gTSHm4CxyySyhcxRkzk21CSKbGCdZVipbRMhINkNtQU= 27 | github.com/samber/slog-formatter v1.2.0/go.mod h1:hgjhSd5Vf69XCOnVp0UW0QHCxJ8iDEm/qASjji6FNoI= 28 | github.com/samber/slog-multi v1.3.3 h1:qhFXaYdW73FIWLt8SrXMXfPwY58NpluzKDwRdPvhWWY= 29 | github.com/samber/slog-multi v1.3.3/go.mod h1:ACuZ5B6heK57TfMVkVknN2UZHoFfjCwRxR0Q2OXKHlo= 30 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 31 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 32 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 33 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 34 | github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= 35 | github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= 36 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 37 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 38 | go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 39 | go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 40 | go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 41 | go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 42 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 43 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 44 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 47 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 48 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 49 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 50 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | -------------------------------------------------------------------------------- /filters.go: -------------------------------------------------------------------------------- 1 | package slogfiber 2 | 3 | import ( 4 | "regexp" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | ) 10 | 11 | type Filter func(ctx *fiber.Ctx) bool 12 | 13 | // Basic 14 | func Accept(filter Filter) Filter { return filter } 15 | func Ignore(filter Filter) Filter { return func(ctx *fiber.Ctx) bool { return !filter(ctx) } } 16 | 17 | // Method 18 | func AcceptMethod(methods ...string) Filter { 19 | return func(c *fiber.Ctx) bool { 20 | reqMethod := strings.ToLower(string(c.Context().Method())) 21 | 22 | for _, method := range methods { 23 | if strings.ToLower(method) == reqMethod { 24 | return true 25 | } 26 | } 27 | 28 | return false 29 | } 30 | } 31 | 32 | func IgnoreMethod(methods ...string) Filter { 33 | return func(c *fiber.Ctx) bool { 34 | reqMethod := strings.ToLower(string(c.Context().Method())) 35 | 36 | for _, method := range methods { 37 | if strings.ToLower(method) == reqMethod { 38 | return false 39 | } 40 | } 41 | 42 | return true 43 | } 44 | } 45 | 46 | // Status 47 | func AcceptStatus(statuses ...int) Filter { 48 | return func(c *fiber.Ctx) bool { 49 | return slices.Contains(statuses, c.Response().StatusCode()) 50 | } 51 | } 52 | 53 | func IgnoreStatus(statuses ...int) Filter { 54 | return func(c *fiber.Ctx) bool { 55 | return !slices.Contains(statuses, c.Response().StatusCode()) 56 | } 57 | } 58 | 59 | func AcceptStatusGreaterThan(status int) Filter { 60 | return func(c *fiber.Ctx) bool { 61 | return c.Response().StatusCode() > status 62 | } 63 | } 64 | 65 | func AcceptStatusGreaterThanOrEqual(status int) Filter { 66 | return func(c *fiber.Ctx) bool { 67 | return c.Response().StatusCode() >= status 68 | } 69 | } 70 | 71 | func AcceptStatusLessThan(status int) Filter { 72 | return func(c *fiber.Ctx) bool { 73 | return c.Response().StatusCode() < status 74 | } 75 | } 76 | 77 | func AcceptStatusLessThanOrEqual(status int) Filter { 78 | return func(c *fiber.Ctx) bool { 79 | return c.Response().StatusCode() <= status 80 | } 81 | } 82 | 83 | func IgnoreStatusGreaterThan(status int) Filter { 84 | return AcceptStatusLessThanOrEqual(status) 85 | } 86 | 87 | func IgnoreStatusGreaterThanOrEqual(status int) Filter { 88 | return AcceptStatusLessThan(status) 89 | } 90 | 91 | func IgnoreStatusLessThan(status int) Filter { 92 | return AcceptStatusGreaterThanOrEqual(status) 93 | } 94 | 95 | func IgnoreStatusLessThanOrEqual(status int) Filter { 96 | return AcceptStatusGreaterThan(status) 97 | } 98 | 99 | // Path 100 | func AcceptPath(urls ...string) Filter { 101 | return func(c *fiber.Ctx) bool { 102 | return slices.Contains(urls, c.Path()) 103 | } 104 | } 105 | 106 | func IgnorePath(urls ...string) Filter { 107 | return func(c *fiber.Ctx) bool { 108 | return !slices.Contains(urls, c.Path()) 109 | } 110 | } 111 | 112 | func AcceptPathContains(parts ...string) Filter { 113 | return func(c *fiber.Ctx) bool { 114 | for _, part := range parts { 115 | if strings.Contains(c.Path(), part) { 116 | return true 117 | } 118 | } 119 | 120 | return false 121 | } 122 | } 123 | 124 | func IgnorePathContains(parts ...string) Filter { 125 | return func(c *fiber.Ctx) bool { 126 | for _, part := range parts { 127 | if strings.Contains(c.Path(), part) { 128 | return false 129 | } 130 | } 131 | 132 | return true 133 | } 134 | } 135 | 136 | func AcceptPathPrefix(prefixs ...string) Filter { 137 | return func(c *fiber.Ctx) bool { 138 | for _, prefix := range prefixs { 139 | if strings.HasPrefix(c.Path(), prefix) { 140 | return true 141 | } 142 | } 143 | 144 | return false 145 | } 146 | } 147 | 148 | func IgnorePathPrefix(prefixs ...string) Filter { 149 | return func(c *fiber.Ctx) bool { 150 | for _, prefix := range prefixs { 151 | if strings.HasPrefix(c.Path(), prefix) { 152 | return false 153 | } 154 | } 155 | 156 | return true 157 | } 158 | } 159 | 160 | func AcceptPathSuffix(prefixs ...string) Filter { 161 | return func(c *fiber.Ctx) bool { 162 | for _, prefix := range prefixs { 163 | if strings.HasPrefix(c.Path(), prefix) { 164 | return true 165 | } 166 | } 167 | 168 | return false 169 | } 170 | } 171 | 172 | func IgnorePathSuffix(suffixs ...string) Filter { 173 | return func(c *fiber.Ctx) bool { 174 | for _, suffix := range suffixs { 175 | if strings.HasSuffix(c.Path(), suffix) { 176 | return false 177 | } 178 | } 179 | 180 | return true 181 | } 182 | } 183 | 184 | func AcceptPathMatch(regs ...regexp.Regexp) Filter { 185 | return func(c *fiber.Ctx) bool { 186 | for _, reg := range regs { 187 | if reg.Match([]byte(c.Path())) { 188 | return true 189 | } 190 | } 191 | 192 | return false 193 | } 194 | } 195 | 196 | func IgnorePathMatch(regs ...regexp.Regexp) Filter { 197 | return func(c *fiber.Ctx) bool { 198 | for _, reg := range regs { 199 | if reg.Match([]byte(c.Path())) { 200 | return false 201 | } 202 | } 203 | 204 | return true 205 | } 206 | } 207 | 208 | // Host 209 | func AcceptHost(hosts ...string) Filter { 210 | return func(c *fiber.Ctx) bool { 211 | return slices.Contains(hosts, c.Hostname()) 212 | } 213 | } 214 | 215 | func IgnoreHost(hosts ...string) Filter { 216 | return func(c *fiber.Ctx) bool { 217 | return !slices.Contains(hosts, c.Hostname()) 218 | } 219 | } 220 | 221 | func AcceptHostContains(parts ...string) Filter { 222 | return func(c *fiber.Ctx) bool { 223 | for _, part := range parts { 224 | if strings.Contains(c.Hostname(), part) { 225 | return true 226 | } 227 | } 228 | 229 | return false 230 | } 231 | } 232 | 233 | func IgnoreHostContains(parts ...string) Filter { 234 | return func(c *fiber.Ctx) bool { 235 | for _, part := range parts { 236 | if strings.Contains(c.Hostname(), part) { 237 | return false 238 | } 239 | } 240 | 241 | return true 242 | } 243 | } 244 | 245 | func AcceptHostPrefix(prefixs ...string) Filter { 246 | return func(c *fiber.Ctx) bool { 247 | for _, prefix := range prefixs { 248 | if strings.HasPrefix(c.Hostname(), prefix) { 249 | return true 250 | } 251 | } 252 | 253 | return false 254 | } 255 | } 256 | 257 | func IgnoreHostPrefix(prefixs ...string) Filter { 258 | return func(c *fiber.Ctx) bool { 259 | for _, prefix := range prefixs { 260 | if strings.HasPrefix(c.Hostname(), prefix) { 261 | return false 262 | } 263 | } 264 | 265 | return true 266 | } 267 | } 268 | 269 | func AcceptHostSuffix(prefixs ...string) Filter { 270 | return func(c *fiber.Ctx) bool { 271 | for _, prefix := range prefixs { 272 | if strings.HasPrefix(c.Hostname(), prefix) { 273 | return true 274 | } 275 | } 276 | 277 | return false 278 | } 279 | } 280 | 281 | func IgnoreHostSuffix(suffixs ...string) Filter { 282 | return func(c *fiber.Ctx) bool { 283 | for _, suffix := range suffixs { 284 | if strings.HasSuffix(c.Hostname(), suffix) { 285 | return false 286 | } 287 | } 288 | 289 | return true 290 | } 291 | } 292 | 293 | func AcceptHostMatch(regs ...regexp.Regexp) Filter { 294 | return func(c *fiber.Ctx) bool { 295 | for _, reg := range regs { 296 | if reg.Match([]byte(c.Hostname())) { 297 | return true 298 | } 299 | } 300 | 301 | return false 302 | } 303 | } 304 | 305 | func IgnoreHostMatch(regs ...regexp.Regexp) Filter { 306 | return func(c *fiber.Ctx) bool { 307 | for _, reg := range regs { 308 | if reg.Match([]byte(c.Hostname())) { 309 | return false 310 | } 311 | } 312 | 313 | return true 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package slogfiber 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "log/slog" 12 | 13 | "github.com/gofiber/fiber/v2" 14 | "github.com/google/uuid" 15 | "github.com/valyala/fasthttp" 16 | "go.opentelemetry.io/otel/trace" 17 | ) 18 | 19 | type customAttributesCtxKeyType struct{} 20 | 21 | var customAttributesCtxKey = customAttributesCtxKeyType{} 22 | 23 | var ( 24 | TraceIDKey = "trace_id" 25 | SpanIDKey = "span_id" 26 | RequestIDKey = "id" 27 | 28 | RequestBodyMaxSize = 64 * 1024 // 64KB 29 | ResponseBodyMaxSize = 64 * 1024 // 64KB 30 | 31 | HiddenRequestHeaders = map[string]struct{}{ 32 | "authorization": {}, 33 | "cookie": {}, 34 | "set-cookie": {}, 35 | "x-auth-token": {}, 36 | "x-csrf-token": {}, 37 | "x-xsrf-token": {}, 38 | } 39 | HiddenResponseHeaders = map[string]struct{}{ 40 | "set-cookie": {}, 41 | } 42 | 43 | // Formatted with http.CanonicalHeaderKey 44 | RequestIDHeaderKey = "X-Request-Id" 45 | ) 46 | 47 | type Config struct { 48 | DefaultLevel slog.Level 49 | ClientErrorLevel slog.Level 50 | ServerErrorLevel slog.Level 51 | 52 | WithUserAgent bool 53 | WithRequestID bool 54 | WithRequestBody bool 55 | WithRequestHeader bool 56 | WithResponseBody bool 57 | WithResponseHeader bool 58 | WithSpanID bool 59 | WithTraceID bool 60 | 61 | Filters []Filter 62 | } 63 | 64 | // New returns a fiber.Handler (middleware) that logs requests using slog. 65 | // 66 | // Requests with errors are logged using slog.Error(). 67 | // Requests without errors are logged using slog.Info(). 68 | func New(logger *slog.Logger) fiber.Handler { 69 | return NewWithConfig(logger, Config{ 70 | DefaultLevel: slog.LevelInfo, 71 | ClientErrorLevel: slog.LevelWarn, 72 | ServerErrorLevel: slog.LevelError, 73 | 74 | WithUserAgent: false, 75 | WithRequestID: true, 76 | WithRequestBody: false, 77 | WithRequestHeader: false, 78 | WithResponseBody: false, 79 | WithResponseHeader: false, 80 | WithSpanID: false, 81 | WithTraceID: false, 82 | 83 | Filters: []Filter{}, 84 | }) 85 | } 86 | 87 | // NewWithFilters returns a fiber.Handler (middleware) that logs requests using slog. 88 | // 89 | // Requests with errors are logged using slog.Error(). 90 | // Requests without errors are logged using slog.Info(). 91 | func NewWithFilters(logger *slog.Logger, filters ...Filter) fiber.Handler { 92 | return NewWithConfig(logger, Config{ 93 | DefaultLevel: slog.LevelInfo, 94 | ClientErrorLevel: slog.LevelWarn, 95 | ServerErrorLevel: slog.LevelError, 96 | 97 | WithUserAgent: false, 98 | WithRequestID: true, 99 | WithRequestBody: false, 100 | WithRequestHeader: false, 101 | WithResponseBody: false, 102 | WithResponseHeader: false, 103 | WithSpanID: false, 104 | WithTraceID: false, 105 | 106 | Filters: filters, 107 | }) 108 | } 109 | 110 | // NewWithConfig returns a fiber.Handler (middleware) that logs requests using slog. 111 | func NewWithConfig(logger *slog.Logger, config Config) fiber.Handler { 112 | var ( 113 | once sync.Once 114 | errHandler fiber.ErrorHandler 115 | ) 116 | 117 | return func(c *fiber.Ctx) error { 118 | once.Do(func() { 119 | errHandler = c.App().ErrorHandler 120 | }) 121 | 122 | start := time.Now() 123 | path := c.Path() 124 | query := string(c.Request().URI().QueryString()) 125 | 126 | requestID := c.Get(RequestIDHeaderKey) 127 | if config.WithRequestID { 128 | if requestID == "" { 129 | requestID = uuid.New().String() 130 | } 131 | c.Context().SetUserValue("request-id", requestID) 132 | c.Set("X-Request-ID", requestID) 133 | } 134 | 135 | err := c.Next() 136 | if err != nil { 137 | if err = errHandler(c, err); err != nil { 138 | _ = c.SendStatus(fiber.StatusInternalServerError) //nolint:errcheck 139 | } 140 | } 141 | 142 | // Pass thru filters and skip early the code below, to prevent unnecessary processing. 143 | for _, filter := range config.Filters { 144 | if !filter(c) { 145 | return err 146 | } 147 | } 148 | 149 | status := c.Response().StatusCode() 150 | method := c.Context().Method() 151 | host := c.Hostname() 152 | params := c.AllParams() 153 | route := c.Route().Path 154 | end := time.Now() 155 | latency := end.Sub(start) 156 | userAgent := c.Context().UserAgent() 157 | referer := c.Get(fiber.HeaderReferer) 158 | 159 | ip := c.Context().RemoteIP().String() 160 | if len(c.IPs()) > 0 { 161 | ip = c.IPs()[0] 162 | } 163 | 164 | baseAttributes := []slog.Attr{} 165 | 166 | requestAttributes := []slog.Attr{ 167 | slog.Time("time", start.UTC()), 168 | slog.String("method", string(method)), 169 | slog.String("host", host), 170 | slog.String("path", path), 171 | slog.String("query", query), 172 | slog.Any("params", params), 173 | slog.String("route", route), 174 | slog.String("ip", ip), 175 | slog.Any("x-forwarded-for", c.IPs()), 176 | slog.String("referer", referer), 177 | } 178 | 179 | responseAttributes := []slog.Attr{ 180 | slog.Time("time", end.UTC()), 181 | slog.Duration("latency", latency), 182 | slog.Int("status", status), 183 | } 184 | 185 | if config.WithRequestID { 186 | baseAttributes = append(baseAttributes, slog.String(RequestIDKey, requestID)) 187 | } 188 | 189 | // otel 190 | baseAttributes = append(baseAttributes, extractTraceSpanID(c.UserContext(), config.WithTraceID, config.WithSpanID)...) 191 | 192 | // request body 193 | requestAttributes = append(requestAttributes, slog.Int("length", len((c.Body())))) 194 | if config.WithRequestBody { 195 | body := c.Body() 196 | if len(body) > RequestBodyMaxSize { 197 | body = body[:RequestBodyMaxSize] 198 | } 199 | requestAttributes = append(requestAttributes, slog.String("body", string(body))) 200 | } 201 | 202 | // request headers 203 | if config.WithRequestHeader { 204 | kv := []any{} 205 | 206 | for k, v := range c.GetReqHeaders() { 207 | if _, found := HiddenRequestHeaders[strings.ToLower(k)]; found { 208 | continue 209 | } 210 | kv = append(kv, slog.Any(k, v)) 211 | } 212 | 213 | requestAttributes = append(requestAttributes, slog.Group("header", kv...)) 214 | } 215 | 216 | if config.WithUserAgent { 217 | requestAttributes = append(requestAttributes, slog.String("user-agent", string(userAgent))) 218 | } 219 | 220 | // response body 221 | responseAttributes = append(responseAttributes, slog.Int("length", len(c.Response().Body()))) 222 | if config.WithResponseBody { 223 | body := c.Response().Body() 224 | if len(body) > ResponseBodyMaxSize { 225 | body = body[:ResponseBodyMaxSize] 226 | } 227 | responseAttributes = append(responseAttributes, slog.String("body", string(body))) 228 | } 229 | 230 | // response headers 231 | if config.WithResponseHeader { 232 | kv := []any{} 233 | 234 | for k, v := range c.GetRespHeaders() { 235 | if _, found := HiddenResponseHeaders[strings.ToLower(k)]; found { 236 | continue 237 | } 238 | kv = append(kv, slog.Any(k, v)) 239 | } 240 | 241 | responseAttributes = append(responseAttributes, slog.Group("header", kv...)) 242 | } 243 | 244 | attributes := append( 245 | []slog.Attr{ 246 | { 247 | Key: "request", 248 | Value: slog.GroupValue(requestAttributes...), 249 | }, 250 | { 251 | Key: "response", 252 | Value: slog.GroupValue(responseAttributes...), 253 | }, 254 | }, 255 | baseAttributes..., 256 | ) 257 | 258 | // custom context values 259 | if v := c.Context().UserValue(customAttributesCtxKey); v != nil { 260 | switch attrs := v.(type) { 261 | case []slog.Attr: 262 | attributes = append(attributes, attrs...) 263 | } 264 | } 265 | 266 | logErr := err 267 | if logErr == nil { 268 | logErr = fiber.NewError(status) 269 | } 270 | 271 | level := config.DefaultLevel 272 | msg := "Incoming request" 273 | if status >= http.StatusInternalServerError { 274 | level = config.ServerErrorLevel 275 | msg = logErr.Error() 276 | if msg == "" { 277 | msg = fmt.Sprintf("HTTP error: %d %s", status, strings.ToLower(http.StatusText(status))) 278 | } 279 | } else if status >= http.StatusBadRequest && status < http.StatusInternalServerError { 280 | level = config.ClientErrorLevel 281 | msg = logErr.Error() 282 | if msg == "" { 283 | msg = fmt.Sprintf("HTTP error: %d %s", status, strings.ToLower(http.StatusText(status))) 284 | } 285 | } 286 | 287 | logger.LogAttrs(c.UserContext(), level, msg, attributes...) 288 | 289 | return err 290 | } 291 | } 292 | 293 | // GetRequestID returns the request identifier. 294 | func GetRequestID(c *fiber.Ctx) string { 295 | return GetRequestIDFromContext(c.Context()) 296 | } 297 | 298 | // GetRequestIDFromContext returns the request identifier from the context. 299 | func GetRequestIDFromContext(ctx *fasthttp.RequestCtx) string { 300 | requestID, ok := ctx.UserValue("request-id").(string) 301 | if !ok { 302 | return "" 303 | } 304 | 305 | return requestID 306 | } 307 | 308 | // AddCustomAttributes adds custom attributes to the request context. 309 | func AddCustomAttributes(c *fiber.Ctx, attrs ...slog.Attr) { 310 | v := c.Context().UserValue(customAttributesCtxKey) 311 | if v == nil { 312 | c.Context().SetUserValue(customAttributesCtxKey, attrs) 313 | return 314 | } 315 | 316 | switch vAttrs := v.(type) { 317 | case []slog.Attr: 318 | c.Context().SetUserValue(customAttributesCtxKey, append(vAttrs, attrs...)) 319 | } 320 | } 321 | 322 | func extractTraceSpanID(ctx context.Context, withTraceID bool, withSpanID bool) []slog.Attr { 323 | if !withTraceID && !withSpanID { 324 | return []slog.Attr{} 325 | } 326 | 327 | span := trace.SpanFromContext(ctx) 328 | if !span.IsRecording() { 329 | return []slog.Attr{} 330 | } 331 | 332 | attrs := []slog.Attr{} 333 | spanCtx := span.SpanContext() 334 | 335 | if withTraceID && spanCtx.HasTraceID() { 336 | traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String() 337 | attrs = append(attrs, slog.String(TraceIDKey, traceID)) 338 | } 339 | 340 | if withSpanID && spanCtx.HasSpanID() { 341 | spanID := spanCtx.SpanID().String() 342 | attrs = append(attrs, slog.String(SpanIDKey, spanID)) 343 | } 344 | 345 | return attrs 346 | } 347 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # slog: Fiber middleware 3 | 4 | [](https://github.com/samber/slog-fiber/releases) 5 |  6 | [](https://pkg.go.dev/github.com/samber/slog-fiber) 7 |  8 | [](https://goreportcard.com/report/github.com/samber/slog-fiber) 9 | [](https://codecov.io/gh/samber/slog-fiber) 10 | [](https://github.com/samber/slog-fiber/graphs/contributors) 11 | [](./LICENSE) 12 | 13 | [Fiber](https://github.com/gofiber/fiber) middleware to log http requests using [slog](https://pkg.go.dev/log/slog). 14 | 15 |