diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 633f421..ef172bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: Build: strategy: matrix: - go-version: [1.21.x, 1.22.x] + go-version: [1.22.x, 1.23.x] platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/README.md b/README.md index 83ad0be..e94e514 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # fiberprometheus -Prometheus middleware for gofiber. +Prometheus middleware for [Fiber](https://github.com/gofiber/fiber)). -**Note: Requires Go 1.21 and above** +**Note: Requires Go 1.22 and above** ![Release](https://img.shields.io/github/release/ansrivas/fiberprometheus.svg) [![Discord](https://img.shields.io/badge/discord-join%20channel-7289DA)](https://gofiber.io/discord) @@ -12,16 +12,15 @@ Prometheus middleware for gofiber. Following metrics are available by default: -``` +```text http_requests_total http_request_duration_seconds http_requests_in_progress_total -http_cache_results ``` ### Install v2 -``` +```console go get -u github.com/gofiber/fiber/v2 go get -u github.com/ansrivas/fiberprometheus/v2 ``` @@ -32,8 +31,8 @@ go get -u github.com/ansrivas/fiberprometheus/v2 package main import ( - "github.com/ansrivas/fiberprometheus/v2" - "github.com/gofiber/fiber/v2" + "github.com/ansrivas/fiberprometheus/v2" + "github.com/gofiber/fiber/v2" ) func main() { @@ -70,6 +69,6 @@ func main() { - Hit the default url at http://localhost:3000 - Navigate to http://localhost:3000/metrics -### Grafana Board +### Grafana Dashboard - https://grafana.com/grafana/dashboards/14331 diff --git a/go.mod b/go.mod index b2e77b4..71e0274 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,29 @@ module github.com/ansrivas/fiberprometheus/v2 -go 1.21 +go 1.22 require ( - github.com/gofiber/fiber/v2 v2.52.4 - github.com/prometheus/client_golang v1.19.0 - github.com/valyala/fasthttp v1.52.0 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/prometheus/client_golang v1.20.5 + github.com/valyala/fasthttp v1.57.0 ) require ( - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.8 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/philhofer/fwd v1.1.2 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.53.0 // indirect - github.com/prometheus/procfs v0.14.0 // indirect + github.com/prometheus/common v0.60.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/tinylib/msgp v1.1.9 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.19.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + golang.org/x/sys v0.26.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect ) diff --git a/go.sum b/go.sum index 7cc2344..ff93048 100644 --- a/go.sum +++ b/go.sum @@ -1,50 +1,69 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= -github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= +github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= -github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= -github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= -github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= -github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= +github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA= +github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= +github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= -github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= -github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= +github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U= +github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= +github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg= +github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/middleware.go b/middleware.go index cfa7445..c643a7e 100644 --- a/middleware.go +++ b/middleware.go @@ -23,6 +23,7 @@ package fiberprometheus import ( "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -39,10 +40,8 @@ type FiberPrometheus struct { requestsTotal *prometheus.CounterVec requestDuration *prometheus.HistogramVec requestInFlight *prometheus.GaugeVec - cacheHeaderKey string - cacheCounter *prometheus.CounterVec defaultURL string - skipPaths map[string]struct{} + skipPaths map[string]bool } func create(registry prometheus.Registerer, serviceName, namespace, subsystem string, labels map[string]string) *FiberPrometheus { @@ -67,15 +66,6 @@ func create(registry prometheus.Registerer, serviceName, namespace, subsystem st []string{"status_code", "method", "path"}, ) - cacheCounter := promauto.With(registry).NewCounterVec( - prometheus.CounterOpts{ - Name: prometheus.BuildFQName(namespace, subsystem, "cache_results"), - Help: "Counts all cache hits by status code, method, and path", - ConstLabels: constLabels, - }, - []string{"status_code", "method", "path", "cache_result"}, - ) - histogram := promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{ Name: prometheus.BuildFQName(namespace, subsystem, "request_duration_seconds"), Help: "Duration of all HTTP requests by status code, method and path.", @@ -115,6 +105,7 @@ func create(registry prometheus.Registerer, serviceName, namespace, subsystem st 15.0, 20.0, 30.0, + 60.0, // 1m }, }, []string{"status_code", "method", "path"}, @@ -138,18 +129,10 @@ func create(registry prometheus.Registerer, serviceName, namespace, subsystem st requestsTotal: counter, requestDuration: histogram, requestInFlight: gauge, - cacheHeaderKey: "X-Cache", - cacheCounter: cacheCounter, defaultURL: "/metrics", } } -// CustomCacheKey allows to set a custom header key for caching -// By default it is set to "X-Cache", the fiber default -func (ps *FiberPrometheus) CustomCacheKey(cacheHeaderKey string) { - ps.cacheHeaderKey = cacheHeaderKey -} - // New creates a new instance of FiberPrometheus middleware // serviceName is available as a const label func New(serviceName string) *FiberPrometheus { @@ -191,6 +174,11 @@ func NewWithRegistry(registry prometheus.Registerer, serviceName, namespace, sub return create(registry, serviceName, namespace, subsystem, labels) } +// NewWithDefaultRegistry creates a new instance of FiberPrometheus middleware using the default prometheus registry +func NewWithDefaultRegistry(serviceName string) *FiberPrometheus { + return create(prometheus.DefaultRegisterer, serviceName, "http", "", nil) +} + // RegisterAt will register the prometheus handler at a given URL func (ps *FiberPrometheus) RegisterAt(app fiber.Router, url string, handlers ...fiber.Handler) { ps.defaultURL = url @@ -201,61 +189,77 @@ func (ps *FiberPrometheus) RegisterAt(app fiber.Router, url string, handlers ... // SetSkipPaths allows to set the paths that should be skipped from the metrics func (ps *FiberPrometheus) SetSkipPaths(paths []string) { - ps.skipPaths = make(map[string]struct{}) + if ps.skipPaths == nil { + ps.skipPaths = make(map[string]bool) + } for _, path := range paths { - ps.skipPaths[path] = struct{}{} + ps.skipPaths[path] = true } } // Middleware is the actual default middleware implementation func (ps *FiberPrometheus) Middleware(ctx *fiber.Ctx) error { - path := string(ctx.Request().RequestURI()) - - if path == ps.defaultURL { - return ctx.Next() - } + // Retrieve the request method + method := utils.CopyString(ctx.Method()) - // Check if the path is in the map of skipped paths - if _, exists := ps.skipPaths[path]; exists { - return ctx.Next() // Skip metrics collection - } - - // Start metrics timer - start := time.Now() - method := ctx.Route().Method + // Increment the in-flight gauge ps.requestInFlight.WithLabelValues(method).Inc() defer func() { ps.requestInFlight.WithLabelValues(method).Dec() }() + // Start metrics timer + start := time.Now() + + // Continue stack err := ctx.Next() - // initialize with default error code - // https://docs.gofiber.io/guide/error-handling + + // Get the route path + routePath := utils.CopyString(ctx.Route().Path) + + // If the route path is empty, use the current path + if routePath == "/" { + routePath = utils.CopyString(ctx.Path()) + } + + // Normalize the path + if routePath != "" && routePath != "/" { + routePath = normalizePath(routePath) + } + + // Check if the normalized path should be skipped + if ps.skipPaths[routePath] { + return nil + } + + // Determine status code from stack status := fiber.StatusInternalServerError if err != nil { if e, ok := err.(*fiber.Error); ok { - // Get correct error code from fiber.Error type status = e.Code } } else { status = ctx.Response().StatusCode() } - // Get status as string + // Convert status code to string statusCode := strconv.Itoa(status) - // Update total requests counter - ps.requestsTotal.WithLabelValues(statusCode, method, path).Inc() - - // Update the cache counter - cacheResult := utils.CopyString(ctx.GetRespHeader(ps.cacheHeaderKey, "")) - if cacheResult != "" { - ps.cacheCounter.WithLabelValues(statusCode, method, path, cacheResult).Inc() - } + // Update metrics + ps.requestsTotal.WithLabelValues(statusCode, method, routePath).Inc() - // Update the request duration histogram + // Observe the Request Duration elapsed := float64(time.Since(start).Nanoseconds()) / 1e9 - ps.requestDuration.WithLabelValues(statusCode, method, path).Observe(elapsed) + ps.requestDuration.WithLabelValues(statusCode, method, routePath).Observe(elapsed) return err } + +// normalizePath will remove the trailing slash from the route path +func normalizePath(routePath string) string { + normalized := strings.TrimRight(routePath, "/") + if normalized == "" { + return "/" + } + return normalized +} diff --git a/middleware_test.go b/middleware_test.go index 24893ee..a9b01a5 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -25,12 +25,14 @@ import ( "fmt" "io" "net/http/httptest" + "strconv" "strings" + "sync" "testing" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/basicauth" - "github.com/gofiber/fiber/v2/middleware/cache" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -39,8 +41,8 @@ import ( func TestMiddleware(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() prometheus := New("test-service") prometheus.RegisterAt(app, "/metrics") app.Use(prometheus.Middleware) @@ -76,20 +78,21 @@ func TestMiddleware(t *testing.T) { req = httptest.NewRequest("GET", "/metrics", nil) resp, _ = app.Test(req, -1) defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) got := string(body) + + // Check Metrics Response want := `http_requests_total{method="GET",path="/",service="test-service",status_code="200"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/error/fiber",service="test-service",status_code="400"} 1` + want = `http_requests_total{method="GET",path="/error/:type",service="test-service",status_code="400"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/error/unknown",service="test-service",status_code="500"} 1` + want = `http_requests_total{method="GET",path="/error/:type",service="test-service",status_code="500"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } @@ -107,8 +110,8 @@ func TestMiddleware(t *testing.T) { func TestMiddlewareWithSkipPath(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() prometheus := New("test-service") prometheus.RegisterAt(app, "/metrics") prometheus.SetSkipPaths([]string{"/healthz", "/livez"}) @@ -156,29 +159,34 @@ func TestMiddlewareWithSkipPath(t *testing.T) { want = `http_requests_total{method="GET",path="/healthz",service="test-service",status_code="200"} 1` if strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + t.Errorf("got %s; not want %s", got, want) + } + + want = `http_requests_total{method="GET",path="/metrics",service="test-service",status_code="200"}` + if strings.Contains(got, want) { + t.Errorf("got %s; not want %s", got, want) } want = `http_requests_total{method="GET",path="/livez",service="test-service",status_code="200"} 1` if strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + t.Errorf("got %s; not want %s", got, want) } want = `http_request_duration_seconds_count{method="GET",path="/",service="test-service",status_code="200"} 1` if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + t.Errorf("got %s; not want %s", got, want) } want = `http_requests_in_progress_total{method="GET",service="test-service"} 0` if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + t.Errorf("got %s; not want %s", got, want) } } func TestMiddlewareWithGroup(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() prometheus := New("test-service") prometheus.RegisterAt(app, "/metrics") app.Use(prometheus.Middleware) @@ -219,20 +227,21 @@ func TestMiddlewareWithGroup(t *testing.T) { req = httptest.NewRequest("GET", "/metrics", nil) resp, _ = app.Test(req, -1) defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) got := string(body) + + // Check Metrics Response want := `http_requests_total{method="GET",path="/public",service="test-service",status_code="200"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/public/error/fiber",service="test-service",status_code="400"} 1` + want = `http_requests_total{method="GET",path="/public/error/:type",service="test-service",status_code="400"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/public/error/unknown",service="test-service",status_code="500"} 1` + want = `http_requests_total{method="GET",path="/public/error/:type",service="test-service",status_code="500"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } @@ -250,6 +259,7 @@ func TestMiddlewareWithGroup(t *testing.T) { func TestMiddlewareOnRoute(t *testing.T) { t.Parallel() + app := fiber.New() prometheus := New("test-route") prefix := "/prefix/path" @@ -301,12 +311,12 @@ func TestMiddlewareOnRoute(t *testing.T) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/error/fiber",service="test-route",status_code="400"} 1` + want = `http_requests_total{method="GET",path="/error/:type",service="test-route",status_code="400"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } - want = `http_requests_total{method="GET",path="/error/unknown",service="test-route",status_code="500"} 1` + want = `http_requests_total{method="GET",path="/error/:type",service="test-route",status_code="500"} 1` if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } @@ -324,11 +334,12 @@ func TestMiddlewareOnRoute(t *testing.T) { func TestMiddlewareWithServiceName(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() prometheus := NewWith("unique-service", "my_service_with_name", "http") prometheus.RegisterAt(app, "/metrics") app.Use(prometheus.Middleware) + app.Get("/", func(c *fiber.Ctx) error { return c.SendString("Hello World") }) @@ -362,8 +373,8 @@ func TestMiddlewareWithServiceName(t *testing.T) { func TestMiddlewareWithLabels(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() constLabels := map[string]string{ "customkey1": "customvalue1", "customkey2": "customvalue2", @@ -371,6 +382,7 @@ func TestMiddlewareWithLabels(t *testing.T) { prometheus := NewWithLabels(constLabels, "my_service", "http") prometheus.RegisterAt(app, "/metrics") app.Use(prometheus.Middleware) + app.Get("/", func(c *fiber.Ctx) error { return c.SendString("Hello World") }) @@ -404,8 +416,8 @@ func TestMiddlewareWithLabels(t *testing.T) { func TestMiddlewareWithBasicAuth(t *testing.T) { t.Parallel() - app := fiber.New() + app := fiber.New() prometheus := New("basic-auth") prometheus.RegisterAt(app, "/metrics", basicauth.New(basicauth.Config{ Users: map[string]string{ @@ -440,18 +452,18 @@ func TestMiddlewareWithBasicAuth(t *testing.T) { func TestMiddlewareWithCustomRegistry(t *testing.T) { t.Parallel() + app := fiber.New() registry := prometheus.NewRegistry() - srv := httptest.NewServer(promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) t.Cleanup(srv.Close) - promfiber := NewWithRegistry(registry, "unique-service", "my_service_with_name", "http", nil) app.Use(promfiber.Middleware) app.Get("/", func(c *fiber.Ctx) error { return c.SendString("Hello World") }) + req := httptest.NewRequest("GET", "/", nil) resp, err := app.Test(req, -1) if err != nil { @@ -493,13 +505,13 @@ func TestMiddlewareWithCustomRegistry(t *testing.T) { func TestCustomRegistryRegisterAt(t *testing.T) { t.Parallel() + app := fiber.New() registry := prometheus.NewRegistry() registry.Register(collectors.NewGoCollector()) registry.Register(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) fpCustom := NewWithRegistry(registry, "custom-registry", "custom_name", "http", nil) fpCustom.RegisterAt(app, "/metrics") - app.Use(fpCustom.Middleware) app.Get("/", func(c *fiber.Ctx) error { @@ -534,126 +546,298 @@ func TestCustomRegistryRegisterAt(t *testing.T) { if !strings.Contains(got, want) { t.Errorf("got %s; want %s", got, want) } + + // Make sure that /metrics was skipped + want = `custom_name_http_requests_total{method="GET",path="/metrics",service="custom-registry",status_code="200"} 1` + if strings.Contains(got, want) { + t.Errorf("got %s; not want %s", got, want) + } } -func TestWithCacheMiddleware(t *testing.T) { - t.Parallel() +// TestInFlightGauge verifies that the in-flight requests gauge is updated correctly. +func TestInFlightGauge(t *testing.T) { app := fiber.New() - registry := prometheus.NewRegistry() - registry.Register(collectors.NewGoCollector()) - registry.Register(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - fpCustom := NewWithRegistry(registry, "custom-registry", "custom_name", "http", nil) - fpCustom.RegisterAt(app, "/metrics") + prometheus := New("inflight-service") + app.Use(prometheus.Middleware) - app.Use(fpCustom.Middleware) - app.Use(cache.New()) + // Long-running handler to simulate in-flight requests + app.Get("/long", func(c *fiber.Ctx) error { + // Sleep for a short duration + time.Sleep(100 * time.Millisecond) + return c.SendString("Long Request") + }) - app.Get("/myPath", func(c *fiber.Ctx) error { - return c.SendString("Hello, world!") + // Register metrics endpoint + prometheus.RegisterAt(app, "/metrics") + + var wg sync.WaitGroup + requests := 10 + wg.Add(requests) + + // Start multiple concurrent requests + for i := 0; i < requests; i++ { + go func() { + defer wg.Done() + req := httptest.NewRequest("GET", "/long", nil) + app.Test(req, -1) + }() + } + + // Allow some time for requests to start + time.Sleep(10 * time.Millisecond) + wg.Wait() + + // After all requests complete, in-flight gauge should be zero + req := httptest.NewRequest("GET", "/metrics", nil) + resp, _ := app.Test(req, -1) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + got := string(body) + + // The in-flight gauge should be equal to the number of concurrent requests + // Since requests are sleeping, some may have completed, so we check for at least one + if !strings.Contains(got, `http_requests_in_progress_total{method="GET",service="inflight-service"} 1`) { + t.Errorf("Expected in-flight gauge to be at least 1, got %s", got) + } + + want := `http_requests_total{method="GET",path="/long",service="inflight-service",status_code="200"} 10` + if !strings.Contains(got, want) { + t.Errorf("Expected in-flight gauge to be 0, got %s", got) + } +} + +// TestDifferentHTTPMethods verifies that metrics are correctly recorded for various HTTP methods. +func TestDifferentHTTPMethods(t *testing.T) { + app := fiber.New() + prometheus := New("methods-service") + app.Use(prometheus.Middleware) + + // Define handlers for different methods + app.Get("/resource", func(c *fiber.Ctx) error { + return c.SendString("GET") + }) + app.Post("/resource", func(c *fiber.Ctx) error { + return c.SendString("POST") + }) + app.Put("/resource", func(c *fiber.Ctx) error { + return c.SendString("PUT") + }) + app.Delete("/resource", func(c *fiber.Ctx) error { + return c.SendString("DELETE") }) - for i := 0; i < 2; i++ { - req := httptest.NewRequest("GET", "/myPath", nil) - res, err := app.Test(req, -1) - if err != nil { - t.Fatal(fmt.Errorf("GET / failed: %w", err)) - } - defer res.Body.Close() - if res.StatusCode != 200 { - t.Fatal(fmt.Errorf("GET /: Status=%d", res.StatusCode)) + // Register metrics endpoint + prometheus.RegisterAt(app, "/metrics") + + // Make requests with different methods + methods := []string{"GET", "POST", "PUT", "DELETE"} + for _, method := range methods { + req := httptest.NewRequest(method, "/resource", nil) + resp, _ := app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200 for %s, got %d", method, resp.StatusCode) } } + // Check Metrics req := httptest.NewRequest("GET", "/metrics", nil) - res, err := app.Test(req, -1) - if err != nil { - t.Fatal(fmt.Errorf("GET /metrics failed: %W", err)) + resp, _ := app.Test(req, -1) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + got := string(body) + + for _, method := range methods { + want := `http_requests_total{method="` + method + `",path="/resource",service="methods-service",status_code="200"} 1` + if !strings.Contains(got, want) { + t.Errorf("Expected metric %s, got %s", want, got) + } } - defer res.Body.Close() - if res.StatusCode != 200 { - t.Fatal(fmt.Errorf("GET /metrics: Status=%d", res.StatusCode)) +} + +// TestSkipPathsWithTrailingSlash verifies that skip paths are correctly normalized and skipped even with trailing slashes. +func TestSkipPathsWithTrailingSlash(t *testing.T) { + app := fiber.New() + prometheus := New("skip-service") + prometheus.RegisterAt(app, "/metrics") + prometheus.SetSkipPaths([]string{"/healthz", "/livez"}) + app.Use(prometheus.Middleware) + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello World") + }) + app.Get("/healthz/", func(c *fiber.Ctx) error { // Trailing slash + return c.SendString("Healthz") + }) + app.Get("/livez/", func(c *fiber.Ctx) error { // Trailing slash + return c.SendString("Livez") + }) + + // Make requests + req := httptest.NewRequest("GET", "/", nil) + resp, _ := app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(fmt.Errorf("GET /metrics: read body: %w", err)) + + req = httptest.NewRequest("GET", "/healthz/", nil) + resp, _ = app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) } - got := string(body) - want := `custom_name_http_requests_total{method="GET",path="/myPath",service="custom-registry",status_code="200"} 2` - if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + + req = httptest.NewRequest("GET", "/livez/", nil) + resp, _ = app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) } - want = `custom_name_http_cache_results{cache_result="hit",method="GET",path="/myPath",service="custom-registry",status_code="200"} 1` + // Check Metrics + req = httptest.NewRequest("GET", "/metrics", nil) + resp, _ = app.Test(req, -1) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + got := string(body) + + // Only the root path should be recorded + want := `http_requests_total{method="GET",path="/",service="skip-service",status_code="200"} 1` if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + t.Errorf("Expected metric %s, got %s", want, got) } - want = `custom_name_http_cache_results{cache_result="miss",method="GET",path="/myPath",service="custom-registry",status_code="200"} 1` - if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + // Ensure skipped paths are not recorded + skippedPaths := []string{"/healthz", "/livez"} + for _, path := range skippedPaths { + want := `http_requests_total{method="GET",path="` + path + `",service="skip-service",status_code="200"} 1` + if strings.Contains(got, want) { + t.Errorf("Did not expect metric %s, but found in %s", want, got) + } } } -func TestWithCacheMiddlewareWithCustomKey(t *testing.T) { - t.Parallel() +// TestMetricsAfterError verifies that metrics are recorded correctly even when handlers return errors. +func TestMetricsAfterError(t *testing.T) { app := fiber.New() - registry := prometheus.NewRegistry() - registry.Register(collectors.NewGoCollector()) - registry.Register(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - fpCustom := NewWithRegistry(registry, "custom-registry", "custom_name", "http", nil) - fpCustom.RegisterAt(app, "/metrics") - fpCustom.CustomCacheKey("my-custom-cache-header") - - app.Use(fpCustom.Middleware) - app.Use(cache.New( - cache.Config{ - CacheHeader: "my-custom-cache-header", - }, - )) + prometheus := New("error-service") + app.Use(prometheus.Middleware) - app.Get("/myPath", func(c *fiber.Ctx) error { - return c.SendString("Hello, world!") + app.Get("/badrequest", func(c *fiber.Ctx) error { + return fiber.ErrBadRequest + }) + app.Get("/internalerror", func(c *fiber.Ctx) error { + return fiber.ErrInternalServerError }) - for i := 0; i < 2; i++ { - req := httptest.NewRequest("GET", "/myPath", nil) - res, err := app.Test(req, -1) - if err != nil { - t.Fatal(fmt.Errorf("GET / failed: %w", err)) - } - defer res.Body.Close() - if res.StatusCode != 200 { - t.Fatal(fmt.Errorf("GET /: Status=%d", res.StatusCode)) - } + // Register metrics endpoint + prometheus.RegisterAt(app, "/metrics") + + // Make error requests + req := httptest.NewRequest("GET", "/badrequest", nil) + resp, _ := app.Test(req, -1) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("Expected status 400, got %d", resp.StatusCode) } - req := httptest.NewRequest("GET", "/metrics", nil) - res, err := app.Test(req, -1) - if err != nil { - t.Fatal(fmt.Errorf("GET /metrics failed: %W", err)) + req = httptest.NewRequest("GET", "/internalerror", nil) + resp, _ = app.Test(req, -1) + if resp.StatusCode != fiber.StatusInternalServerError { + t.Fatalf("Expected status 500, got %d", resp.StatusCode) } - defer res.Body.Close() - if res.StatusCode != 200 { - t.Fatal(fmt.Errorf("GET /metrics: Status=%d", res.StatusCode)) + + // Check Metrics + req = httptest.NewRequest("GET", "/metrics", nil) + resp, _ = app.Test(req, -1) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + got := string(body) + + want400 := `http_requests_total{method="GET",path="/badrequest",service="error-service",status_code="400"} 1` + if !strings.Contains(got, want400) { + t.Errorf("Expected metric %s, got %s", want400, got) } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(fmt.Errorf("GET /metrics: read body: %w", err)) + + want500 := `http_requests_total{method="GET",path="/internalerror",service="error-service",status_code="500"} 1` + if !strings.Contains(got, want500) { + t.Errorf("Expected metric %s, got %s", want500, got) + } +} + +// TestMultipleRegistrations ensures that calling RegisterAt multiple times does not duplicate handlers. +func TestMultipleRegistrations(t *testing.T) { + app := fiber.New() + prometheus := New("multi-register-service") + app.Use(prometheus.Middleware) + prometheus.RegisterAt(app, "/metrics") + prometheus.RegisterAt(app, "/metrics") // Register again + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello World") + }) + + // Make requests + req := httptest.NewRequest("GET", "/", nil) + resp, _ := app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) } + + // Make a request to /metrics + req = httptest.NewRequest("GET", "/metrics", nil) + resp, _ = app.Test(req, -1) + if resp.StatusCode != 200 { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) got := string(body) - want := `custom_name_http_requests_total{method="GET",path="/myPath",service="custom-registry",status_code="200"} 2` - if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + + // Expect metrics to be registered only once + want := `http_requests_total{method="GET",path="/",service="multi-register-service",status_code="200"} 1` + if strings.Count(got, want) != 1 { + t.Errorf("Expected metric %s to appear once, got %s occurrences", want, got) } +} - want = `custom_name_http_cache_results{cache_result="hit",method="GET",path="/myPath",service="custom-registry",status_code="200"} 1` - if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) +// TestMetricsHandlerConcurrentAccess verifies that the metrics handler can handle concurrent access without issues. +func TestMetricsHandlerConcurrentAccess(t *testing.T) { + app := fiber.New() + prometheus := New("concurrent-service") + app.Use(prometheus.Middleware) + + app.Get("/resource", func(c *fiber.Ctx) error { + return c.SendString("Resource") + }) + + // Register metrics endpoint + prometheus.RegisterAt(app, "/metrics") + + // Make multiple concurrent requests + var wg sync.WaitGroup + requests := 100 + wg.Add(requests) + + for i := 0; i < requests; i++ { + go func() { + defer wg.Done() + req := httptest.NewRequest("GET", "/resource", nil) + app.Test(req, -1) + }() } - want = `custom_name_http_cache_results{cache_result="miss",method="GET",path="/myPath",service="custom-registry",status_code="200"} 1` - if !strings.Contains(got, want) { - t.Errorf("got %s; want %s", got, want) + wg.Wait() + + // Check Metrics + req := httptest.NewRequest("GET", "/metrics", nil) + resp, _ := app.Test(req, -1) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + got := string(body) + + // Verify that requests_total is incremented correctly + wantTotal := `http_requests_total{method="GET",path="/resource",service="concurrent-service",status_code="200"} ` + strconv.Itoa(requests) + if !strings.Contains(got, wantTotal) { + t.Errorf("Expected metric %s, got %s", wantTotal, got) } }