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
This commit is contained in:
Philipp Eckel
2025-03-17 14:30:38 +01:00
committed by GitHub
parent 8c7e941182
commit 1ec026e191
13 changed files with 328 additions and 21 deletions

View File

@ -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
}

View File

@ -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, "")
}

View File

@ -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)
}

View File

@ -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"),

View File

@ -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)

View File

@ -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")
}

View File

@ -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":