diff --git a/appview/config/config.go b/appview/config/config.go index d8a533e8..f55b159a 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -14,6 +14,7 @@ type CoreConfig struct { CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` DbPath string `env:"DB_PATH, default=appview.db"` ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` + MetricsListenAddr string `env:"METRICS_LISTEN_ADDR, default=0.0.0.0:9090"` AppviewHost string `env:"APPVIEW_HOST, default=tangled.org"` AppviewName string `env:"APPVIEW_NAME, default=Tangled"` Dev bool `env:"DEV, default=false"` diff --git a/appview/metrics/metrics.go b/appview/metrics/metrics.go new file mode 100644 index 00000000..2f0977b5 --- /dev/null +++ b/appview/metrics/metrics.go @@ -0,0 +1,19 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + HttpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "appview_http_requests_total", + Help: "Total number of HTTP requests", + }, []string{"method", "path", "status"}) + + HttpRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "appview_http_request_duration_seconds", + Help: "HTTP request duration in seconds", + Buckets: prometheus.DefBuckets, + }, []string{"method", "path", "status"}) +) diff --git a/appview/metrics/middleware.go b/appview/metrics/middleware.go new file mode 100644 index 00000000..9475fa9b --- /dev/null +++ b/appview/metrics/middleware.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + + next.ServeHTTP(rec, r) + + // use the matched route pattern to avoid high cardinality + routePattern := chi.RouteContext(r.Context()).RoutePattern() + if routePattern == "" { + routePattern = "unknown" + } + + status := fmt.Sprintf("%d", rec.status) + duration := time.Since(start).Seconds() + + HttpRequestsTotal.WithLabelValues(r.Method, routePattern, status).Inc() + HttpRequestDuration.WithLabelValues(r.Method, routePattern, status).Observe(duration) + }) +} diff --git a/appview/state/router.go b/appview/state/router.go index 8b0e5ec1..6c46eb86 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -11,6 +11,7 @@ import ( "tangled.org/core/appview/issues" "tangled.org/core/appview/knots" "tangled.org/core/appview/labels" + "tangled.org/core/appview/metrics" "tangled.org/core/appview/middleware" "tangled.org/core/appview/migration" "tangled.org/core/appview/notifications" @@ -38,6 +39,8 @@ func (s *State) Router() http.Handler { s.logger, ) + router.Use(metrics.Middleware) + m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger) router.Use(m.BackgroundMigrationMiddleware) diff --git a/cmd/appview/main.go b/cmd/appview/main.go index ae63a5f9..54c38653 100644 --- a/cmd/appview/main.go +++ b/cmd/appview/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" + "github.com/prometheus/client_golang/prometheus/promhttp" "tangled.org/core/appview/config" "tangled.org/core/appview/state" tlog "tangled.org/core/log" @@ -35,6 +36,14 @@ func main() { logger.Info("starting server", "address", c.Core.ListenAddr) + go func() { + logger.Info("starting metrics server", "address", c.Core.MetricsListenAddr) + http.Handle("/metrics", promhttp.Handler()) + if err := http.ListenAndServe(c.Core.MetricsListenAddr, nil); err != nil { + logger.Error("failed to start metrics server", "err", err) + } + }() + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { logger.Error("failed to start appview", "err", err) }