From 1ec026e191d9c694d6f5961e8f2fae55753773ec Mon Sep 17 00:00:00 2001 From: Philipp Eckel <111437998+philippeckelintive@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:30:38 +0100 Subject: [PATCH] feat: add Prometheus metrics (#439) * feat: add Prometheus metrics * setup metrics using Prometheus client under /metrics endpoint * add configuration value for metrics * configure Prometheus middleware for generic metrics * provide metrics for totals of users, gists and SSH keys * modify test request to optionally return the response * provide integration test for Prometheus metrics * update documentation * chore: make fmt --- config.yml | 3 + docs/.vitepress/config.mts | 1 + docs/configuration/cheat-sheet.md | 1 + docs/configuration/metrics.md | 49 ++++++++++ go.mod | 11 ++- go.sum | 24 ++++- internal/config/config.go | 4 + internal/web/handlers/health/metrics.go | 9 -- internal/web/handlers/metrics/metrics.go | 101 ++++++++++++++++++++ internal/web/server/middlewares.go | 5 + internal/web/server/router.go | 19 ++-- internal/web/test/metrics_test.go | 113 +++++++++++++++++++++++ internal/web/test/server.go | 9 +- 13 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 docs/configuration/metrics.md delete mode 100644 internal/web/handlers/health/metrics.go create mode 100644 internal/web/handlers/metrics/metrics.go create mode 100644 internal/web/test/metrics_test.go diff --git a/config.yml b/config.yml index e9b10d7..fa6f3ec 100644 --- a/config.yml +++ b/config.yml @@ -48,6 +48,9 @@ http.port: 6157 # Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true http.git-enabled: true +# Enable or disable the metrics endpoint (either `true` or `false`). Default: false +metrics.enabled: false + # SSH built-in server configuration # Note: it is not using the SSH daemon from your machine (yet) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4849d21..bd7625b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -46,6 +46,7 @@ export default defineConfig({ {text: 'Custom assets', link: '/custom-assets'}, {text: 'Custom links', link: '/custom-links'}, {text: 'Cheat Sheet', link: '/cheat-sheet'}, + {text: 'Metrics', link: '/metrics'}, {text: 'Admin panel', link: '/admin-panel'}, ], collapsed: false }, diff --git a/docs/configuration/cheat-sheet.md b/docs/configuration/cheat-sheet.md index a80e77c..e76686e 100644 --- a/docs/configuration/cheat-sheet.md +++ b/docs/configuration/cheat-sheet.md @@ -19,6 +19,7 @@ aside: false | http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. | | http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. | | http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) | +| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) | | ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) | | ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | | ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | diff --git a/docs/configuration/metrics.md b/docs/configuration/metrics.md new file mode 100644 index 0000000..36eb22a --- /dev/null +++ b/docs/configuration/metrics.md @@ -0,0 +1,49 @@ +# Metrics + +Opengist offers built-in support for Prometheus metrics to help you monitor the performance and usage of your instance. These metrics provide insights into application health, user activity, and database statistics. + +## Enabling metrics + +By default, the metrics endpoint is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md): + +```yaml +metrics.enabled = true +``` + +Alternatively, you can use the environment variable: + +```bash +OG_METRICS_ENABLED=true +``` + +Once enabled, metrics are available at the /metrics endpoint. + +## Available metrics + +### Opengist-specific metrics + +| Metric Name | Type | Description | +|-------------|------|-------------| +| `opengist_users_total` | Gauge | Total number of registered users | +| `opengist_gists_total` | Gauge | Total number of gists in the system | +| `opengist_ssh_keys_total` | Gauge | Total number of SSH keys added by users | + +### Standard HTTP metrics + +In addition to the Opengist-specific metrics, standard Prometheus HTTP metrics are also available through the Echo Prometheus middleware. These include request durations, request counts, and request/response sizes. + +These standard metrics follow the Prometheus naming convention and include labels for HTTP method, status code, and handler path. + +## Security Considerations + +The metrics endpoint exposes information about your Opengist instance that might be sensitive in some environments. Consider using a reverse proxy with authentication for the `/metrics` endpoint if your Opengist instance is publicly accessible. + +Example with Nginx: + +```shell +location /metrics { + auth_basic "Metrics"; + auth_basic_user_file /etc/nginx/.htpasswd; + proxy_pass http://localhost:6157/metrics; +} +``` diff --git a/go.mod b/go.mod index 4475679..4c06e59 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,11 @@ require ( github.com/gorilla/schema v1.4.1 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 + github.com/labstack/echo-contrib v0.17.2 github.com/labstack/echo/v4 v4.13.3 github.com/markbates/goth v1.80.0 github.com/pquerna/otp v1.4.0 + github.com/prometheus/client_golang v1.20.5 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.5 @@ -35,6 +37,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/RoaringBitmap/roaring v1.9.4 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.17.0 // indirect github.com/blevesearch/bleve_index_api v1.1.13 // indirect github.com/blevesearch/geo v0.1.20 // indirect @@ -54,6 +57,7 @@ require ( github.com/blevesearch/zapx/v15 v15.3.16 // indirect github.com/blevesearch/zapx/v16 v16.1.9-0.20241217210638-a0519e7caf3b // indirect github.com/boombuler/barcode v1.0.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect @@ -77,6 +81,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -87,8 +92,12 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mschoch/smat v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.61.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -103,7 +112,7 @@ require ( golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/time v0.8.0 // indirect - google.golang.org/protobuf v1.35.2 // indirect + google.golang.org/protobuf v1.36.1 // indirect modernc.org/libc v1.61.2 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect diff --git a/go.sum b/go.sum index 0a07646..ffb0cc4 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eL github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +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/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.17.0 h1:1X2TS7aHz1ELcC0yU1y2stUs/0ig5oMU6STFZGrhvHI= github.com/bits-and-blooms/bitset v1.17.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= @@ -54,6 +56,8 @@ github.com/blevesearch/zapx/v16 v16.1.9-0.20241217210638-a0519e7caf3b/go.mod h1: github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +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/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY= github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA= @@ -148,10 +152,16 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 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/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w= +github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -179,6 +189,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -186,6 +198,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= +github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -251,8 +271,8 @@ golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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= diff --git a/internal/config/config.go b/internal/config/config.go index c2e868a..0e08ab4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,6 +71,8 @@ type config struct { OIDCSecret string `yaml:"oidc.secret" env:"OG_OIDC_SECRET"` OIDCDiscoveryUrl string `yaml:"oidc.discovery-url" env:"OG_OIDC_DISCOVERY_URL"` + MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"` + CustomName string `yaml:"custom.name" env:"OG_CUSTOM_NAME"` CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"` CustomFavicon string `yaml:"custom.favicon" env:"OG_CUSTOM_FAVICON"` @@ -110,6 +112,8 @@ func configWithDefaults() (*config, error) { c.GiteaUrl = "https://gitea.com" c.GiteaName = "Gitea" + c.MetricsEnabled = false + return c, nil } diff --git a/internal/web/handlers/health/metrics.go b/internal/web/handlers/health/metrics.go deleted file mode 100644 index 9630cfb..0000000 --- a/internal/web/handlers/health/metrics.go +++ /dev/null @@ -1,9 +0,0 @@ -package health - -import "github.com/thomiceli/opengist/internal/web/context" - -// Metrics is a dummy handler to satisfy the /metrics endpoint (for Prometheus, Openmetrics, etc.) -// until we have a proper metrics endpoint -func Metrics(ctx *context.Context) error { - return ctx.String(200, "") -} diff --git a/internal/web/handlers/metrics/metrics.go b/internal/web/handlers/metrics/metrics.go new file mode 100644 index 0000000..fdb57cc --- /dev/null +++ b/internal/web/handlers/metrics/metrics.go @@ -0,0 +1,101 @@ +package metrics + +import ( + "github.com/labstack/echo-contrib/echoprometheus" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/web/context" +) + +var ( + // Using promauto to automatically register metrics with the default registry + countUsersGauge prometheus.Gauge + countGistsGauge prometheus.Gauge + countSSHKeysGauge prometheus.Gauge + + metricsInitialized bool = false +) + +// initMetrics initializes metrics if they're not already initialized +func initMetrics() { + if metricsInitialized { + return + } + + // Only initialize metrics if they're enabled + if config.C.MetricsEnabled { + countUsersGauge = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "opengist_users_total", + Help: "Total number of users", + }, + ) + + countGistsGauge = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "opengist_gists_total", + Help: "Total number of gists", + }, + ) + + countSSHKeysGauge = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "opengist_ssh_keys_total", + Help: "Total number of SSH keys", + }, + ) + + metricsInitialized = true + } +} + +// updateMetrics refreshes all metric values from the database +func updateMetrics() { + // Only update metrics if they're enabled + if !config.C.MetricsEnabled || !metricsInitialized { + return + } + + // Update users count + countUsers, err := db.CountAll(&db.User{}) + if err == nil { + countUsersGauge.Set(float64(countUsers)) + } + + // Update gists count + countGists, err := db.CountAll(&db.Gist{}) + if err == nil { + countGistsGauge.Set(float64(countGists)) + } + + // Update SSH keys count + countKeys, err := db.CountAll(&db.SSHKey{}) + if err == nil { + countSSHKeysGauge.Set(float64(countKeys)) + } +} + +// Metrics handles prometheus metrics endpoint requests. +func Metrics(ctx *context.Context) error { + // If metrics are disabled, return 404 + if !config.C.MetricsEnabled { + return ctx.NotFound("Metrics endpoint is disabled") + } + + // Initialize metrics if not already done + initMetrics() + + // Update metrics + updateMetrics() + + // Get the Echo context + echoCtx := ctx.Context + + // Use the Prometheus metrics handler + handler := echoprometheus.NewHandler() + + // Call the handler + return handler(echoCtx) +} diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index b9066f2..e5b1cc9 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -3,6 +3,7 @@ package server import ( "errors" "fmt" + "github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog/log" @@ -34,6 +35,10 @@ func (s *Server) useCustomContext() { func (s *Server) registerMiddlewares() { s.echo.Use(Middleware(dataInit).toEcho()) s.echo.Use(Middleware(locale).toEcho()) + if config.C.MetricsEnabled { + p := echoprometheus.NewMiddleware("opengist") + s.echo.Use(p) + } s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{ Getter: middleware.MethodFromForm("_method"), diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 6948606..88e270d 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -1,6 +1,13 @@ package server import ( + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/index" @@ -10,14 +17,9 @@ import ( "github.com/thomiceli/opengist/internal/web/handlers/gist" "github.com/thomiceli/opengist/internal/web/handlers/git" "github.com/thomiceli/opengist/internal/web/handlers/health" + "github.com/thomiceli/opengist/internal/web/handlers/metrics" "github.com/thomiceli/opengist/internal/web/handlers/settings" "github.com/thomiceli/opengist/public" - "net/http" - "os" - "path" - "path/filepath" - "strings" - "time" ) func (s *Server) registerRoutes() { @@ -29,7 +31,10 @@ func (s *Server) registerRoutes() { r.POST("/preview", gist.Preview, logged) r.GET("/healthcheck", health.Healthcheck) - r.GET("/metrics", health.Metrics) + + if config.C.MetricsEnabled { + r.GET("/metrics", metrics.Metrics) + } r.GET("/register", auth.Register) r.POST("/register", auth.ProcessRegister) diff --git a/internal/web/test/metrics_test.go b/internal/web/test/metrics_test.go new file mode 100644 index 0000000..4fd2863 --- /dev/null +++ b/internal/web/test/metrics_test.go @@ -0,0 +1,113 @@ +package test + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + "io" + "net/http" + "os" + "strconv" + "strings" + "testing" +) + +var ( + SSHKey = db.SSHKeyDTO{ + Title: "Test SSH Key", + Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`, + } + AdminUser = db.UserDTO{ + Username: "admin", + Password: "admin", + } + + SimpleGist = db.GistDTO{ + Title: "Simple Test Gist", + Description: "A simple gist for testing", + VisibilityDTO: db.VisibilityDTO{ + Private: 0, + }, + Name: []string{"file1.txt"}, + Content: []string{"This is the content of file1"}, + Topics: "", + } +) + +// TestMetrics tests the metrics endpoint functionality of the application. +// It verifies that the metrics endpoint correctly reports counts for: +// - Total number of users +// - Total number of gists +// - Total number of SSH keys +// +// The test follows these steps: +// 1. Enables metrics via environment variable +// 2. Sets up test environment +// 3. Registers and logs in an admin user +// 4. Creates a gist and adds an SSH key +// 5. Queries the metrics endpoint +// 6. Verifies the reported metrics match expected values +// +// Environment variables: +// - OG_METRICS_ENABLED: Set to "true" for this test +func TestMetrics(t *testing.T) { + originalValue := os.Getenv("OG_METRICS_ENABLED") + + os.Setenv("OG_METRICS_ENABLED", "true") + + defer os.Setenv("OG_METRICS_ENABLED", originalValue) + + s := Setup(t) + defer Teardown(t, s) + + register(t, s, AdminUser) + login(t, s, AdminUser) + + err := s.Request("GET", "/all", nil, 200) + require.NoError(t, err) + + err = s.Request("POST", "/", SimpleGist, 302) + require.NoError(t, err) + + err = s.Request("POST", "/settings/ssh-keys", SSHKey, 302) + require.NoError(t, err) + + var metricsRes http.Response + err = s.Request("GET", "/metrics", nil, 200, &metricsRes) + require.NoError(t, err) + + body, err := io.ReadAll(metricsRes.Body) + defer metricsRes.Body.Close() + require.NoError(t, err) + + lines := strings.Split(string(body), "\n") + var usersTotal float64 + var gistsTotal float64 + var sshKeysTotal float64 + + for _, line := range lines { + if strings.HasPrefix(line, "opengist_users_total") { + parts := strings.Fields(line) + if len(parts) == 2 { + usersTotal, err = strconv.ParseFloat(parts[1], 64) + assert.NoError(t, err) + } + } else if strings.HasPrefix(line, "opengist_gists_total") { + parts := strings.Fields(line) + if len(parts) == 2 { + gistsTotal, err = strconv.ParseFloat(parts[1], 64) + assert.NoError(t, err) + } + } else if strings.HasPrefix(line, "opengist_ssh_keys_total") { + parts := strings.Fields(line) + if len(parts) == 2 { + sshKeysTotal, err = strconv.ParseFloat(parts[1], 64) + assert.NoError(t, err) + } + } + } + + assert.Equal(t, 1.0, usersTotal, "opengist_users_total should be 1") + assert.Equal(t, 1.0, gistsTotal, "opengist_gists_total should be 1") + assert.Equal(t, 1.0, sshKeysTotal, "opengist_ssh_keys_total should be 1") +} diff --git a/internal/web/test/server.go b/internal/web/test/server.go index b2d5875..469c39a 100644 --- a/internal/web/test/server.go +++ b/internal/web/test/server.go @@ -48,7 +48,7 @@ func (s *TestServer) stop() { s.server.Stop() } -func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int) error { +func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error { var bodyReader io.Reader if method == http.MethodPost || method == http.MethodPut { values := structToURLValues(data) @@ -92,6 +92,11 @@ func (s *TestServer) Request(method, uri string, data interface{}, expectedCode } } + // If a response pointer was provided, fill it with the response data + if len(responsePtr) > 0 && responsePtr[0] != nil { + *responsePtr[0] = *w.Result() + } + return nil } @@ -157,7 +162,7 @@ func Setup(t *testing.T) *TestServer { databaseType = os.Getenv("OPENGIST_TEST_DB") switch databaseType { case "sqlite": - databaseDsn = "file:" + filepath.Join(homePath, "tmp", "opengist.db") + databaseDsn = "file:" + filepath.Join(homePath, "tmp", "opengist_test.db") case "postgres": databaseDsn = "postgres://postgres:opengist@localhost:5432/opengist_test" case "mysql":