tenseleyflow/shithub / bb6cfc1

Browse files

Add Prometheus registry, HTTP middleware, and DB pool observer

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bb6cfc154eec3da94ea933120f11abd62ce3ec09
Parents
de4daa7
Tree
7892dc4

3 changed files

StatusFile+-
A internal/infra/metrics/dbobserver.go 36 0
A internal/infra/metrics/metrics.go 125 0
A internal/web/middleware/metrics.go 39 0
internal/infra/metrics/dbobserver.goadded
@@ -0,0 +1,36 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package metrics
4
+
5
+import (
6
+	"context"
7
+	"time"
8
+
9
+	"github.com/jackc/pgx/v5/pgxpool"
10
+)
11
+
12
+// ObserveDBPool starts a goroutine that periodically refreshes the pgx
13
+// pool gauges. The goroutine exits when ctx is canceled.
14
+func ObserveDBPool(ctx context.Context, pool *pgxpool.Pool, interval time.Duration) {
15
+	if pool == nil {
16
+		return
17
+	}
18
+	if interval <= 0 {
19
+		interval = 10 * time.Second
20
+	}
21
+	go func() {
22
+		t := time.NewTicker(interval)
23
+		defer t.Stop()
24
+		for {
25
+			select {
26
+			case <-ctx.Done():
27
+				return
28
+			case <-t.C:
29
+				stat := pool.Stat()
30
+				DBConnsAcquired.Set(float64(stat.AcquiredConns()))
31
+				DBConnsIdle.Set(float64(stat.IdleConns()))
32
+				DBConnsTotal.Set(float64(stat.TotalConns()))
33
+			}
34
+		}
35
+	}()
36
+}
internal/infra/metrics/metrics.goadded
@@ -0,0 +1,125 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package metrics owns the Prometheus registry. Standard metrics are
4
+// instantiated up front; per-package metrics register against the same
5
+// shared registry.
6
+package metrics
7
+
8
+import (
9
+	"crypto/subtle"
10
+	"net/http"
11
+
12
+	"github.com/prometheus/client_golang/prometheus"
13
+	"github.com/prometheus/client_golang/prometheus/collectors"
14
+	"github.com/prometheus/client_golang/prometheus/promhttp"
15
+)
16
+
17
+// Registry is the project-wide Prometheus registry. Subpackages register
18
+// their collectors against this so /metrics has a single source.
19
+var Registry = prometheus.NewRegistry()
20
+
21
+// Standard process / Go runtime metrics.
22
+func init() {
23
+	Registry.MustRegister(
24
+		collectors.NewGoCollector(),
25
+		collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
26
+	)
27
+}
28
+
29
+// HTTP request metrics. Wired by the HTTP middleware.
30
+var (
31
+	HTTPRequestsTotal = prometheus.NewCounterVec(
32
+		prometheus.CounterOpts{
33
+			Name: "shithub_http_requests_total",
34
+			Help: "Total HTTP requests by route, method, and status.",
35
+		},
36
+		[]string{"route", "method", "status"},
37
+	)
38
+	HTTPRequestDuration = prometheus.NewHistogramVec(
39
+		prometheus.HistogramOpts{
40
+			Name:    "shithub_http_request_duration_seconds",
41
+			Help:    "HTTP request duration distribution by route and method.",
42
+			Buckets: prometheus.ExponentialBuckets(0.001, 2.5, 12),
43
+		},
44
+		[]string{"route", "method"},
45
+	)
46
+	HTTPInFlight = prometheus.NewGauge(
47
+		prometheus.GaugeOpts{
48
+			Name: "shithub_http_in_flight",
49
+			Help: "Number of HTTP requests currently in flight.",
50
+		},
51
+	)
52
+	PanicsTotal = prometheus.NewCounter(
53
+		prometheus.CounterOpts{
54
+			Name: "shithub_panics_total",
55
+			Help: "Total panics caught by the recover middleware.",
56
+		},
57
+	)
58
+)
59
+
60
+// DB pool metrics. Updated periodically by an observer goroutine that the
61
+// caller starts via Observe(pool, interval).
62
+var (
63
+	DBConnsAcquired = prometheus.NewGauge(
64
+		prometheus.GaugeOpts{
65
+			Name: "shithub_db_pool_acquired",
66
+			Help: "Postgres connections currently checked out of the pool.",
67
+		},
68
+	)
69
+	DBConnsIdle = prometheus.NewGauge(
70
+		prometheus.GaugeOpts{
71
+			Name: "shithub_db_pool_idle",
72
+			Help: "Postgres connections currently idle in the pool.",
73
+		},
74
+	)
75
+	DBConnsTotal = prometheus.NewGauge(
76
+		prometheus.GaugeOpts{
77
+			Name: "shithub_db_pool_total",
78
+			Help: "Postgres connections currently held by the pool.",
79
+		},
80
+	)
81
+	DBAcquireWaitDurationTotal = prometheus.NewCounter(
82
+		prometheus.CounterOpts{
83
+			Name: "shithub_db_pool_acquire_wait_seconds_total",
84
+			Help: "Cumulative time clients spent waiting to acquire a Postgres connection.",
85
+		},
86
+	)
87
+)
88
+
89
+func init() {
90
+	Registry.MustRegister(
91
+		HTTPRequestsTotal,
92
+		HTTPRequestDuration,
93
+		HTTPInFlight,
94
+		PanicsTotal,
95
+		DBConnsAcquired,
96
+		DBConnsIdle,
97
+		DBConnsTotal,
98
+		DBAcquireWaitDurationTotal,
99
+	)
100
+}
101
+
102
+// Handler returns the /metrics HTTP handler. When user/pass is set, the
103
+// handler enforces HTTP Basic auth; otherwise it serves unauthenticated
104
+// (S35 will tighten the policy).
105
+func Handler(user, pass string) http.Handler {
106
+	h := promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
107
+		Registry: Registry,
108
+	})
109
+	if user == "" && pass == "" {
110
+		return h
111
+	}
112
+	expectedUser := []byte(user)
113
+	expectedPass := []byte(pass)
114
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115
+		gotUser, gotPass, ok := r.BasicAuth()
116
+		if !ok ||
117
+			subtle.ConstantTimeCompare([]byte(gotUser), expectedUser) != 1 ||
118
+			subtle.ConstantTimeCompare([]byte(gotPass), expectedPass) != 1 {
119
+			w.Header().Set("WWW-Authenticate", `Basic realm="metrics"`)
120
+			http.Error(w, "unauthorized", http.StatusUnauthorized)
121
+			return
122
+		}
123
+		h.ServeHTTP(w, r)
124
+	})
125
+}
internal/web/middleware/metrics.goadded
@@ -0,0 +1,39 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package middleware
4
+
5
+import (
6
+	"net/http"
7
+	"strconv"
8
+	"time"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
13
+)
14
+
15
+// Metrics returns middleware that records HTTP-level metrics via the
16
+// project-wide Prometheus registry.
17
+//
18
+// Route labels are extracted from chi when available so we get
19
+// "/owner/{repo}" instead of the per-repo concrete path — keeping
20
+// cardinality bounded.
21
+func Metrics(next http.Handler) http.Handler {
22
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23
+		metrics.HTTPInFlight.Inc()
24
+		defer metrics.HTTPInFlight.Dec()
25
+
26
+		start := time.Now()
27
+		rec := newStatusRecorder(w)
28
+		next.ServeHTTP(rec, r)
29
+
30
+		route := r.URL.Path
31
+		if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePattern() != "" {
32
+			route = rctx.RoutePattern()
33
+		}
34
+		method := r.Method
35
+		status := strconv.Itoa(rec.status)
36
+		metrics.HTTPRequestsTotal.WithLabelValues(route, method, status).Inc()
37
+		metrics.HTTPRequestDuration.WithLabelValues(route, method).Observe(time.Since(start).Seconds())
38
+	})
39
+}