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)
* [new relic](./v3/newrelic/README.md)
* [monitor](./v3/monitor/README.md)
+* [prometheus](./v3/prometheus/README.md)
* [open policy agent](./v3/opa/README.md)
* [otel (opentelemetry)](./v3/otel/README.md)
* [paseto](./v3/paseto/README.md)
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)
* [new relic](./newrelic/README.md)
* [monitor](./monitor/README.md)
+* [prometheus](./prometheus/README.md)
* [open policy agent](./opa/README.md)
* [otel (opentelemetry)](./otel/README.md)
* [paseto](./paseto/README.md)
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).
+
+
+[](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)
}
}