diff --git a/core/validatorapi/metrics.go b/core/validatorapi/metrics.go index 68da4c886..c043b5fc5 100644 --- a/core/validatorapi/metrics.go +++ b/core/validatorapi/metrics.go @@ -64,9 +64,41 @@ func observeAPILatency(endpoint string) func() { func observeProxyAPILatency(path string) func() { t0 := time.Now() - path = strings.Trim(strings.ReplaceAll(path, "/", "_"), "_") + label := proxyPathLabel(path) return func() { - proxyAPILatency.WithLabelValues(path).Observe(time.Since(t0).Seconds()) + proxyAPILatency.WithLabelValues(label).Observe(time.Since(t0).Seconds()) } } + +// proxyPathLabel converts a request path into a bounded metric label by replacing dynamic +// path segments with placeholders. Without this, paths like /eth/v2/beacon/blocks/0x +// produce a unique label value per block root, which grows metric cardinality (and memory) +// without bound. +func proxyPathLabel(path string) string { + segments := strings.Split(strings.Trim(path, "/"), "/") + for i, segment := range segments { + switch { + case strings.HasPrefix(segment, "0x"): + segments[i] = "{hex}" // Block/state roots, validator pubkeys. + case isNumeric(segment): + segments[i] = "{n}" // Slots, epochs, validator indices. + case i > 0 && segments[i-1] == "peers": + segments[i] = "{peer_id}" // libp2p peer IDs are base58/base32, not hex or numeric. + default: + } + } + + return strings.Join(segments, "_") +} + +// isNumeric returns true if s is non-empty and contains only ASCII digits. +func isNumeric(s string) bool { + if s == "" { + return false + } + + return !strings.ContainsFunc(s, func(r rune) bool { + return r < '0' || r > '9' + }) +} diff --git a/core/validatorapi/metrics_internal_test.go b/core/validatorapi/metrics_internal_test.go new file mode 100644 index 000000000..46abdd64c --- /dev/null +++ b/core/validatorapi/metrics_internal_test.go @@ -0,0 +1,76 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package validatorapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProxyPathLabel(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "static path", + path: "/eth/v1/beacon/genesis", + want: "eth_v1_beacon_genesis", + }, + { + name: "block by root collapses hex", + path: "/eth/v2/beacon/blocks/0x0342020caa311b9f104cd1b223872b7d416d868d2e5add744e7af8265ba435ff", + want: "eth_v2_beacon_blocks_{hex}", + }, + { + name: "named block id kept", + path: "/eth/v2/beacon/blocks/head", + want: "eth_v2_beacon_blocks_head", + }, + { + name: "numeric slot collapsed", + path: "/eth/v1/beacon/blocks/123456/root", + want: "eth_v1_beacon_blocks_{n}_root", + }, + { + name: "pubkey collapses hex", + path: "/eth/v1/beacon/states/head/validators/0xa1b2c3", + want: "eth_v1_beacon_states_head_validators_{hex}", + }, + { + name: "validator index collapsed", + path: "/eth/v1/validator/duties/attester/42", + want: "eth_v1_validator_duties_attester_{n}", + }, + { + name: "peer id collapsed", + path: "/eth/v1/node/peers/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N", + want: "eth_v1_node_peers_{peer_id}", + }, + { + name: "peers list (no id) kept", + path: "/eth/v1/node/peers", + want: "eth_v1_node_peers", + }, + { + name: "empty path", + path: "/", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, proxyPathLabel(tt.path)) + }) + } +} + +func TestProxyPathLabelBoundedCardinality(t *testing.T) { + // Distinct block roots must collapse to a single label value. + a := proxyPathLabel("/eth/v2/beacon/blocks/0x0342020caa311b9f104cd1b223872b7d416d868d2e5add744e7af8265ba435ff") + b := proxyPathLabel("/eth/v2/beacon/blocks/0x04639c0c1fff050014a818280fcd12dc8880077583e83fee738afd74ade618c0") + require.Equal(t, a, b) +}