From 0f851108d1fa9a60bd3572b1180c27308559b10e Mon Sep 17 00:00:00 2001 From: Lee Jaeyong Date: Sun, 24 Aug 2025 11:44:32 +0900 Subject: [PATCH] Add custom labels support for metrics --- caddyconfig/httpcaddyfile/options.go | 14 +++- caddyconfig/httpcaddyfile/options_test.go | 60 ++++++++++++++- .../custom_labels_metrics.caddyfiletest | 47 ++++++++++++ modules/caddyhttp/metrics.go | 53 +++++++++++++ modules/caddyhttp/metrics_test.go | 76 +++++++++++++++++++ 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 caddytest/integration/caddyfile_adapt/custom_labels_metrics.caddyfiletest diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go index 336c6999f92..9be164208e8 100644 --- a/caddyconfig/httpcaddyfile/options.go +++ b/caddyconfig/httpcaddyfile/options.go @@ -472,8 +472,20 @@ func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) { switch d.Val() { case "per_host": metrics.PerHost = true + case "labels": + if metrics.Labels == nil { + metrics.Labels = make(map[string]string) + } + for nesting := d.Nesting(); d.NextBlock(nesting); { + key := d.Val() + if !d.NextArg() { + return nil, d.ArgErr() + } + value := d.Val() + metrics.Labels[key] = value + } default: - return nil, d.Errf("unrecognized servers option '%s'", d.Val()) + return nil, d.Errf("unrecognized metrics option '%s'", d.Val()) } } return metrics, nil diff --git a/caddyconfig/httpcaddyfile/options_test.go b/caddyconfig/httpcaddyfile/options_test.go index bc9e8813404..0844ffb6f9d 100644 --- a/caddyconfig/httpcaddyfile/options_test.go +++ b/caddyconfig/httpcaddyfile/options_test.go @@ -58,7 +58,65 @@ func TestGlobalLogOptionSyntax(t *testing.T) { } if string(out) != tc.output { - t.Errorf("Test %d error output mismatch Expected: %s, got %s", i, tc.output, out) + t.Errorf("Test %d error output mismatch Expected: %s, got %s", i, tc.output, string(out)) + } + } +} + +func TestGlobalMetricsOptionSyntax(t *testing.T) { + for i, tc := range []struct { + input string + expectError bool + }{ + { + input: `{ + metrics { + per_host + } + }`, + expectError: false, + }, + { + input: `{ + metrics { + labels { + proto "{http.request.proto}" + method "{http.request.method}" + } + } + }`, + expectError: false, + }, + { + input: `{ + metrics { + per_host + labels { + proto "{http.request.proto}" + host "{http.request.host}" + } + } + }`, + expectError: false, + }, + { + input: `{ + metrics { + unknown_option + } + }`, + expectError: true, + }, + } { + adapter := caddyfile.Adapter{ + ServerType: ServerType{}, + } + + out, _, err := adapter.Adapt([]byte(tc.input), nil) + + if err != nil != tc.expectError { + t.Errorf("Test %d error expectation failed Expected: %v, got %v", i, tc.expectError, err) + continue } } } diff --git a/caddytest/integration/caddyfile_adapt/custom_labels_metrics.caddyfiletest b/caddytest/integration/caddyfile_adapt/custom_labels_metrics.caddyfiletest new file mode 100644 index 00000000000..9164a789090 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/custom_labels_metrics.caddyfiletest @@ -0,0 +1,47 @@ +{ + metrics { + labels { + proto "{http.request.proto}" + method "{http.request.method}" + client_ip "{http.request.remote}" + host "{http.request.host}" + } + } +} + +:8080 { + respond "Hello World" 200 +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":8080" + ], + "routes": [ + { + "handle": [ + { + "body": "Hello World", + "handler": "static_response", + "status_code": 200 + } + ] + } + ] + } + }, + "metrics": { + "labels": { + "client_ip": "{http.request.remote}", + "host": "{http.request.host}", + "method": "{http.request.method}", + "proto": "{http.request.proto}" + } + } + } + } +} \ No newline at end of file diff --git a/modules/caddyhttp/metrics.go b/modules/caddyhttp/metrics.go index 9bb97e0b47b..c909276512d 100644 --- a/modules/caddyhttp/metrics.go +++ b/modules/caddyhttp/metrics.go @@ -23,6 +23,11 @@ type Metrics struct { // managed by Caddy. PerHost bool `json:"per_host,omitempty"` + // Labels allows users to define custom labels for metrics. + // The value can use placeholders like {http.request.scheme}, {http.request.proto}, {http.request.remote}, etc. + // These labels will be added to all HTTP metrics. + Labels map[string]string `json:"labels,omitempty"` + init sync.Once httpMetrics *httpMetrics `json:"-"` } @@ -44,6 +49,12 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { if metrics.PerHost { basicLabels = append(basicLabels, "host") } + if metrics.Labels != nil { + for key := range metrics.Labels { + basicLabels = append(basicLabels, key) + } + } + metrics.httpMetrics.requestInFlight = promauto.With(registry).NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Subsystem: sub, @@ -71,6 +82,12 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { if metrics.PerHost { httpLabels = append(httpLabels, "host") } + if metrics.Labels != nil { + for key := range metrics.Labels { + httpLabels = append(httpLabels, key) + } + } + metrics.httpMetrics.requestDuration = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{ Namespace: ns, Subsystem: sub, @@ -111,6 +128,36 @@ func serverNameFromContext(ctx context.Context) string { return srv.name } +// processCustomLabels processes custom labels by replacing placeholders with actual values. +func (h *metricsInstrumentedHandler) processCustomLabels(r *http.Request) prometheus.Labels { + labels := make(prometheus.Labels) + + if h.metrics.Labels == nil { + return labels + } + + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + if repl == nil { + repl = caddy.NewReplacer() + } + + for key, value := range h.metrics.Labels { + if strings.Contains(value, "{") && strings.Contains(value, "}") { + replaced := repl.ReplaceAll(value, "") + + if replaced == "" || replaced == value { + replaced = "unknown" + } + + labels[key] = replaced + } else { + labels[key] = value + } + } + + return labels +} + type metricsInstrumentedHandler struct { handler string mh MiddlewareHandler @@ -138,6 +185,12 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re statusLabels["host"] = strings.ToLower(r.Host) } + customLabels := h.processCustomLabels(r) + for key, value := range customLabels { + labels[key] = value + statusLabels[key] = value + } + inFlight := h.metrics.httpMetrics.requestInFlight.With(labels) inFlight.Inc() defer inFlight.Dec() diff --git a/modules/caddyhttp/metrics_test.go b/modules/caddyhttp/metrics_test.go index 4e1aa8b3037..f6e19fc2db3 100644 --- a/modules/caddyhttp/metrics_test.go +++ b/modules/caddyhttp/metrics_test.go @@ -379,6 +379,82 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { } } +func TestMetricsInstrumentedHandlerCustomLabels(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + metrics := &Metrics{ + Labels: map[string]string{ + "proto": "{http.request.proto}", + "client_ip": "IP: {http.request.remote}", + "host": "Host is {http.request.host}", + "version": "v1.0.0", + }, + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + } + + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.Write([]byte("hello world!")) + return nil + }) + + mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + return h.ServeHTTP(w, r) + }) + + ih := newMetricsInstrumentedHandler(ctx, "custom_labels", mh, metrics) + + r := httptest.NewRequest("GET", "/", nil) + r.Host = "example.com" + r.RemoteAddr = "192.168.1.1:12345" + + repl := caddy.NewReplacer() + reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl) + r = r.WithContext(reqCtx) + + w := httptest.NewRecorder() + + addHTTPVarsToReplacer(repl, r, w) + + if err := ih.ServeHTTP(w, r, h); err != nil { + t.Errorf("Received unexpected error: %v", err) + } + + expected := ` + # HELP caddy_http_request_size_bytes Total size of the request. Includes body + # TYPE caddy_http_request_size_bytes histogram + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="256"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="1024"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="4096"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="16384"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="65536"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="262144"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="1.048576e+06"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="4.194304e+06"} 1 + caddy_http_request_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="+Inf"} 1 + caddy_http_request_size_bytes_sum{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0"} 23 + caddy_http_request_size_bytes_count{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0"} 1 + # HELP caddy_http_response_size_bytes Size of the returned response. + # TYPE caddy_http_response_size_bytes histogram + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="256"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="1024"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="4096"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="16384"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="65536"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="262144"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="1.048576e+06"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="4.194304e+06"} 1 + caddy_http_response_size_bytes_bucket{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0",le="+Inf"} 1 + caddy_http_response_size_bytes_sum{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0"} 12 + caddy_http_response_size_bytes_count{client_ip="IP: 192.168.1.1:12345",code="200",handler="custom_labels",host="Host is example.com",method="GET",proto="HTTP/1.1",server="UNKNOWN",version="v1.0.0"} 1 + ` + if err := testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expected), + "caddy_http_request_size_bytes", + "caddy_http_response_size_bytes", + ); err != nil { + t.Errorf("received unexpected error: %s", err) + } +} + type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error {