Skip to content

Commit bda202f

Browse files
feat: add path & method labels to prometheus metrics (cherry-pick #17362) (#17416)
1 parent 0f27da0 commit bda202f

File tree

2 files changed

+133
-10
lines changed

2 files changed

+133
-10
lines changed

coderd/httpmw/prometheus.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package httpmw
33
import (
44
"net/http"
55
"strconv"
6+
"strings"
67
"time"
78

89
"github.com/go-chi/chi/v5"
@@ -22,18 +23,18 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
2223
Name: "requests_processed_total",
2324
Help: "The total number of processed API requests",
2425
}, []string{"code", "method", "path"})
25-
requestsConcurrent := factory.NewGauge(prometheus.GaugeOpts{
26+
requestsConcurrent := factory.NewGaugeVec(prometheus.GaugeOpts{
2627
Namespace: "coderd",
2728
Subsystem: "api",
2829
Name: "concurrent_requests",
2930
Help: "The number of concurrent API requests.",
30-
})
31-
websocketsConcurrent := factory.NewGauge(prometheus.GaugeOpts{
31+
}, []string{"method", "path"})
32+
websocketsConcurrent := factory.NewGaugeVec(prometheus.GaugeOpts{
3233
Namespace: "coderd",
3334
Subsystem: "api",
3435
Name: "concurrent_websockets",
3536
Help: "The total number of concurrent API websockets.",
36-
})
37+
}, []string{"path"})
3738
websocketsDist := factory.NewHistogramVec(prometheus.HistogramOpts{
3839
Namespace: "coderd",
3940
Subsystem: "api",
@@ -61,7 +62,6 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
6162
var (
6263
start = time.Now()
6364
method = r.Method
64-
rctx = chi.RouteContext(r.Context())
6565
)
6666

6767
sw, ok := w.(*tracing.StatusWriter)
@@ -72,24 +72,25 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
7272
var (
7373
dist *prometheus.HistogramVec
7474
distOpts []string
75+
path = getRoutePattern(r)
7576
)
77+
7678
// We want to count WebSockets separately.
7779
if httpapi.IsWebsocketUpgrade(r) {
78-
websocketsConcurrent.Inc()
79-
defer websocketsConcurrent.Dec()
80+
websocketsConcurrent.WithLabelValues(path).Inc()
81+
defer websocketsConcurrent.WithLabelValues(path).Dec()
8082

8183
dist = websocketsDist
8284
} else {
83-
requestsConcurrent.Inc()
84-
defer requestsConcurrent.Dec()
85+
requestsConcurrent.WithLabelValues(method, path).Inc()
86+
defer requestsConcurrent.WithLabelValues(method, path).Dec()
8587

8688
dist = requestsDist
8789
distOpts = []string{method}
8890
}
8991

9092
next.ServeHTTP(w, r)
9193

92-
path := rctx.RoutePattern()
9394
distOpts = append(distOpts, path)
9495
statusStr := strconv.Itoa(sw.Status)
9596

@@ -98,3 +99,34 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
9899
})
99100
}
100101
}
102+
103+
func getRoutePattern(r *http.Request) string {
104+
rctx := chi.RouteContext(r.Context())
105+
if rctx == nil {
106+
return ""
107+
}
108+
109+
if pattern := rctx.RoutePattern(); pattern != "" {
110+
// Pattern is already available
111+
return pattern
112+
}
113+
114+
routePath := r.URL.Path
115+
if r.URL.RawPath != "" {
116+
routePath = r.URL.RawPath
117+
}
118+
119+
tctx := chi.NewRouteContext()
120+
routes := rctx.Routes
121+
if routes != nil && !routes.Match(tctx, r.Method, routePath) {
122+
// No matching pattern. /api/* requests will be matched as "UNKNOWN"
123+
// All other ones will be matched as "STATIC".
124+
if strings.HasPrefix(routePath, "/api/") {
125+
return "UNKNOWN"
126+
}
127+
return "STATIC"
128+
}
129+
130+
// tctx has the updated pattern, since Match mutates it
131+
return tctx.RoutePattern()
132+
}

coderd/httpmw/prometheus_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import (
88

99
"github.com/go-chi/chi/v5"
1010
"github.com/prometheus/client_golang/prometheus"
11+
cm "github.com/prometheus/client_model/go"
12+
"github.com/stretchr/testify/assert"
1113
"github.com/stretchr/testify/require"
1214

1315
"github.com/coder/coder/v2/coderd/httpmw"
1416
"github.com/coder/coder/v2/coderd/tracing"
17+
"github.com/coder/coder/v2/testutil"
18+
"github.com/coder/websocket"
1519
)
1620

1721
func TestPrometheus(t *testing.T) {
1822
t.Parallel()
23+
1924
t.Run("All", func(t *testing.T) {
2025
t.Parallel()
2126
req := httptest.NewRequest("GET", "/", nil)
@@ -29,4 +34,90 @@ func TestPrometheus(t *testing.T) {
2934
require.NoError(t, err)
3035
require.Greater(t, len(metrics), 0)
3136
})
37+
38+
t.Run("Concurrent", func(t *testing.T) {
39+
t.Parallel()
40+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
41+
defer cancel()
42+
43+
reg := prometheus.NewRegistry()
44+
promMW := httpmw.Prometheus(reg)
45+
46+
// Create a test handler to simulate a WebSocket connection
47+
testHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
48+
conn, err := websocket.Accept(rw, r, nil)
49+
if !assert.NoError(t, err, "failed to accept websocket") {
50+
return
51+
}
52+
defer conn.Close(websocket.StatusGoingAway, "")
53+
})
54+
55+
wrappedHandler := promMW(testHandler)
56+
57+
r := chi.NewRouter()
58+
r.Use(tracing.StatusWriterMiddleware, promMW)
59+
r.Get("/api/v2/build/{build}/logs", func(rw http.ResponseWriter, r *http.Request) {
60+
wrappedHandler.ServeHTTP(rw, r)
61+
})
62+
63+
srv := httptest.NewServer(r)
64+
defer srv.Close()
65+
// nolint: bodyclose
66+
conn, _, err := websocket.Dial(ctx, srv.URL+"/api/v2/build/1/logs", nil)
67+
require.NoError(t, err, "failed to dial WebSocket")
68+
defer conn.Close(websocket.StatusNormalClosure, "")
69+
70+
metrics, err := reg.Gather()
71+
require.NoError(t, err)
72+
require.Greater(t, len(metrics), 0)
73+
metricLabels := getMetricLabels(metrics)
74+
75+
concurrentWebsockets, ok := metricLabels["coderd_api_concurrent_websockets"]
76+
require.True(t, ok, "coderd_api_concurrent_websockets metric not found")
77+
require.Equal(t, "/api/v2/build/{build}/logs", concurrentWebsockets["path"])
78+
})
79+
80+
t.Run("UserRoute", func(t *testing.T) {
81+
t.Parallel()
82+
reg := prometheus.NewRegistry()
83+
promMW := httpmw.Prometheus(reg)
84+
85+
r := chi.NewRouter()
86+
r.With(promMW).Get("/api/v2/users/{user}", func(w http.ResponseWriter, r *http.Request) {})
87+
88+
req := httptest.NewRequest("GET", "/api/v2/users/john", nil)
89+
90+
sw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
91+
92+
r.ServeHTTP(sw, req)
93+
94+
metrics, err := reg.Gather()
95+
require.NoError(t, err)
96+
require.Greater(t, len(metrics), 0)
97+
metricLabels := getMetricLabels(metrics)
98+
99+
reqProcessed, ok := metricLabels["coderd_api_requests_processed_total"]
100+
require.True(t, ok, "coderd_api_requests_processed_total metric not found")
101+
require.Equal(t, "/api/v2/users/{user}", reqProcessed["path"])
102+
require.Equal(t, "GET", reqProcessed["method"])
103+
104+
concurrentRequests, ok := metricLabels["coderd_api_concurrent_requests"]
105+
require.True(t, ok, "coderd_api_concurrent_requests metric not found")
106+
require.Equal(t, "/api/v2/users/{user}", concurrentRequests["path"])
107+
require.Equal(t, "GET", concurrentRequests["method"])
108+
})
109+
}
110+
111+
func getMetricLabels(metrics []*cm.MetricFamily) map[string]map[string]string {
112+
metricLabels := map[string]map[string]string{}
113+
for _, metricFamily := range metrics {
114+
metricName := metricFamily.GetName()
115+
metricLabels[metricName] = map[string]string{}
116+
for _, metric := range metricFamily.GetMetric() {
117+
for _, labelPair := range metric.GetLabel() {
118+
metricLabels[metricName][labelPair.GetName()] = labelPair.GetValue()
119+
}
120+
}
121+
}
122+
return metricLabels
32123
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy