From cc25e5dff9db8ac0d03f0bc1aad720661f55c5f6 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:10:31 -0500 Subject: [PATCH 1/8] Handle HEAD requests in Prometheus middleware --- .github/workflows/test-prometheus.yml | 31 ++ README.md | 1 + go.work | 1 + v3/README.md | 1 + v3/prometheus/README.md | 106 ++++ v3/prometheus/config.go | 187 +++++++ v3/prometheus/go.mod | 41 ++ v3/prometheus/go.sum | 99 ++++ v3/prometheus/prometheus.go | 422 +++++++++++++++ v3/prometheus/prometheus_test.go | 729 ++++++++++++++++++++++++++ 10 files changed, 1618 insertions(+) create mode 100644 .github/workflows/test-prometheus.yml create mode 100644 v3/prometheus/README.md create mode 100644 v3/prometheus/config.go create mode 100644 v3/prometheus/go.mod create mode 100644 v3/prometheus/go.sum create mode 100644 v3/prometheus/prometheus.go create mode 100644 v3/prometheus/prometheus_test.go diff --git a/.github/workflows/test-prometheus.yml b/.github/workflows/test-prometheus.yml new file mode 100644 index 000000000..3b4f06d69 --- /dev/null +++ b/.github/workflows/test-prometheus.yml @@ -0,0 +1,31 @@ +name: "Test Prometheus" + +on: + push: + branches: + - main + paths: + - 'v3/prometheus/**/*.go' + - 'v3/prometheus/go.mod' + pull_request: + paths: + - 'v3/prometheus/**/*.go' + - 'v3/prometheus/go.mod' + +jobs: + Tests: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: + - 1.25.x + steps: + - name: Fetch Repository + uses: actions/checkout@v5 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '${{ matrix.go-version }}' + - name: Run Test + working-directory: ./v3/prometheus + run: go test -v -race ./... diff --git a/README.md b/README.md index 0f0110fc2..45fdb876b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Repository for third party middlewares and service implementations, with depende * [loadshed](./v3/loadshed/README.md) loadshed workflow status * [new relic](./v3/newrelic/README.md) new relic workflow status * [monitor](./v3/monitor/README.md) monitor workflow status +* [prometheus](./v3/prometheus/README.md) prometheus workflow status * [open policy agent](./v3/opa/README.md) OPA workflow status * [otel (opentelemetry)](./v3/otel/README.md) otel workflow status * [paseto](./v3/paseto/README.md) paseto workflow status diff --git a/go.work b/go.work index 5c1858b5e..bc9373141 100644 --- a/go.work +++ b/go.work @@ -13,6 +13,7 @@ use ( ./v3/opa ./v3/otel ./v3/paseto +./v3/prometheus ./v3/sentry ./v3/socketio ./v3/swagger diff --git a/v3/README.md b/v3/README.md index 64a0f5b7c..807ea29f1 100644 --- a/v3/README.md +++ b/v3/README.md @@ -31,6 +31,7 @@ Repository for third party middlewares and service implementations, with depende * [loadshed](./loadshed/README.md) loadshed workflow status * [new relic](./newrelic/README.md) new relic workflow status * [monitor](./monitor/README.md) monitor workflow status +* [prometheus](./prometheus/README.md) prometheus workflow status * [open policy agent](./opa/README.md) OPA workflow status * [otel (opentelemetry)](./otel/README.md) otel workflow status * [paseto](./paseto/README.md) paseto workflow status diff --git a/v3/prometheus/README.md b/v3/prometheus/README.md new file mode 100644 index 000000000..f7d6639c3 --- /dev/null +++ b/v3/prometheus/README.md @@ -0,0 +1,106 @@ +# Prometheus + +Prometheus middleware for [Fiber v3](https://github.com/gofiber/fiber) based on [ansrivas/fiberprometheus](https://github.com/ansrivas/fiberprometheus). + +![Release](https://img.shields.io/github/release/ansrivas/fiberprometheus.svg) +[![Discord](https://img.shields.io/badge/discord-join%20channel-7289DA)](https://gofiber.io/discord) + +Following metrics are available by default: + +```text +http_requests_total +http_requests_status_class_total +http_request_duration_seconds +http_requests_in_progress_total +http_request_size_bytes +http_response_size_bytes +``` + +`http_requests_in_progress_total` exposes both the HTTP method and normalized +route path so you can pinpoint which handlers are currently running. + +> [!NOTE] +> The middleware requires Go 1.25 or newer and Fiber v3 (currently RC). + +## 🚀 Installation + +```bash +go get github.com/gofiber/contrib/v3/prometheus +``` + +## 📄 Example + +```go +package main + +import ( + fiberprometheus "github.com/gofiber/contrib/v3/prometheus" + "github.com/gofiber/fiber/v3" +) + +func main() { + app := fiber.New() + + app.Use("/metrics", fiberprometheus.New(fiberprometheus.Config{ + Service: "my-service-name", + SkipURIs: []string{"/ping"}, + IgnoreStatusCodes: []int{401, 403, 404}, + })) + + app.Get("/", func(c fiber.Ctx) error { + return c.SendString("Hello World") + }) + + app.Get("/ping", func(c fiber.Ctx) error { + return c.SendString("pong") + }) + + app.Post("/some", func(c fiber.Ctx) error { + return c.SendString("Welcome!") + }) + + app.Listen(":3000") +} +``` + +### Collector, OpenMetrics, and response options + +The middleware exposes Prometheus collector toggles and `HandlerOpts` via +`Config`. By default it creates a private `Registerer`/`Gatherer` pair and uses +that for both registration and scraping. When customizing the registry, ensure +that the `Registerer` and `Gatherer` refer to the same metrics source (for +example, a `*prometheus.Registry`). Supplying only one that does not implement +the other interface or providing a mismatched pair will cause initialization to +panic so metrics are not silently dropped. + +- `DisableGoCollector` disables the default Go runtime metrics collector when set to `true`. +- `DisableProcessCollector` disables the default process metrics collector when set to `true`. + +- `EnableOpenMetrics` negotiates the experimental OpenMetrics encoding so exemplars are exported. +- `EnableOpenMetricsTextCreatedSamples` adds synthetic `_created` samples when OpenMetrics is enabled. +- `DisableCompression` disables gzip/zstd compression even when clients request it. + +- `TrackUnmatchedRequests` records metrics for requests that miss all registered routes using `UnmatchedRouteLabel` as the path label. Defaults to `false`. +- `UnmatchedRouteLabel` customizes the path label applied to unmatched requests when tracking is enabled. Defaults to `/__unmatched__`. + +- `RequestDurationBuckets`, `RequestSizeBuckets`, and `ResponseSizeBuckets` customize the histogram buckets used for latency and payload metrics. They default to: + - Duration: `[0.005 0.01 0.025 0.05 0.075 0.1 0.25 0.5 0.75 1 2.5 5 10 15 30 60]` + - Request size: `[256 512 1024 2048 4096 8192 16384 32768 65536 131072 262144 524288 1048576 2097152 5242880]` + - Response size: `[256 512 1024 2048 4096 8192 16384 32768 65536 131072 262144 524288 1048576 2097152 5242880]` + +All of the options default to `false` and can be enabled or disabled individually as needed. + +The metrics endpoint path is derived from how the middleware is mounted. In the +example above, calling `app.Use("/metrics", fiberprometheus.New(...))` exposes +the handler at `/metrics` while the middleware continues to instrument all +routed traffic. + +## 📊 Result + +- Hit the default url at http://localhost:3000 +- Navigate to http://localhost:3000/metrics +- Metrics are recorded only for routes registered with Fiber unless `TrackUnmatchedRequests` is enabled, in which case unmatched requests are labeled with `UnmatchedRouteLabel`. + +## 📈 Grafana Dashboard + +- https://grafana.com/grafana/dashboards/14331 diff --git a/v3/prometheus/config.go b/v3/prometheus/config.go new file mode 100644 index 000000000..585341aaf --- /dev/null +++ b/v3/prometheus/config.go @@ -0,0 +1,187 @@ +package prometheus + +import ( + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/prometheus/client_golang/prometheus" +) + +// Config defines the middleware configuration. +type Config struct { + // Service is added as the `service` const label on every metric. + // + // Optional. Default: "" (label omitted). + Service string + + // Namespace prefixes every metric name. + // + // Optional. Default: "http". + Namespace string + + // Subsystem prefixes every metric name after Namespace. + // + // Optional. Default: "". + Subsystem string + + // Labels are attached to every metric. + // + // Optional. Default: no labels. + Labels prometheus.Labels + + // Registerer is used to register metrics. + // + // Optional. Default: a private registry. + Registerer prometheus.Registerer + + // Gatherer provides metrics to the HTTP handler. + // + // Optional. Default: a private registry/gatherer pair created when neither + // Registerer nor Gatherer is supplied. If only one is provided, it must also + // implement the other interface or the middleware will panic to prevent + // silently omitting metrics. + Gatherer prometheus.Gatherer + + // DisableGoCollector disables the Go runtime metrics collector registration. + // + // Optional. Default: false (collector enabled). + DisableGoCollector bool + + // DisableProcessCollector disables the process metrics collector registration. + // + // Optional. Default: false (collector enabled). + DisableProcessCollector bool + + // RequestDurationBuckets configures the histogram buckets used for request + // latency metrics. Provide nil to use the defaults. + // + // Optional. Default: []float64{0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 10, 15, 30, 60}. + RequestDurationBuckets []float64 + + // RequestSizeBuckets configures the histogram buckets used for request + // payload size metrics. Provide nil to use the defaults. + // + // Optional. Default: []float64{256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 5242880}. + RequestSizeBuckets []float64 + + // ResponseSizeBuckets configures the histogram buckets used for response + // payload size metrics. Provide nil to use the defaults. + // + // Optional. Default: []float64{256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 5242880}. + ResponseSizeBuckets []float64 + + // TrackUnmatchedRequests toggles metrics for requests that do not resolve to a + // registered Fiber route. + // + // Optional. Default: false. + TrackUnmatchedRequests bool + + // UnmatchedRouteLabel is the path label used when TrackUnmatchedRequests is + // enabled and a request does not match a registered route. + // + // Optional. Default: "/__unmatched__". + UnmatchedRouteLabel string + + // EnableOpenMetrics exposes the experimental OpenMetrics encoding. + // + // Optional. Default: false. + EnableOpenMetrics bool + + // EnableOpenMetricsTextCreatedSamples adds synthetic `_created` samples to + // OpenMetrics responses. + // + // Optional. Default: false. + EnableOpenMetricsTextCreatedSamples bool + + // DisableCompression prevents gzip compression of metrics responses, even when + // requested by the client (both gzip and zstd). + // + // Optional. Default: false. + DisableCompression bool + + // SkipURIs excludes matching routes from instrumentation. + // + // Optional. Default: none. + SkipURIs []string + + // IgnoreStatusCodes excludes matching response status codes from metrics. + // + // Optional. Default: none. + IgnoreStatusCodes []int + + // Next skips the middleware when it returns true. + // + // Optional. Default: nil. + Next func(fiber.Ctx) bool +} + +var ( + defaultRequestDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 10, 15, 30, 60} + defaultRequestSizeBuckets = []float64{256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 5242880} + defaultResponseSizeBuckets = []float64{256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 5242880} +) + +// ConfigDefault holds the default middleware configuration. +var ConfigDefault = Config{ + Namespace: "http", + UnmatchedRouteLabel: "/__unmatched__", + RequestDurationBuckets: defaultRequestDurationBuckets, + RequestSizeBuckets: defaultRequestSizeBuckets, + ResponseSizeBuckets: defaultResponseSizeBuckets, +} + +func configDefault(config ...Config) Config { + if len(config) == 0 { + cfg := ConfigDefault + cfg.Labels = make(prometheus.Labels) + cfg.RequestDurationBuckets = append([]float64(nil), ConfigDefault.RequestDurationBuckets...) + cfg.RequestSizeBuckets = append([]float64(nil), ConfigDefault.RequestSizeBuckets...) + cfg.ResponseSizeBuckets = append([]float64(nil), ConfigDefault.ResponseSizeBuckets...) + return cfg + } + + cfg := config[0] + + if cfg.Namespace == "" { + cfg.Namespace = ConfigDefault.Namespace + } + + if cfg.UnmatchedRouteLabel == "" { + cfg.UnmatchedRouteLabel = ConfigDefault.UnmatchedRouteLabel + } else { + cfg.UnmatchedRouteLabel = strings.Clone(cfg.UnmatchedRouteLabel) + } + + if cfg.RequestDurationBuckets == nil { + cfg.RequestDurationBuckets = append([]float64(nil), ConfigDefault.RequestDurationBuckets...) + } else { + cfg.RequestDurationBuckets = append([]float64(nil), cfg.RequestDurationBuckets...) + } + + if cfg.RequestSizeBuckets == nil { + cfg.RequestSizeBuckets = append([]float64(nil), ConfigDefault.RequestSizeBuckets...) + } else { + cfg.RequestSizeBuckets = append([]float64(nil), cfg.RequestSizeBuckets...) + } + + if cfg.ResponseSizeBuckets == nil { + cfg.ResponseSizeBuckets = append([]float64(nil), ConfigDefault.ResponseSizeBuckets...) + } else { + cfg.ResponseSizeBuckets = append([]float64(nil), cfg.ResponseSizeBuckets...) + } + + if cfg.Labels == nil { + cfg.Labels = make(prometheus.Labels) + } else { + labels := make(prometheus.Labels, len(cfg.Labels)) + for key, value := range cfg.Labels { + labels[key] = value + } + cfg.Labels = labels + } + + cfg.SkipURIs = append([]string(nil), cfg.SkipURIs...) + cfg.IgnoreStatusCodes = append([]int(nil), cfg.IgnoreStatusCodes...) + + return cfg +} diff --git a/v3/prometheus/go.mod b/v3/prometheus/go.mod new file mode 100644 index 000000000..c0853acef --- /dev/null +++ b/v3/prometheus/go.mod @@ -0,0 +1,41 @@ +module github.com/gofiber/contrib/v3/prometheus + +go 1.25.0 + +require ( + github.com/gofiber/fiber/v3 v3.0.0-rc.3 + github.com/gofiber/utils/v2 v2.0.0-rc.3 + github.com/prometheus/client_golang v1.23.2 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/tinylib/msgp v1.5.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.68.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/v3/prometheus/go.sum b/v3/prometheus/go.sum new file mode 100644 index 000000000..ac851669f --- /dev/null +++ b/v3/prometheus/go.sum @@ -0,0 +1,99 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA= +github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc= +github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4= +github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= +github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= +github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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/v3/prometheus/prometheus.go b/v3/prometheus/prometheus.go new file mode 100644 index 000000000..87abe5a85 --- /dev/null +++ b/v3/prometheus/prometheus.go @@ -0,0 +1,422 @@ +// Package prometheus provides a Fiber middleware that exposes Prometheus +// metrics while instrumenting incoming HTTP traffic. +package prometheus + +import ( + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/gofiber/utils/v2" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "go.opentelemetry.io/otel/trace" +) + +// middleware encapsulates all mutable state required to expose metrics and +// instrument Fiber requests. +type middleware struct { + cfg Config + gatherer prometheus.Gatherer + requestsTotal *prometheus.CounterVec + requestsByClass *prometheus.CounterVec + requestDuration *prometheus.HistogramVec + requestSize *prometheus.HistogramVec + responseSize *prometheus.HistogramVec + requestInFlight *prometheus.GaugeVec + metricsHandler fiber.Handler + skipURIs map[string]struct{} + ignoreStatusCode map[int]struct{} + registeredRoutes map[string]struct{} + routesVersion int + routesMu sync.RWMutex +} + +// New creates a new Prometheus middleware handler. +// +// The returned handler records request/response metrics for all routes that are +// mounted in the current Fiber application and serves the Prometheus endpoint +// when it detects that the registered metrics route is being invoked. +func New(config ...Config) fiber.Handler { + cfg := configDefault(config...) + + registry, gatherer := resolveRegistry(cfg) + + if !cfg.DisableGoCollector { + registerCollector(registry, collectors.NewGoCollector()) + } + + if !cfg.DisableProcessCollector { + registerCollector(registry, collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + } + + labels := make(prometheus.Labels, len(cfg.Labels)+1) + for key, value := range cfg.Labels { + labels[key] = value + } + if cfg.Service != "" { + labels["service"] = cfg.Service + } + + counter := promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "requests_total"), + Help: "Count all http requests by status code, method and path.", + ConstLabels: labels, + }, + []string{"status_code", "method", "path"}, + ) + + statusClassCounter := promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "requests_status_class_total"), + Help: "Count all http requests grouped by status class, method and path.", + ConstLabels: labels, + }, + []string{"status_class", "method", "path"}, + ) + + histogram := promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "request_duration_seconds"), + Help: "Duration of all HTTP requests by status code, method and path.", + ConstLabels: labels, + Buckets: cfg.RequestDurationBuckets, + }, + []string{"status_code", "method", "path"}, + ) + + requestHistogram := promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "request_size_bytes"), + Help: "Size of all HTTP requests by status code, method and path.", + ConstLabels: labels, + Buckets: cfg.RequestSizeBuckets, + }, + []string{"status_code", "method", "path"}, + ) + + responseHistogram := promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "response_size_bytes"), + Help: "Size of all HTTP responses by status code, method and path.", + ConstLabels: labels, + Buckets: cfg.ResponseSizeBuckets, + }, + []string{"status_code", "method", "path"}, + ) + + gauge := promauto.With(registry).NewGaugeVec( + prometheus.GaugeOpts{ + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "requests_in_progress_total"), + Help: "All the requests in progress", + ConstLabels: labels, + }, + []string{"method", "path"}, + ) + + metricsHandler := adaptor.HTTPHandler(promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{ + EnableOpenMetrics: cfg.EnableOpenMetrics, + EnableOpenMetricsTextCreatedSamples: cfg.EnableOpenMetricsTextCreatedSamples, + DisableCompression: cfg.DisableCompression, + })) + + m := &middleware{ + cfg: cfg, + gatherer: gatherer, + requestsTotal: counter, + requestsByClass: statusClassCounter, + requestDuration: histogram, + requestSize: requestHistogram, + responseSize: responseHistogram, + requestInFlight: gauge, + metricsHandler: metricsHandler, + skipURIs: make(map[string]struct{}, len(cfg.SkipURIs)), + ignoreStatusCode: make(map[int]struct{}, len(cfg.IgnoreStatusCodes)), + } + + for _, path := range cfg.SkipURIs { + m.skipURIs[normalizePath(path)] = struct{}{} + } + + for _, code := range cfg.IgnoreStatusCodes { + m.ignoreStatusCode[code] = struct{}{} + } + + return func(ctx fiber.Ctx) error { + return m.handle(ctx) + } +} + +// resolveRegistry selects the registerer/gatherer pair used for collector +// registration and metrics exposure, enforcing that both interfaces point to the +// same metrics source. +func resolveRegistry(cfg Config) (prometheus.Registerer, prometheus.Gatherer) { + registerer := cfg.Registerer + gatherer := cfg.Gatherer + + if registerer == nil && gatherer == nil { + reg := prometheus.NewRegistry() + return reg, reg + } + + if registerer == nil && gatherer != nil { + if reg, ok := gatherer.(prometheus.Registerer); ok { + return reg, gatherer + } + panic("prometheus middleware: provided Gatherer does not implement prometheus.Registerer; supply a matching Registerer") + } + + if registerer != nil && gatherer == nil { + if g, ok := registerer.(prometheus.Gatherer); ok { + return registerer, g + } + panic("prometheus middleware: provided Registerer does not implement prometheus.Gatherer; supply a matching Gatherer or use prometheus.Registry") + } + + if regGatherer, ok := registerer.(prometheus.Gatherer); ok { + if regGatherer != gatherer { + panic("prometheus middleware: Registerer and Gatherer must reference the same metrics source") + } + return registerer, gatherer + } + + panic("prometheus middleware: Registerer must implement prometheus.Gatherer when a custom Gatherer is provided") +} + +// handle dispatches the request to the next middleware and serves the metrics +// endpoint when the current route matches the registered metrics path. +func (m *middleware) handle(ctx fiber.Ctx) error { + if m.isMetricsRequest(ctx) { + method := ctx.Method() + if method != fiber.MethodGet && method != fiber.MethodHead { + return fiber.ErrMethodNotAllowed + } + _ = m.metricsHandler(ctx) + return nil + } + + if m.cfg.Next != nil && m.cfg.Next(ctx) { + return ctx.Next() + } + + return m.instrument(ctx) +} + +// isMetricsRequest returns true when the current request is routed to the +// Prometheus endpoint exposed by this middleware. +func (m *middleware) isMetricsRequest(ctx fiber.Ctx) bool { + route := ctx.Route() + if route == nil { + return false + } + + registered := route.Path + if registered == "" { + registered = "/" + } else if registered != "/" { + registered = normalizePath(registered) + } + + return registered == normalizePath(ctx.Path()) +} + +// instrument wraps the downstream handler, recording duration, request/response +// sizes, in-flight counts, and status code metrics for the active route. +func (m *middleware) instrument(ctx fiber.Ctx) error { + method := utils.CopyString(ctx.Method()) + routePath := m.resolveRoutePath(ctx) + routeKey := method + " " + routePath + + registered := m.refreshRoutes(ctx, routeKey) + trackUnmatched := false + if !registered && m.cfg.TrackUnmatchedRequests { + routePath = normalizePath(m.cfg.UnmatchedRouteLabel) + trackUnmatched = true + } + + inflightPath := routePath + m.requestInFlight.WithLabelValues(method, inflightPath).Inc() + deleteGauge := false + defer func() { + m.requestInFlight.WithLabelValues(method, inflightPath).Dec() + if deleteGauge { + m.requestInFlight.DeleteLabelValues(method, inflightPath) + } + }() + + start := time.Now() + + err := ctx.Next() + + if !registered && !trackUnmatched { + deleteGauge = true + return err + } + + if _, ok := m.skipURIs[routePath]; ok { + deleteGauge = true + return err + } + + status := fiber.StatusInternalServerError + if err != nil { + if e, ok := err.(*fiber.Error); ok { + status = e.Code + } + } else { + status = ctx.Response().StatusCode() + } + + if _, ok := m.ignoreStatusCode[status]; ok { + deleteGauge = true + return err + } + + statusCode := strconv.Itoa(status) + + m.requestsTotal.WithLabelValues(statusCode, method, routePath).Inc() + + statusClass := strconv.Itoa(status/100) + "xx" + m.requestsByClass.WithLabelValues(statusClass, method, routePath).Inc() + + elapsed := float64(time.Since(start).Nanoseconds()) / 1e9 + + spanCtx := trace.SpanContextFromContext(ctx.Context()) + traceID := spanCtx.TraceID() + var exemplarLabels prometheus.Labels + if traceID.IsValid() { + exemplarLabels = prometheus.Labels{"traceID": traceID.String()} + } + + observe := func(observer prometheus.Observer, value float64) { + if exemplarLabels != nil { + if exemplarObserver, ok := observer.(prometheus.ExemplarObserver); ok { + exemplarObserver.ObserveWithExemplar(value, exemplarLabels) + return + } + } + observer.Observe(value) + } + + histogram := m.requestDuration.WithLabelValues(statusCode, method, routePath) + observe(histogram, elapsed) + + requestLength := ctx.Request().Header.ContentLength() + if requestLength < 0 { + requestLength = len(ctx.Request().Body()) + } + requestHistogram := m.requestSize.WithLabelValues(statusCode, method, routePath) + observe(requestHistogram, float64(requestLength)) + + responseLength := ctx.Response().Header.ContentLength() + if responseLength < 0 { + responseLength = len(ctx.Response().Body()) + } + responseHistogram := m.responseSize.WithLabelValues(statusCode, method, routePath) + observe(responseHistogram, float64(responseLength)) + + return err +} + +// refreshRoutes ensures the registeredRoutes map reflects the current Fiber +// stack. If the requested route key is missing or the stack length changes, the +// cache is rebuilt before returning the registration status for the provided +// key. +func (m *middleware) refreshRoutes(ctx fiber.Ctx, routeKey string) bool { + stack := ctx.App().Stack() + stackVersion := stackSize(stack) + + m.routesMu.RLock() + currentVersion := m.routesVersion + _, registered := m.registeredRoutes[routeKey] + m.routesMu.RUnlock() + + if registered && currentVersion == stackVersion { + return true + } + + routes := make(map[string]struct{}) + for i := range stack { + routesList := stack[i] + for j := range routesList { + r := routesList[j] + if r == nil { + continue + } + + path := utils.CopyString(r.Path) + if path == "" { + path = "/" + } else if path != "/" { + path = normalizePath(path) + } + + routes[r.Method+" "+path] = struct{}{} + if r.Method == fiber.MethodGet { + routes[fiber.MethodHead+" "+path] = struct{}{} + } + } + } + + m.routesMu.Lock() + m.registeredRoutes = routes + m.routesVersion = stackVersion + _, registered = routes[routeKey] + m.routesMu.Unlock() + + return registered +} + +// resolveRoutePath returns the normalized route path associated with the +// current request. When Fiber has not resolved a route, the request path is +// used as a fallback so metrics can still be attributed. +func (m *middleware) resolveRoutePath(ctx fiber.Ctx) string { + routePath := "/" + if route := ctx.Route(); route != nil { + routePath = utils.CopyString(route.Path) + } + if routePath == "" || routePath == "/" { + routePath = utils.CopyString(ctx.Path()) + } + if routePath != "" && routePath != "/" { + routePath = normalizePath(routePath) + } + return routePath +} + +// normalizePath trims trailing slashes and converts empty paths to "/" so +// routes can be matched consistently. +func normalizePath(routePath string) string { + normalized := strings.TrimRight(routePath, "/") + if normalized == "" { + return "/" + } + return normalized +} + +// stackSize returns the total number of routes present in the Fiber stack. +func stackSize(stack [][]*fiber.Route) int { + size := 0 + for i := range stack { + size += len(stack[i]) + } + return size +} + +// registerCollector attempts to register the provided collector, suppressing +// the AlreadyRegistered error so callers can opt-in without coordination. +func registerCollector(registry prometheus.Registerer, collector prometheus.Collector) { + if err := registry.Register(collector); err != nil { + if _, ok := err.(prometheus.AlreadyRegisteredError); ok { + return + } + panic(err) + } +} diff --git a/v3/prometheus/prometheus_test.go b/v3/prometheus/prometheus_test.go new file mode 100644 index 000000000..235cd81ae --- /dev/null +++ b/v3/prometheus/prometheus_test.go @@ -0,0 +1,729 @@ +package prometheus + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" + tracesdk "go.opentelemetry.io/otel/sdk/trace" +) + +var noTimeoutConfig = fiber.TestConfig{Timeout: 0} + +func getMetrics(t *testing.T, app *fiber.App, path string) string { + t.Helper() + + if path == "" { + path = "/metrics" + } + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, path, nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + + return string(body) +} + +func newAppWithMiddleware(cfg Config, metricsPath string) (*fiber.App, fiber.Handler) { + app := fiber.New() + handler := New(cfg) + app.Use(handler) + if metricsPath == "" { + metricsPath = "/metrics" + } + app.Use(metricsPath, handler) + + return app, handler +} + +func TestMiddlewareRecordsMetrics(t *testing.T) { + app, _ := newAppWithMiddleware(Config{Service: "test-service"}, "") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + app.Post("/payload", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + payloadReq := httptest.NewRequest(fiber.MethodPost, "/payload", strings.NewReader("hello world")) + payloadReq.Header.Set("Content-Type", "text/plain") + if _, err := app.Test(payloadReq, noTimeoutConfig); err != nil { + t.Fatalf("unexpected payload request error: %v", err) + } + + metricsResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if metricsResp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", metricsResp.StatusCode) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if !strings.Contains(metrics, "http_requests_total") { + t.Fatalf("expected metrics to contain request counter, got %q", metrics) + } + if !strings.Contains(metrics, "path=\"/hello\"") { + t.Fatalf("expected metrics to contain path label, got %q", metrics) + } + if !strings.Contains(metrics, "service=\"test-service\"") { + t.Fatalf("expected metrics to include service label, got %q", metrics) + } + if !strings.Contains(metrics, "http_request_size_bytes_sum") { + t.Fatalf("expected metrics to contain request size histogram, got %q", metrics) + } + if !strings.Contains(metrics, "http_response_size_bytes_sum") { + t.Fatalf("expected metrics to contain response size histogram, got %q", metrics) + } + if !strings.Contains(metrics, "http_requests_status_class_total") { + t.Fatalf("expected metrics to contain status class counter") + } + if !strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/hello\",service=\"test-service\"}") { + t.Fatalf("expected in-flight gauge to include method and path labels, got %q", metrics) + } +} + +func TestDefaultRuntimeCollectorsEnabled(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + metrics := getMetrics(t, app, "/metrics") + + if !strings.Contains(metrics, "go_goroutines") { + t.Fatalf("expected Go collector metrics, got %q", metrics) + } + + if !strings.Contains(metrics, "process_cpu_seconds_total") { + t.Fatalf("expected process collector metrics, got %q", metrics) + } +} + +func TestSkipURIs(t *testing.T) { + app, _ := newAppWithMiddleware(Config{SkipURIs: []string{"/skip"}}, "") + app.Get("/skip", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/skip", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + metricsResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if strings.Contains(metrics, "path=\"/skip\"") { + t.Fatalf("expected skip path to be excluded, got %q", metrics) + } + if strings.Contains(metrics, "http_request_size_bytes_sum{status_code=\"200\",method=\"GET\",path=\"/skip\"}") { + t.Fatalf("expected skip path request size metric to be excluded, got %q", metrics) + } + if strings.Contains(metrics, "http_response_size_bytes_sum{status_code=\"200\",method=\"GET\",path=\"/skip\"}") { + t.Fatalf("expected skip path response size metric to be excluded, got %q", metrics) + } + if strings.Contains(metrics, "http_requests_status_class_total{status_class=\"2xx\",method=\"GET\",path=\"/skip\"}") { + t.Fatalf("expected skip path status class metric to be excluded") + } + if strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/skip\"}") { + t.Fatalf("expected skip path in-flight metric to be excluded") + } +} + +func TestIgnoreStatusCodes(t *testing.T) { + app, _ := newAppWithMiddleware(Config{IgnoreStatusCodes: []int{fiber.StatusUnauthorized}}, "") + app.Get("/deny", func(c fiber.Ctx) error { + return fiber.ErrUnauthorized + }) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/deny", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + if resp.StatusCode != fiber.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", resp.StatusCode) + } + + metricsResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if strings.Contains(metrics, "status_code=\"401\"") { + t.Fatalf("expected status code 401 to be ignored, got %q", metrics) + } + if strings.Contains(metrics, "http_request_size_bytes_sum{status_code=\"401\",method=\"GET\",path=\"/deny\"}") { + t.Fatalf("expected ignored status code request size metric to be excluded, got %q", metrics) + } + if strings.Contains(metrics, "http_response_size_bytes_sum{status_code=\"401\",method=\"GET\",path=\"/deny\"}") { + t.Fatalf("expected ignored status code response size metric to be excluded, got %q", metrics) + } + if strings.Contains(metrics, "http_requests_status_class_total{status_class=\"4xx\",method=\"GET\",path=\"/deny\"}") { + t.Fatalf("expected ignored status code status class metric to be excluded") + } +} + +func TestIgnoreStatusCodesRemovesInFlightGauge(t *testing.T) { + app, _ := newAppWithMiddleware(Config{IgnoreStatusCodes: []int{fiber.StatusUnauthorized}}, "") + app.Get("/deny", func(c fiber.Ctx) error { + return fiber.ErrUnauthorized + }) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/deny", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + if resp.StatusCode != fiber.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", resp.StatusCode) + } + + metrics := getMetrics(t, app, "/metrics") + if strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/deny\"") { + t.Fatalf("expected ignored status code in-flight metric to be removed, got %q", metrics) + } +} + +func TestCustomHistogramBuckets(t *testing.T) { + cfg := Config{ + RequestDurationBuckets: []float64{0.1, 0.2}, + RequestSizeBuckets: []float64{111, 222}, + ResponseSizeBuckets: []float64{333, 444}, + } + app, _ := newAppWithMiddleware(cfg, "") + app.Post("/bucket", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest(fiber.MethodPost, "/bucket", strings.NewReader(strings.Repeat("a", 150))) + req.Header.Set("Content-Type", "text/plain") + if _, err := app.Test(req, noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + metrics := getMetrics(t, app, "") + + if strings.Contains(metrics, "le=\"0.005\"") { + t.Fatalf("expected default duration buckets to be replaced, got %q", metrics) + } + if !strings.Contains(metrics, "http_request_duration_seconds_bucket{method=\"POST\",path=\"/bucket\",status_code=\"200\",le=\"0.2\"}") { + t.Fatalf("expected custom duration buckets in metrics, got %q", metrics) + } + + if strings.Contains(metrics, "le=\"5242880\"") { + t.Fatalf("expected default size buckets to be replaced, got %q", metrics) + } + if !strings.Contains(metrics, "http_request_size_bytes_bucket{method=\"POST\",path=\"/bucket\",status_code=\"200\",le=\"111\"}") { + t.Fatalf("expected custom request size buckets in metrics, got %q", metrics) + } + if !strings.Contains(metrics, "http_response_size_bytes_bucket{method=\"POST\",path=\"/bucket\",status_code=\"200\",le=\"333\"}") { + t.Fatalf("expected custom response size buckets in metrics, got %q", metrics) + } +} + +func TestTrackUnmatchedRequestsDisabled(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/unmatched", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected status 404, got %d", resp.StatusCode) + } + + metrics := getMetrics(t, app, "/metrics") + if strings.Contains(metrics, "path=\"/__unmatched__\"") { + t.Fatalf("expected unmatched routes to be excluded when tracking disabled, got %q", metrics) + } +} + +func TestTrackUnmatchedRequestsEnabled(t *testing.T) { + app, _ := newAppWithMiddleware(Config{TrackUnmatchedRequests: true}, "") + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/unmatched", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected status 404, got %d", resp.StatusCode) + } + + metrics := getMetrics(t, app, "/metrics") + if !strings.Contains(metrics, "http_requests_total{method=\"GET\",path=\"/__unmatched__\",status_code=\"404\"") { + t.Fatalf("expected unmatched route request counter to include fallback label, got %q", metrics) + } + if !strings.Contains(metrics, "http_requests_status_class_total{method=\"GET\",path=\"/__unmatched__\",status_class=\"4xx\"}") { + t.Fatalf("expected unmatched route status class counter to include fallback label, got %q", metrics) + } + if !strings.Contains(metrics, "http_request_duration_seconds_sum{method=\"GET\",path=\"/__unmatched__\",status_code=\"404\"") { + t.Fatalf("expected unmatched route duration histogram to include fallback label, got %q", metrics) + } +} + +func TestRoutesRefreshAfterInitialRequest(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/late", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected status 404 for late route before registration, got %d", resp.StatusCode) + } + + metrics := getMetrics(t, app, "") + if strings.Contains(metrics, "path=\"/late\"") { + t.Fatalf("expected metrics to exclude late route before registration, got %q", metrics) + } + + app.Get("/late", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/late", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error after registering route: %v", err) + } + + metrics = getMetrics(t, app, "") + if !strings.Contains(metrics, "http_requests_total{method=\"GET\",path=\"/late\",status_code=\"200\"}") { + t.Fatalf("expected metrics to include late-registered route, got %q", metrics) + } +} + +func TestNextSkipsInstrumentation(t *testing.T) { + app, _ := newAppWithMiddleware(Config{ + Next: func(c fiber.Ctx) bool { + return c.Path() == "/healthz" + }, + }, "") + app.Get("/healthz", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/healthz", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + metricsResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if strings.Contains(metrics, "path=\"/healthz\"") { + t.Fatalf("expected next-skipped path to be excluded, got %q", metrics) + } +} + +func TestCustomMetricsPath(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "/internal/metrics") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + req := httptest.NewRequest(fiber.MethodGet, "/internal/metrics", nil) + resp, err := app.Test(req, noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if !strings.Contains(metrics, "path=\"/hello\"") { + t.Fatalf("expected request metrics to be recorded, got %q", metrics) + } +} + +func TestMetricsEndpointAllowsHead(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + resp, err := app.Test(httptest.NewRequest(fiber.MethodHead, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics with HEAD: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestMetricsEndpointRejectsOtherMethods(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("posting metrics: %v", err) + } + if resp.StatusCode != fiber.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", resp.StatusCode) + } +} + +func TestHeadRequestsMatchGetRoutes(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + app.Get("/head-get", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodHead, "/head-get", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("unexpected HEAD request error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + metrics := getMetrics(t, app, "") + + if !strings.Contains(metrics, "http_requests_total{method=\"HEAD\",path=\"/head-get\",status_code=\"200\"}") { + t.Fatalf("expected HEAD request counter to be emitted, got %q", metrics) + } + + if !strings.Contains(metrics, "http_request_duration_seconds_count{method=\"HEAD\",path=\"/head-get\",status_code=\"200\"}") { + t.Fatalf("expected HEAD request duration histogram to be emitted, got %q", metrics) + } + + if !strings.Contains(metrics, "http_requests_in_progress_total{method=\"HEAD\",path=\"/head-get\"}") { + t.Fatalf("expected HEAD request in-flight gauge to be emitted, got %q", metrics) + } +} + +func TestCustomRegistry(t *testing.T) { + registry := prometheus.NewRegistry() + app, _ := newAppWithMiddleware(Config{Registerer: registry}, "") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + metricsResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/metrics", nil), noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if !strings.Contains(metrics, "http_requests_total") { + t.Fatalf("expected metrics to be produced, got %q", metrics) + } +} + +func TestRegistererWithoutGathererPanics(t *testing.T) { + baseRegistry := prometheus.NewRegistry() + registerer := prometheus.WrapRegistererWithPrefix("custom_", baseRegistry) + + defer func() { + if r := recover(); r != nil { + message := fmt.Sprint(r) + if !strings.Contains(message, "Registerer does not implement prometheus.Gatherer") { + t.Fatalf("expected panic about missing Gatherer, got %q", message) + } + return + } + t.Fatal("expected panic when Registerer does not implement Gatherer") + }() + + _ = New(Config{Registerer: registerer}) +} + +func TestEnableOpenMetricsNegotiation(t *testing.T) { + app, _ := newAppWithMiddleware(Config{EnableOpenMetrics: true}, "") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + req := httptest.NewRequest(fiber.MethodGet, "/metrics", nil) + req.Header.Set("Accept", "application/openmetrics-text; version=1.0.0; charset=utf-8") + + resp, err := app.Test(req, noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/openmetrics-text") { + t.Fatalf("expected OpenMetrics content type, got %q", contentType) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if !strings.Contains(metrics, "# EOF") { + t.Fatalf("expected OpenMetrics EOF marker, got %q", metrics) + } +} + +func TestEnableOpenMetricsTextCreatedSamples(t *testing.T) { + app, _ := newAppWithMiddleware(Config{ + EnableOpenMetrics: true, + EnableOpenMetricsTextCreatedSamples: true, + }, "") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + req := httptest.NewRequest(fiber.MethodGet, "/metrics", nil) + req.Header.Set("Accept", "application/openmetrics-text; version=1.0.0; charset=utf-8") + + resp, err := app.Test(req, noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + metrics := string(body) + if !strings.Contains(metrics, "_created") { + t.Fatalf("expected created samples in OpenMetrics output, got %q", metrics) + } +} + +func TestDisableCompression(t *testing.T) { + app, _ := newAppWithMiddleware(Config{DisableCompression: true}, "") + app.Get("/hello", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + if _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/hello", nil), noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + req := httptest.NewRequest(fiber.MethodGet, "/metrics", nil) + req.Header.Set("Accept-Encoding", "gzip") + + resp, err := app.Test(req, noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + if encoding := resp.Header.Get("Content-Encoding"); encoding != "" { + t.Fatalf("expected compression to be disabled, got %q", encoding) + } +} + +func TestDisableGoCollector(t *testing.T) { + app, _ := newAppWithMiddleware(Config{DisableGoCollector: true}, "") + + metrics := getMetrics(t, app, "") + + if strings.Contains(metrics, "go_goroutines") { + t.Fatalf("expected Go collector metrics to be disabled, got %q", metrics) + } + + if !strings.Contains(metrics, "process_cpu_seconds_total") { + t.Fatalf("expected process collector metrics to remain enabled, got %q", metrics) + } +} + +func TestDisableProcessCollector(t *testing.T) { + app, _ := newAppWithMiddleware(Config{DisableProcessCollector: true}, "") + + metrics := getMetrics(t, app, "") + + if strings.Contains(metrics, "process_cpu_seconds_total") { + t.Fatalf("expected process collector metrics to be disabled, got %q", metrics) + } + + if !strings.Contains(metrics, "go_goroutines") { + t.Fatalf("expected Go collector metrics to remain enabled, got %q", metrics) + } +} + +func TestStatusClassMetrics(t *testing.T) { + app, _ := newAppWithMiddleware(Config{}, "") + + app.Get("/ok", func(c fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + app.Get("/bad", func(c fiber.Ctx) error { + return fiber.ErrBadRequest + }) + + app.Get("/boom", func(c fiber.Ctx) error { + return fiber.ErrInternalServerError + }) + + requests := []*http.Request{ + httptest.NewRequest(fiber.MethodGet, "/ok", nil), + httptest.NewRequest(fiber.MethodGet, "/bad", nil), + httptest.NewRequest(fiber.MethodGet, "/boom", nil), + } + + for _, req := range requests { + if _, err := app.Test(req, noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + } + + metrics := getMetrics(t, app, "") + found := map[string]bool{} + for _, line := range strings.Split(metrics, "\n") { + if !strings.Contains(line, "http_requests_status_class_total") { + continue + } + + switch { + case strings.Contains(line, "status_class=\"2xx\"") && strings.Contains(line, "path=\"/ok\"") && strings.Contains(line, "method=\"GET\""): + found["2xx"] = true + case strings.Contains(line, "status_class=\"4xx\"") && strings.Contains(line, "path=\"/bad\"") && strings.Contains(line, "method=\"GET\""): + found["4xx"] = true + case strings.Contains(line, "status_class=\"5xx\"") && strings.Contains(line, "path=\"/boom\"") && strings.Contains(line, "method=\"GET\""): + found["5xx"] = true + } + } + + for _, class := range []string{"2xx", "4xx", "5xx"} { + if !found[class] { + t.Fatalf("expected status class %s metric to be present", class) + } + } +} + +func TestSizeHistogramsIncludeTraceExemplars(t *testing.T) { + prev := otel.GetTracerProvider() + tp := tracesdk.NewTracerProvider(tracesdk.WithSampler(tracesdk.AlwaysSample())) + otel.SetTracerProvider(tp) + t.Cleanup(func() { + otel.SetTracerProvider(prev) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := tp.Shutdown(ctx); err != nil { + t.Fatalf("shutting down tracer provider: %v", err) + } + }) + + tracer := otel.Tracer("test") + + app := fiber.New() + handler := New(Config{EnableOpenMetrics: true}) + + app.Use(func(c fiber.Ctx) error { + ctxWithSpan, span := tracer.Start(c.Context(), "test-request") + defer span.End() + c.SetContext(ctxWithSpan) + return handler(c) + }) + + app.Post("/upload", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + app.Use("/metrics", handler) + + payload := httptest.NewRequest(fiber.MethodPost, "/upload", strings.NewReader("payload")) + payload.Header.Set("Content-Type", "text/plain") + if _, err := app.Test(payload, noTimeoutConfig); err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + metricsReq := httptest.NewRequest(fiber.MethodGet, "/metrics", nil) + metricsReq.Header.Set("Accept", "application/openmetrics-text; version=1.0.0; charset=utf-8") + metricsResp, err := app.Test(metricsReq, noTimeoutConfig) + if err != nil { + t.Fatalf("fetching metrics: %v", err) + } + if metricsResp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", metricsResp.StatusCode) + } + + body, err := io.ReadAll(metricsResp.Body) + if err != nil { + t.Fatalf("reading metrics body: %v", err) + } + + metrics := string(body) + + requestLineWithExemplar := false + responseLineWithExemplar := false + for _, line := range strings.Split(metrics, "\n") { + if strings.Contains(line, "http_request_size_bytes_bucket{") && strings.Contains(line, "status_code=\"200\"") && strings.Contains(line, "method=\"POST\"") && strings.Contains(line, "path=\"/upload\"") && strings.Contains(line, "le=\"256.0\"") && strings.Contains(line, "# {traceID=\"") { + requestLineWithExemplar = true + } + if strings.Contains(line, "http_response_size_bytes_bucket{") && strings.Contains(line, "status_code=\"200\"") && strings.Contains(line, "method=\"POST\"") && strings.Contains(line, "path=\"/upload\"") && strings.Contains(line, "le=\"256.0\"") && strings.Contains(line, "# {traceID=\"") { + responseLineWithExemplar = true + } + } + + if !requestLineWithExemplar { + t.Fatalf("expected request size histogram to include a trace exemplar, got %q", metrics) + } + if !responseLineWithExemplar { + t.Fatalf("expected response size histogram to include a trace exemplar, got %q", metrics) + } +} From ea4709848406f322c1b38dcca7ac5ea199cc3052 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:46:58 -0500 Subject: [PATCH 2/8] Run gofumpt in prometheus --- v3/prometheus/config.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/v3/prometheus/config.go b/v3/prometheus/config.go index 585341aaf..ca9d6b630 100644 --- a/v3/prometheus/config.go +++ b/v3/prometheus/config.go @@ -34,13 +34,13 @@ type Config struct { // Optional. Default: a private registry. Registerer prometheus.Registerer - // Gatherer provides metrics to the HTTP handler. - // - // Optional. Default: a private registry/gatherer pair created when neither - // Registerer nor Gatherer is supplied. If only one is provided, it must also - // implement the other interface or the middleware will panic to prevent - // silently omitting metrics. - Gatherer prometheus.Gatherer + // Gatherer provides metrics to the HTTP handler. + // + // Optional. Default: a private registry/gatherer pair created when neither + // Registerer nor Gatherer is supplied. If only one is provided, it must also + // implement the other interface or the middleware will panic to prevent + // silently omitting metrics. + Gatherer prometheus.Gatherer // DisableGoCollector disables the Go runtime metrics collector registration. // From 81f2d3f55a29bb5c8a05c7b82890d360aef93996 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:16:09 -0500 Subject: [PATCH 3/8] Use utils trim helper in prometheus --- v3/prometheus/prometheus.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/v3/prometheus/prometheus.go b/v3/prometheus/prometheus.go index 87abe5a85..2cacc3c4c 100644 --- a/v3/prometheus/prometheus.go +++ b/v3/prometheus/prometheus.go @@ -4,7 +4,6 @@ package prometheus import ( "strconv" - "strings" "sync" "time" @@ -394,7 +393,7 @@ func (m *middleware) resolveRoutePath(ctx fiber.Ctx) string { // normalizePath trims trailing slashes and converts empty paths to "/" so // routes can be matched consistently. func normalizePath(routePath string) string { - normalized := strings.TrimRight(routePath, "/") + normalized := utils.TrimRight(routePath, '/') if normalized == "" { return "/" } From 841584b87d429633e67b1943b70fec5424caa123 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:28:24 -0500 Subject: [PATCH 4/8] Add Go version 1.26.x to CI workflow --- .github/workflows/test-prometheus.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-prometheus.yml b/.github/workflows/test-prometheus.yml index 3b4f06d69..7d8e1ac93 100644 --- a/.github/workflows/test-prometheus.yml +++ b/.github/workflows/test-prometheus.yml @@ -19,6 +19,7 @@ jobs: matrix: go-version: - 1.25.x + - 1.26.x steps: - name: Fetch Repository uses: actions/checkout@v5 From cdf6431a0ea2683eaf75fb52ce059fa7fbaa6f49 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:37:06 -0500 Subject: [PATCH 5/8] Upgrade GitHub Actions to checkout and setup-go v6 --- .github/workflows/test-prometheus.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-prometheus.yml b/.github/workflows/test-prometheus.yml index 7d8e1ac93..b93177a49 100644 --- a/.github/workflows/test-prometheus.yml +++ b/.github/workflows/test-prometheus.yml @@ -22,9 +22,9 @@ jobs: - 1.26.x steps: - name: Fetch Repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '${{ matrix.go-version }}' - name: Run Test From b35b3795d7d5297ee9124402697a24504d86d988 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:47:43 -0500 Subject: [PATCH 6/8] Fix formatting of the prometheus path in go.work --- go.work | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.work b/go.work index bafca2a03..51c9621c0 100644 --- a/go.work +++ b/go.work @@ -13,7 +13,7 @@ use ( ./v3/opa ./v3/otel ./v3/paseto - ./v3/prometheus + ./v3/prometheus ./v3/sentry ./v3/socketio ./v3/swaggerui From e210b035eeb759fdd6aef7fd13a21232e1b47ca5 Mon Sep 17 00:00:00 2001 From: Juan Calderon-Perez <835733+gaby@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:48:36 -0500 Subject: [PATCH 7/8] Update v3/prometheus/prometheus.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- v3/prometheus/prometheus.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/v3/prometheus/prometheus.go b/v3/prometheus/prometheus.go index 2cacc3c4c..9c345b404 100644 --- a/v3/prometheus/prometheus.go +++ b/v3/prometheus/prometheus.go @@ -197,8 +197,7 @@ func (m *middleware) handle(ctx fiber.Ctx) error { if method != fiber.MethodGet && method != fiber.MethodHead { return fiber.ErrMethodNotAllowed } - _ = m.metricsHandler(ctx) - return nil + return m.metricsHandler(ctx) } if m.cfg.Next != nil && m.cfg.Next(ctx) { From 7fe5a88dc8346a749737c4e03a53e391b898bdc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:38:54 +0000 Subject: [PATCH 8/8] fix: rename requests_in_progress_total gauge to requests_in_progress Agent-Logs-Url: https://github.com/gofiber/contrib/sessions/10c0c586-2ddf-40aa-a369-6310b4f7a70a Co-authored-by: gaby <835733+gaby@users.noreply.github.com> --- v3/prometheus/README.md | 4 ++-- v3/prometheus/prometheus.go | 2 +- v3/prometheus/prometheus_test.go | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/v3/prometheus/README.md b/v3/prometheus/README.md index f7d6639c3..830733ac2 100644 --- a/v3/prometheus/README.md +++ b/v3/prometheus/README.md @@ -11,12 +11,12 @@ Following metrics are available by default: http_requests_total http_requests_status_class_total http_request_duration_seconds -http_requests_in_progress_total +http_requests_in_progress http_request_size_bytes http_response_size_bytes ``` -`http_requests_in_progress_total` exposes both the HTTP method and normalized +`http_requests_in_progress` exposes both the HTTP method and normalized route path so you can pinpoint which handlers are currently running. > [!NOTE] diff --git a/v3/prometheus/prometheus.go b/v3/prometheus/prometheus.go index 9c345b404..4877e8bc5 100644 --- a/v3/prometheus/prometheus.go +++ b/v3/prometheus/prometheus.go @@ -113,7 +113,7 @@ func New(config ...Config) fiber.Handler { gauge := promauto.With(registry).NewGaugeVec( prometheus.GaugeOpts{ - Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "requests_in_progress_total"), + Name: prometheus.BuildFQName(cfg.Namespace, cfg.Subsystem, "requests_in_progress"), Help: "All the requests in progress", ConstLabels: labels, }, diff --git a/v3/prometheus/prometheus_test.go b/v3/prometheus/prometheus_test.go index 235cd81ae..35a325399 100644 --- a/v3/prometheus/prometheus_test.go +++ b/v3/prometheus/prometheus_test.go @@ -103,7 +103,7 @@ func TestMiddlewareRecordsMetrics(t *testing.T) { if !strings.Contains(metrics, "http_requests_status_class_total") { t.Fatalf("expected metrics to contain status class counter") } - if !strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/hello\",service=\"test-service\"}") { + if !strings.Contains(metrics, "http_requests_in_progress{method=\"GET\",path=\"/hello\",service=\"test-service\"}") { t.Fatalf("expected in-flight gauge to include method and path labels, got %q", metrics) } } @@ -154,7 +154,7 @@ func TestSkipURIs(t *testing.T) { if strings.Contains(metrics, "http_requests_status_class_total{status_class=\"2xx\",method=\"GET\",path=\"/skip\"}") { t.Fatalf("expected skip path status class metric to be excluded") } - if strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/skip\"}") { + if strings.Contains(metrics, "http_requests_in_progress{method=\"GET\",path=\"/skip\"}") { t.Fatalf("expected skip path in-flight metric to be excluded") } } @@ -212,7 +212,7 @@ func TestIgnoreStatusCodesRemovesInFlightGauge(t *testing.T) { } metrics := getMetrics(t, app, "/metrics") - if strings.Contains(metrics, "http_requests_in_progress_total{method=\"GET\",path=\"/deny\"") { + if strings.Contains(metrics, "http_requests_in_progress{method=\"GET\",path=\"/deny\"") { t.Fatalf("expected ignored status code in-flight metric to be removed, got %q", metrics) } } @@ -431,7 +431,7 @@ func TestHeadRequestsMatchGetRoutes(t *testing.T) { t.Fatalf("expected HEAD request duration histogram to be emitted, got %q", metrics) } - if !strings.Contains(metrics, "http_requests_in_progress_total{method=\"HEAD\",path=\"/head-get\"}") { + if !strings.Contains(metrics, "http_requests_in_progress{method=\"HEAD\",path=\"/head-get\"}") { t.Fatalf("expected HEAD request in-flight gauge to be emitted, got %q", metrics) } }