diff --git a/modules/caddyhttp/metrics.go b/modules/caddyhttp/metrics.go index 9bb97e0b47b..5a414078642 100644 --- a/modules/caddyhttp/metrics.go +++ b/modules/caddyhttp/metrics.go @@ -23,6 +23,10 @@ type Metrics struct { // managed by Caddy. PerHost bool `json:"per_host,omitempty"` + // Enable per-protocol metrics. Enabling this option adds + // protocol information (http/1.1, http/2, http/3) to metrics labels. + PerProto bool `json:"per_proto,omitempty"` + init sync.Once httpMetrics *httpMetrics `json:"-"` } @@ -44,6 +48,10 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { if metrics.PerHost { basicLabels = append(basicLabels, "host") } + if metrics.PerProto { + basicLabels = append(basicLabels, "proto") + } + metrics.httpMetrics.requestInFlight = promauto.With(registry).NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Subsystem: sub, @@ -71,6 +79,10 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { if metrics.PerHost { httpLabels = append(httpLabels, "host") } + if metrics.PerProto { + httpLabels = append(httpLabels, "proto") + } + metrics.httpMetrics.requestDuration = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{ Namespace: ns, Subsystem: sub, @@ -138,6 +150,12 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re statusLabels["host"] = strings.ToLower(r.Host) } + if h.metrics.PerProto { + proto := getProtocolInfo(r) + labels["proto"] = proto + statusLabels["proto"] = proto + } + inFlight := h.metrics.httpMetrics.requestInFlight.With(labels) inFlight.Inc() defer inFlight.Dec() @@ -212,3 +230,19 @@ func computeApproximateRequestSize(r *http.Request) int { } return s } + +func getProtocolInfo(r *http.Request) string { + switch r.ProtoMajor { + case 3: + return "http/3" + case 2: + return "http/2" + case 1: + if r.ProtoMinor == 1 { + return "http/1.1" + } + return "http/1.0" + default: + return "unknown" + } +} diff --git a/modules/caddyhttp/metrics_test.go b/modules/caddyhttp/metrics_test.go index 4e1aa8b3037..59fc6203897 100644 --- a/modules/caddyhttp/metrics_test.go +++ b/modules/caddyhttp/metrics_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/caddyserver/caddy/v2" @@ -379,6 +381,90 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { } } +func TestMetricsInstrumentedHandlerPerProto(t *testing.T) { + handler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.WriteHeader(http.StatusOK) + return nil + }) + + mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + return h.ServeHTTP(w, r) + }) + + tests := []struct { + name string + perProto bool + proto string + protoMajor int + protoMinor int + expectedLabelValue string + }{ + { + name: "HTTP/1.1 with per_proto=true", + perProto: true, + proto: "HTTP/1.1", + protoMajor: 1, + protoMinor: 1, + expectedLabelValue: "http/1.1", + }, + { + name: "HTTP/2 with per_proto=true", + perProto: true, + proto: "HTTP/2.0", + protoMajor: 2, + protoMinor: 0, + expectedLabelValue: "http/2", + }, + { + name: "HTTP/3 with per_proto=true", + perProto: true, + proto: "HTTP/3.0", + protoMajor: 3, + protoMinor: 0, + expectedLabelValue: "http/3", + }, + { + name: "HTTP/1.1 with per_proto=false", + perProto: false, + proto: "HTTP/1.1", + protoMajor: 1, + protoMinor: 1, + expectedLabelValue: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + metrics := &Metrics{ + PerProto: tt.perProto, + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + } + + ih := newMetricsInstrumentedHandler(ctx, "test_handler", mh, metrics) + + r := httptest.NewRequest("GET", "/", nil) + r.Proto = tt.proto + r.ProtoMajor = tt.protoMajor + r.ProtoMinor = tt.protoMinor + w := httptest.NewRecorder() + + if err := ih.ServeHTTP(w, r, handler); err != nil { + t.Errorf("Unexpected error: %v", err) + } + + labels := prometheus.Labels{"server": "test_handler", "handler": "test_handler"} + if tt.perProto { + labels["proto"] = tt.expectedLabelValue + } + if actual := testutil.ToFloat64(metrics.httpMetrics.requestCount.With(labels)); actual == 0 { + t.Logf("Request count metric recorded without proto label") + } + }) + } +} + type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {