From b7a2b867ca5d991932af90a5e87b5da7d21d0481 Mon Sep 17 00:00:00 2001 From: mostlyconsistent Date: Fri, 4 Sep 2026 15:29:43 -0400 Subject: [PATCH 1/3] Chain Config Metrics --- go.mod | 2 +- pkg/chains/legacyevm/chain.go | 6 + pkg/chains/legacyevm/node_config_metrics.go | 64 +++++++ .../legacyevm/node_config_metrics_test.go | 157 ++++++++++++++++++ 4 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 pkg/chains/legacyevm/node_config_metrics.go create mode 100644 pkg/chains/legacyevm/node_config_metrics_test.go diff --git a/go.mod b/go.mod index 4a38410d67..3f7d3d7ced 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/umbracle/ethgo v0.1.3 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.52.0 @@ -203,7 +204,6 @@ require ( go.opentelemetry.io/otel/log v0.19.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/goleak v1.3.0 // indirect diff --git a/pkg/chains/legacyevm/chain.go b/pkg/chains/legacyevm/chain.go index 2ab73a4d87..bab40c6e36 100644 --- a/pkg/chains/legacyevm/chain.go +++ b/pkg/chains/legacyevm/chain.go @@ -9,6 +9,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" gotoml "github.com/pelletier/go-toml/v2" + "go.opentelemetry.io/otel/metric" "go.uber.org/multierr" chainselectors "github.com/smartcontractkit/chain-selectors" @@ -115,6 +116,9 @@ type chain struct { logPoller logpoller.LogPoller balanceMonitor monitor.BalanceMonitor gasEstimator gas.EvmFeeEstimator + // nodeConfigMeter records the node_config_info metric. A nil meter falls + // back to the global beholder meter. + nodeConfigMeter metric.Meter // Extends with support for the Tron TXM tronTxm *trontxm.TronTxm @@ -380,6 +384,8 @@ func (c *chain) Start(ctx context.Context) error { } } + c.emitNodeConfigInfo(ctx, c.nodeConfigMeter) + return nil }) } diff --git a/pkg/chains/legacyevm/node_config_metrics.go b/pkg/chains/legacyevm/node_config_metrics.go new file mode 100644 index 0000000000..0bb4c16121 --- /dev/null +++ b/pkg/chains/legacyevm/node_config_metrics.go @@ -0,0 +1,64 @@ +package legacyevm + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" +) + +// nodeConfigInfoMetricName is an info-style gauge: its value is always 1 and all +// state is carried in the labels. +const nodeConfigInfoMetricName = "node_config_info" + +// nodeConfigAttributes returns the exhaustive, whitelisted label set for the +// node_config_info metric for one EVM chain. +// +// The whitelist is the security boundary of this metric: it must stay limited to +// low-cardinality, non-sensitive values. In particular it must never carry an +// RPC or OFA URL (TransactionManagerV2.CustomURL/CustomURLs), because those can +// embed credentials. See docs on OEV-1648 / INCIDENT-2541. +func nodeConfigAttributes(chainID string, txV2Enabled, dualBroadcast bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("chain_id", chainID), + attribute.Bool("transaction_v2_enabled", txV2Enabled), + attribute.Bool("dual_broadcast", dualBroadcast), + } +} + +// derefBool reads an optional config bool, treating an unset value as false. +func derefBool(b *bool) bool { return b != nil && *b } + +// recordNodeConfigInfo records the node_config_info gauge for a single chain. +// A synchronous gauge re-exports its last recorded value on every reader +// interval, so a single record at startup keeps the series alive. +func recordNodeConfigInfo(ctx context.Context, meter metric.Meter, chainID string, txV2Enabled, dualBroadcast bool) error { + gauge, err := meter.Int64Gauge( + nodeConfigInfoMetricName, + metric.WithDescription("SVR-relevant EVM node config; value is always 1, state is in the labels"), + metric.WithUnit("{info}"), + ) + if err != nil { + return fmt.Errorf("failed to create %s gauge: %w", nodeConfigInfoMetricName, err) + } + + gauge.Record(ctx, 1, metric.WithAttributes(nodeConfigAttributes(chainID, txV2Enabled, dualBroadcast)...)) + return nil +} + +// emitNodeConfigInfo reports this chain's SVR-relevant config state. A nil meter +// falls back to the global beholder meter. Failures are non-fatal: a metrics +// hiccup must never block chain startup. +func (c *chain) emitNodeConfigInfo(ctx context.Context, meter metric.Meter) { + if meter == nil { + meter = beholder.GetMeter() + } + + txV2 := c.cfg.EVM().Transactions().TransactionManagerV2() + if err := recordNodeConfigInfo(ctx, meter, c.id.String(), txV2.Enabled(), derefBool(txV2.DualBroadcast())); err != nil { + c.logger.Warnw("Failed to record node config info metric", "chainID", c.id, "err", err) + } +} diff --git a/pkg/chains/legacyevm/node_config_metrics_test.go b/pkg/chains/legacyevm/node_config_metrics_test.go new file mode 100644 index 0000000000..769b89026f --- /dev/null +++ b/pkg/chains/legacyevm/node_config_metrics_test.go @@ -0,0 +1,157 @@ +package legacyevm + +import ( + stdbig "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + "github.com/smartcontractkit/chainlink-evm/pkg/client" + "github.com/smartcontractkit/chainlink-evm/pkg/config" + "github.com/smartcontractkit/chainlink-evm/pkg/config/configtest" + "github.com/smartcontractkit/chainlink-evm/pkg/config/toml" + "github.com/smartcontractkit/chainlink-evm/pkg/heads" + "github.com/smartcontractkit/chainlink-evm/pkg/log" + "github.com/smartcontractkit/chainlink-evm/pkg/logpoller" + "github.com/smartcontractkit/chainlink-evm/pkg/txmgr" +) + +func TestNodeConfigAttributes_exactWhitelist(t *testing.T) { + t.Parallel() + + attrs := nodeConfigAttributes("1", true, false) + + got := map[attribute.Key]attribute.Value{} + for _, kv := range attrs { + got[kv.Key] = kv.Value + } + + // Exactly the three whitelisted keys - nothing else can leak. + require.Len(t, attrs, 3) + assert.Equal(t, "1", got["chain_id"].AsString()) + assert.True(t, got["transaction_v2_enabled"].AsBool()) + assert.False(t, got["dual_broadcast"].AsBool()) + assert.NotContains(t, got, attribute.Key("custom_url")) + assert.NotContains(t, got, attribute.Key("custom_urls")) +} + +func TestDerefBool_nilIsFalse(t *testing.T) { + t.Parallel() + + assert.False(t, derefBool(nil)) + v := true + assert.True(t, derefBool(&v)) +} + +func TestRecordNodeConfigInfo_recordsGaugeWithWhitelistOnly(t *testing.T) { + t.Parallel() + + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + + require.NoError(t, recordNodeConfigInfo(t.Context(), meter, "1", true, false)) + + dp := collectNodeConfigInfo(t, reader) + assert.Equal(t, int64(1), dp.Value) + assert.Equal(t, map[string]string{ + "chain_id": "1", + "transaction_v2_enabled": "true", + "dual_broadcast": "false", + }, attrsToStrings(dp.Attributes)) +} + +func TestChain_emitNodeConfigInfo(t *testing.T) { + t.Parallel() + + c := &chain{ + id: stdbig.NewInt(42161), + cfg: txV2ChainConfig(t), + logger: logger.Test(t), + } + + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + + c.emitNodeConfigInfo(t.Context(), meter) + + dp := collectNodeConfigInfo(t, reader) + assert.Equal(t, int64(1), dp.Value) + assert.Equal(t, map[string]string{ + "chain_id": "42161", + "transaction_v2_enabled": "true", + "dual_broadcast": "false", + }, attrsToStrings(dp.Attributes)) +} + +func TestChain_Start_emitsNodeConfigInfo(t *testing.T) { + t.Parallel() + + reader := sdkmetric.NewManualReader() + lggr := logger.Test(t) + cfg := txV2ChainConfig(t) + c := &chain{ + id: stdbig.NewInt(42161), + cfg: cfg, + logger: lggr, + client: client.NewNullClient(stdbig.NewInt(42161), lggr), + txm: &txmgr.NullTxManager{ErrMsg: "no txm"}, + headBroadcaster: heads.NewBroadcaster(lggr), + headTracker: heads.NullTracker, + logBroadcaster: &log.NullBroadcaster{ErrMsg: "no log broadcaster"}, + logPoller: logpoller.LogPollerDisabled, + nodeConfigMeter: sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), + } + + require.NoError(t, c.Start(t.Context())) + t.Cleanup(func() { assert.NoError(t, c.Close()) }) + + dp := collectNodeConfigInfo(t, reader) + assert.Equal(t, int64(1), dp.Value) + assert.Equal(t, "42161", attrsToStrings(dp.Attributes)["chain_id"]) +} + +// txV2ChainConfig is a chain config with TransactionManagerV2 enabled, dual +// broadcast off, and an OFA URL that embeds a secret. +func txV2ChainConfig(t *testing.T) *config.ChainScoped { + return configtest.NewChainScopedConfig(t, func(c *toml.EVMConfig) { + c.ChainID = sqlutil.NewI(42161) + enabled, dualBroadcast := true, false + c.Transactions.TransactionManagerV2 = toml.TransactionManagerV2Config{ + Enabled: &enabled, + DualBroadcast: &dualBroadcast, + CustomURLs: []*commonconfig.URL{commonconfig.MustParseURL("https://user:hunter2@ofa.example.com")}, + } + }) +} + +func collectNodeConfigInfo(t *testing.T, reader sdkmetric.Reader) metricdata.DataPoint[int64] { + t.Helper() + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &rm)) + + require.Len(t, rm.ScopeMetrics, 1) + require.Len(t, rm.ScopeMetrics[0].Metrics, 1) + m := rm.ScopeMetrics[0].Metrics[0] + assert.Equal(t, "node_config_info", m.Name) + + g, ok := m.Data.(metricdata.Gauge[int64]) + require.True(t, ok, "expected an int64 gauge, got %T", m.Data) + require.Len(t, g.DataPoints, 1) + return g.DataPoints[0] +} + +func attrsToStrings(set attribute.Set) map[string]string { + out := map[string]string{} + for _, kv := range set.ToSlice() { + out[string(kv.Key)] = kv.Value.String() + } + return out +} From 812bafc2075f66a76a080d8abbe5526347edb5bb Mon Sep 17 00:00:00 2001 From: mostlyconsistent Date: Tue, 8 Sep 2026 09:48:34 -0400 Subject: [PATCH 2/3] rename metric --- pkg/chains/legacyevm/chain.go | 6 +- pkg/chains/legacyevm/chain_config_metrics.go | 64 +++++++++++++++++++ ...s_test.go => chain_config_metrics_test.go} | 44 ++++++------- pkg/chains/legacyevm/node_config_metrics.go | 64 ------------------- 4 files changed, 89 insertions(+), 89 deletions(-) create mode 100644 pkg/chains/legacyevm/chain_config_metrics.go rename pkg/chains/legacyevm/{node_config_metrics_test.go => chain_config_metrics_test.go} (76%) delete mode 100644 pkg/chains/legacyevm/node_config_metrics.go diff --git a/pkg/chains/legacyevm/chain.go b/pkg/chains/legacyevm/chain.go index bab40c6e36..2290a65089 100644 --- a/pkg/chains/legacyevm/chain.go +++ b/pkg/chains/legacyevm/chain.go @@ -116,9 +116,9 @@ type chain struct { logPoller logpoller.LogPoller balanceMonitor monitor.BalanceMonitor gasEstimator gas.EvmFeeEstimator - // nodeConfigMeter records the node_config_info metric. A nil meter falls + // chainConfigMeter records the evm_chain_config_info metric. A nil meter falls // back to the global beholder meter. - nodeConfigMeter metric.Meter + chainConfigMeter metric.Meter // Extends with support for the Tron TXM tronTxm *trontxm.TronTxm @@ -384,7 +384,7 @@ func (c *chain) Start(ctx context.Context) error { } } - c.emitNodeConfigInfo(ctx, c.nodeConfigMeter) + c.emitChainConfigInfo(ctx, c.chainConfigMeter) return nil }) diff --git a/pkg/chains/legacyevm/chain_config_metrics.go b/pkg/chains/legacyevm/chain_config_metrics.go new file mode 100644 index 0000000000..0ef83703a9 --- /dev/null +++ b/pkg/chains/legacyevm/chain_config_metrics.go @@ -0,0 +1,64 @@ +package legacyevm + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" +) + +// evmChainConfigInfoMetricName is an info-style gauge: its value is always 1 and all +// state is carried in the labels. +const evmChainConfigInfoMetricName = "evm_chain_config_info" + +// chainConfigAttributes returns the exhaustive, whitelisted label set for the +// evm_chain_config_info metric for one EVM chain. +// +// The whitelist is the security boundary of this metric: it must stay limited to +// low-cardinality, non-sensitive values. In particular it must never carry an +// RPC or OFA URL (TransactionManagerV2.CustomURL/CustomURLs), because those can +// embed credentials. +func chainConfigAttributes(chainID string, txV2Enabled, dualBroadcast bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("chain_id", chainID), + attribute.Bool("transaction_v2_enabled", txV2Enabled), + attribute.Bool("dual_broadcast", dualBroadcast), + } +} + +// derefBool reads an optional config bool, treating an unset value as false. +func derefBool(b *bool) bool { return b != nil && *b } + +// recordChainConfigInfo records the evm_chain_config_info gauge for a single chain. +// A synchronous gauge re-exports its last recorded value on every reader +// interval, so a single record at startup keeps the series alive. +func recordChainConfigInfo(ctx context.Context, meter metric.Meter, chainID string, txV2Enabled, dualBroadcast bool) error { + gauge, err := meter.Int64Gauge( + evmChainConfigInfoMetricName, + metric.WithDescription("Whitelisted EVM chain configuration; value is always 1, state is in the labels"), + metric.WithUnit("{info}"), + ) + if err != nil { + return fmt.Errorf("failed to create %s gauge: %w", evmChainConfigInfoMetricName, err) + } + + gauge.Record(ctx, 1, metric.WithAttributes(chainConfigAttributes(chainID, txV2Enabled, dualBroadcast)...)) + return nil +} + +// emitChainConfigInfo reports this chain's whitelisted config state. A nil meter +// falls back to the global beholder meter. Failures are non-fatal: a metrics +// hiccup must never block chain startup. +func (c *chain) emitChainConfigInfo(ctx context.Context, meter metric.Meter) { + if meter == nil { + meter = beholder.GetMeter() + } + + txV2 := c.cfg.EVM().Transactions().TransactionManagerV2() + if err := recordChainConfigInfo(ctx, meter, c.id.String(), txV2.Enabled(), derefBool(txV2.DualBroadcast())); err != nil { + c.logger.Warnw("Failed to record chain config info metric", "chainID", c.id, "err", err) + } +} diff --git a/pkg/chains/legacyevm/node_config_metrics_test.go b/pkg/chains/legacyevm/chain_config_metrics_test.go similarity index 76% rename from pkg/chains/legacyevm/node_config_metrics_test.go rename to pkg/chains/legacyevm/chain_config_metrics_test.go index 769b89026f..244a053379 100644 --- a/pkg/chains/legacyevm/node_config_metrics_test.go +++ b/pkg/chains/legacyevm/chain_config_metrics_test.go @@ -23,10 +23,10 @@ import ( "github.com/smartcontractkit/chainlink-evm/pkg/txmgr" ) -func TestNodeConfigAttributes_exactWhitelist(t *testing.T) { +func TestChainConfigAttributes_exactWhitelist(t *testing.T) { t.Parallel() - attrs := nodeConfigAttributes("1", true, false) + attrs := chainConfigAttributes("1", true, false) got := map[attribute.Key]attribute.Value{} for _, kv := range attrs { @@ -50,15 +50,15 @@ func TestDerefBool_nilIsFalse(t *testing.T) { assert.True(t, derefBool(&v)) } -func TestRecordNodeConfigInfo_recordsGaugeWithWhitelistOnly(t *testing.T) { +func TestRecordChainConfigInfo_recordsGaugeWithWhitelistOnly(t *testing.T) { t.Parallel() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - require.NoError(t, recordNodeConfigInfo(t.Context(), meter, "1", true, false)) + require.NoError(t, recordChainConfigInfo(t.Context(), meter, "1", true, false)) - dp := collectNodeConfigInfo(t, reader) + dp := collectChainConfigInfo(t, reader) assert.Equal(t, int64(1), dp.Value) assert.Equal(t, map[string]string{ "chain_id": "1", @@ -67,7 +67,7 @@ func TestRecordNodeConfigInfo_recordsGaugeWithWhitelistOnly(t *testing.T) { }, attrsToStrings(dp.Attributes)) } -func TestChain_emitNodeConfigInfo(t *testing.T) { +func TestChain_emitChainConfigInfo(t *testing.T) { t.Parallel() c := &chain{ @@ -79,9 +79,9 @@ func TestChain_emitNodeConfigInfo(t *testing.T) { reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - c.emitNodeConfigInfo(t.Context(), meter) + c.emitChainConfigInfo(t.Context(), meter) - dp := collectNodeConfigInfo(t, reader) + dp := collectChainConfigInfo(t, reader) assert.Equal(t, int64(1), dp.Value) assert.Equal(t, map[string]string{ "chain_id": "42161", @@ -90,29 +90,29 @@ func TestChain_emitNodeConfigInfo(t *testing.T) { }, attrsToStrings(dp.Attributes)) } -func TestChain_Start_emitsNodeConfigInfo(t *testing.T) { +func TestChain_Start_emitsChainConfigInfo(t *testing.T) { t.Parallel() reader := sdkmetric.NewManualReader() lggr := logger.Test(t) cfg := txV2ChainConfig(t) c := &chain{ - id: stdbig.NewInt(42161), - cfg: cfg, - logger: lggr, - client: client.NewNullClient(stdbig.NewInt(42161), lggr), - txm: &txmgr.NullTxManager{ErrMsg: "no txm"}, - headBroadcaster: heads.NewBroadcaster(lggr), - headTracker: heads.NullTracker, - logBroadcaster: &log.NullBroadcaster{ErrMsg: "no log broadcaster"}, - logPoller: logpoller.LogPollerDisabled, - nodeConfigMeter: sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), + id: stdbig.NewInt(42161), + cfg: cfg, + logger: lggr, + client: client.NewNullClient(stdbig.NewInt(42161), lggr), + txm: &txmgr.NullTxManager{ErrMsg: "no txm"}, + headBroadcaster: heads.NewBroadcaster(lggr), + headTracker: heads.NullTracker, + logBroadcaster: &log.NullBroadcaster{ErrMsg: "no log broadcaster"}, + logPoller: logpoller.LogPollerDisabled, + chainConfigMeter: sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), } require.NoError(t, c.Start(t.Context())) t.Cleanup(func() { assert.NoError(t, c.Close()) }) - dp := collectNodeConfigInfo(t, reader) + dp := collectChainConfigInfo(t, reader) assert.Equal(t, int64(1), dp.Value) assert.Equal(t, "42161", attrsToStrings(dp.Attributes)["chain_id"]) } @@ -131,7 +131,7 @@ func txV2ChainConfig(t *testing.T) *config.ChainScoped { }) } -func collectNodeConfigInfo(t *testing.T, reader sdkmetric.Reader) metricdata.DataPoint[int64] { +func collectChainConfigInfo(t *testing.T, reader sdkmetric.Reader) metricdata.DataPoint[int64] { t.Helper() var rm metricdata.ResourceMetrics @@ -140,7 +140,7 @@ func collectNodeConfigInfo(t *testing.T, reader sdkmetric.Reader) metricdata.Dat require.Len(t, rm.ScopeMetrics, 1) require.Len(t, rm.ScopeMetrics[0].Metrics, 1) m := rm.ScopeMetrics[0].Metrics[0] - assert.Equal(t, "node_config_info", m.Name) + assert.Equal(t, "evm_chain_config_info", m.Name) g, ok := m.Data.(metricdata.Gauge[int64]) require.True(t, ok, "expected an int64 gauge, got %T", m.Data) diff --git a/pkg/chains/legacyevm/node_config_metrics.go b/pkg/chains/legacyevm/node_config_metrics.go deleted file mode 100644 index 0bb4c16121..0000000000 --- a/pkg/chains/legacyevm/node_config_metrics.go +++ /dev/null @@ -1,64 +0,0 @@ -package legacyevm - -import ( - "context" - "fmt" - - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - - "github.com/smartcontractkit/chainlink-common/pkg/beholder" -) - -// nodeConfigInfoMetricName is an info-style gauge: its value is always 1 and all -// state is carried in the labels. -const nodeConfigInfoMetricName = "node_config_info" - -// nodeConfigAttributes returns the exhaustive, whitelisted label set for the -// node_config_info metric for one EVM chain. -// -// The whitelist is the security boundary of this metric: it must stay limited to -// low-cardinality, non-sensitive values. In particular it must never carry an -// RPC or OFA URL (TransactionManagerV2.CustomURL/CustomURLs), because those can -// embed credentials. See docs on OEV-1648 / INCIDENT-2541. -func nodeConfigAttributes(chainID string, txV2Enabled, dualBroadcast bool) []attribute.KeyValue { - return []attribute.KeyValue{ - attribute.String("chain_id", chainID), - attribute.Bool("transaction_v2_enabled", txV2Enabled), - attribute.Bool("dual_broadcast", dualBroadcast), - } -} - -// derefBool reads an optional config bool, treating an unset value as false. -func derefBool(b *bool) bool { return b != nil && *b } - -// recordNodeConfigInfo records the node_config_info gauge for a single chain. -// A synchronous gauge re-exports its last recorded value on every reader -// interval, so a single record at startup keeps the series alive. -func recordNodeConfigInfo(ctx context.Context, meter metric.Meter, chainID string, txV2Enabled, dualBroadcast bool) error { - gauge, err := meter.Int64Gauge( - nodeConfigInfoMetricName, - metric.WithDescription("SVR-relevant EVM node config; value is always 1, state is in the labels"), - metric.WithUnit("{info}"), - ) - if err != nil { - return fmt.Errorf("failed to create %s gauge: %w", nodeConfigInfoMetricName, err) - } - - gauge.Record(ctx, 1, metric.WithAttributes(nodeConfigAttributes(chainID, txV2Enabled, dualBroadcast)...)) - return nil -} - -// emitNodeConfigInfo reports this chain's SVR-relevant config state. A nil meter -// falls back to the global beholder meter. Failures are non-fatal: a metrics -// hiccup must never block chain startup. -func (c *chain) emitNodeConfigInfo(ctx context.Context, meter metric.Meter) { - if meter == nil { - meter = beholder.GetMeter() - } - - txV2 := c.cfg.EVM().Transactions().TransactionManagerV2() - if err := recordNodeConfigInfo(ctx, meter, c.id.String(), txV2.Enabled(), derefBool(txV2.DualBroadcast())); err != nil { - c.logger.Warnw("Failed to record node config info metric", "chainID", c.id, "err", err) - } -} From 3330595e703be07903ea4d6418cad2c373f3f2fa Mon Sep 17 00:00:00 2001 From: mostlyconsistent Date: Tue, 8 Sep 2026 11:11:02 -0400 Subject: [PATCH 3/3] address comments --- pkg/chains/legacyevm/chain.go | 67 ++++++++++++------- pkg/chains/legacyevm/chain_config_metrics.go | 65 ++++++++---------- .../legacyevm/chain_config_metrics_test.go | 65 +++++++----------- pkg/cmd/chainlink-evm/main.go | 1 + 4 files changed, 97 insertions(+), 101 deletions(-) diff --git a/pkg/chains/legacyevm/chain.go b/pkg/chains/legacyevm/chain.go index 2290a65089..3c2023b92b 100644 --- a/pkg/chains/legacyevm/chain.go +++ b/pkg/chains/legacyevm/chain.go @@ -14,6 +14,7 @@ import ( chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-common/pkg/beholder" common "github.com/smartcontractkit/chainlink-common/pkg/chains" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" @@ -105,20 +106,18 @@ type chain struct { services.StateMachine types.UnimplementedChainService - id *big.Int - cfg *config.ChainScoped - client client.Client - txm txmgr.TxManager - logger logger.Logger - headBroadcaster heads.Broadcaster - headTracker heads.Tracker - logBroadcaster log.Broadcaster - logPoller logpoller.LogPoller - balanceMonitor monitor.BalanceMonitor - gasEstimator gas.EvmFeeEstimator - // chainConfigMeter records the evm_chain_config_info metric. A nil meter falls - // back to the global beholder meter. - chainConfigMeter metric.Meter + id *big.Int + cfg *config.ChainScoped + client client.Client + txm txmgr.TxManager + logger logger.Logger + headBroadcaster heads.Broadcaster + headTracker heads.Tracker + logBroadcaster log.Broadcaster + logPoller logpoller.LogPoller + balanceMonitor monitor.BalanceMonitor + gasEstimator gas.EvmFeeEstimator + chainConfigMetrics *chainConfigMetrics // Extends with support for the Tron TXM tronTxm *trontxm.TronTxm @@ -152,6 +151,11 @@ type ChainOpts struct { DS sqlutil.DataSource + // Meter is used to report chain metrics. Defaults to the global beholder + // meter when unset. + // TODO: make this required once chainlink core passes it explicitly. + Meter metric.Meter + // TODO BCF-2513 remove test code from the API // Gen-functions are useful for dependency injection by tests GenChainStore func(ks core.Keystore, i *big.Int) keys.ChainStore @@ -333,18 +337,28 @@ func newChain(cfg *config.ChainScoped, nodes []*toml.Node, opts ChainRelayOpts, } } + meter := opts.Meter + if meter == nil { + meter = beholder.GetMeter() + } + chainConfigMetrics, err := newChainConfigMetrics(meter) + if err != nil { + return nil, fmt.Errorf("failed to create chain config metrics: %w", err) + } + return &chain{ - id: chainID, - cfg: cfg, - client: cl, - txm: txm, - logger: l, - headBroadcaster: headBroadcaster, - headTracker: headTracker, - logBroadcaster: logBroadcaster, - logPoller: logPoller, - balanceMonitor: balanceMonitor, - gasEstimator: gasEstimator, + id: chainID, + cfg: cfg, + client: cl, + txm: txm, + logger: l, + headBroadcaster: headBroadcaster, + headTracker: headTracker, + logBroadcaster: logBroadcaster, + logPoller: logPoller, + balanceMonitor: balanceMonitor, + gasEstimator: gasEstimator, + chainConfigMetrics: chainConfigMetrics, // Extends with support for the Tron TXM tronTxm: tronTxm, @@ -384,7 +398,8 @@ func (c *chain) Start(ctx context.Context) error { } } - c.emitChainConfigInfo(ctx, c.chainConfigMeter) + txV2 := c.cfg.EVM().Transactions().TransactionManagerV2() + c.chainConfigMetrics.recordConfigInfo(ctx, c.id.String(), txV2.Enabled(), isTrue(txV2.DualBroadcast())) return nil }) diff --git a/pkg/chains/legacyevm/chain_config_metrics.go b/pkg/chains/legacyevm/chain_config_metrics.go index 0ef83703a9..4ea33d4f3d 100644 --- a/pkg/chains/legacyevm/chain_config_metrics.go +++ b/pkg/chains/legacyevm/chain_config_metrics.go @@ -6,59 +6,52 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" - - "github.com/smartcontractkit/chainlink-common/pkg/beholder" ) // evmChainConfigInfoMetricName is an info-style gauge: its value is always 1 and all // state is carried in the labels. const evmChainConfigInfoMetricName = "evm_chain_config_info" -// chainConfigAttributes returns the exhaustive, whitelisted label set for the -// evm_chain_config_info metric for one EVM chain. -// -// The whitelist is the security boundary of this metric: it must stay limited to -// low-cardinality, non-sensitive values. In particular it must never carry an -// RPC or OFA URL (TransactionManagerV2.CustomURL/CustomURLs), because those can -// embed credentials. -func chainConfigAttributes(chainID string, txV2Enabled, dualBroadcast bool) []attribute.KeyValue { - return []attribute.KeyValue{ - attribute.String("chain_id", chainID), - attribute.Bool("transaction_v2_enabled", txV2Enabled), - attribute.Bool("dual_broadcast", dualBroadcast), - } +// chainConfigMetrics holds the instruments for reporting a chain's whitelisted +// configuration state. +type chainConfigMetrics struct { + configInfo metric.Int64Gauge } -// derefBool reads an optional config bool, treating an unset value as false. -func derefBool(b *bool) bool { return b != nil && *b } - -// recordChainConfigInfo records the evm_chain_config_info gauge for a single chain. -// A synchronous gauge re-exports its last recorded value on every reader -// interval, so a single record at startup keeps the series alive. -func recordChainConfigInfo(ctx context.Context, meter metric.Meter, chainID string, txV2Enabled, dualBroadcast bool) error { - gauge, err := meter.Int64Gauge( +func newChainConfigMetrics(meter metric.Meter) (*chainConfigMetrics, error) { + configInfo, err := meter.Int64Gauge( evmChainConfigInfoMetricName, metric.WithDescription("Whitelisted EVM chain configuration; value is always 1, state is in the labels"), metric.WithUnit("{info}"), ) if err != nil { - return fmt.Errorf("failed to create %s gauge: %w", evmChainConfigInfoMetricName, err) + return nil, fmt.Errorf("failed to create %s gauge: %w", evmChainConfigInfoMetricName, err) } - gauge.Record(ctx, 1, metric.WithAttributes(chainConfigAttributes(chainID, txV2Enabled, dualBroadcast)...)) - return nil + return &chainConfigMetrics{configInfo: configInfo}, nil } -// emitChainConfigInfo reports this chain's whitelisted config state. A nil meter -// falls back to the global beholder meter. Failures are non-fatal: a metrics -// hiccup must never block chain startup. -func (c *chain) emitChainConfigInfo(ctx context.Context, meter metric.Meter) { - if meter == nil { - meter = beholder.GetMeter() - } +// recordConfigInfo records the config gauge for a single chain. A synchronous +// gauge re-exports its last recorded value on every reader interval, so a single +// record at startup keeps the series alive. +func (m *chainConfigMetrics) recordConfigInfo(ctx context.Context, chainID string, txV2Enabled, dualBroadcast bool) { + m.configInfo.Record(ctx, 1, metric.WithAttributes(chainConfigAttributes(chainID, txV2Enabled, dualBroadcast)...)) +} - txV2 := c.cfg.EVM().Transactions().TransactionManagerV2() - if err := recordChainConfigInfo(ctx, meter, c.id.String(), txV2.Enabled(), derefBool(txV2.DualBroadcast())); err != nil { - c.logger.Warnw("Failed to record chain config info metric", "chainID", c.id, "err", err) +// chainConfigAttributes returns the exhaustive, whitelisted label set for the +// evm_chain_config_info metric for one EVM chain. +// +// The whitelist is the security boundary of this metric: it must stay limited to +// low-cardinality, non-sensitive values. In particular it must never carry an +// RPC or OFA URL (TransactionManagerV2.CustomURL/CustomURLs), because those can +// embed credentials. See docs on OEV-1648 / INCIDENT-2541. +func chainConfigAttributes(chainID string, txV2Enabled, dualBroadcast bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("chain_id", chainID), + attribute.Bool("transaction_v2_enabled", txV2Enabled), + attribute.Bool("dual_broadcast", dualBroadcast), } } + +// isTrue reads an optional config bool, treating an unset value as false. +func isTrue(b *bool) bool { return b != nil && *b } diff --git a/pkg/chains/legacyevm/chain_config_metrics_test.go b/pkg/chains/legacyevm/chain_config_metrics_test.go index 244a053379..d14346612e 100644 --- a/pkg/chains/legacyevm/chain_config_metrics_test.go +++ b/pkg/chains/legacyevm/chain_config_metrics_test.go @@ -42,49 +42,29 @@ func TestChainConfigAttributes_exactWhitelist(t *testing.T) { assert.NotContains(t, got, attribute.Key("custom_urls")) } -func TestDerefBool_nilIsFalse(t *testing.T) { +func TestIsTrue_nilIsFalse(t *testing.T) { t.Parallel() - assert.False(t, derefBool(nil)) + assert.False(t, isTrue(nil)) v := true - assert.True(t, derefBool(&v)) + assert.True(t, isTrue(&v)) } -func TestRecordChainConfigInfo_recordsGaugeWithWhitelistOnly(t *testing.T) { +func TestChainConfigMetrics_recordConfigInfo(t *testing.T) { t.Parallel() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - require.NoError(t, recordChainConfigInfo(t.Context(), meter, "1", true, false)) + metrics, err := newChainConfigMetrics(meter) + require.NoError(t, err) - dp := collectChainConfigInfo(t, reader) - assert.Equal(t, int64(1), dp.Value) - assert.Equal(t, map[string]string{ - "chain_id": "1", - "transaction_v2_enabled": "true", - "dual_broadcast": "false", - }, attrsToStrings(dp.Attributes)) -} - -func TestChain_emitChainConfigInfo(t *testing.T) { - t.Parallel() - - c := &chain{ - id: stdbig.NewInt(42161), - cfg: txV2ChainConfig(t), - logger: logger.Test(t), - } - - reader := sdkmetric.NewManualReader() - meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - - c.emitChainConfigInfo(t.Context(), meter) + metrics.recordConfigInfo(t.Context(), "1", true, false) dp := collectChainConfigInfo(t, reader) assert.Equal(t, int64(1), dp.Value) assert.Equal(t, map[string]string{ - "chain_id": "42161", + "chain_id": "1", "transaction_v2_enabled": "true", "dual_broadcast": "false", }, attrsToStrings(dp.Attributes)) @@ -97,24 +77,31 @@ func TestChain_Start_emitsChainConfigInfo(t *testing.T) { lggr := logger.Test(t) cfg := txV2ChainConfig(t) c := &chain{ - id: stdbig.NewInt(42161), - cfg: cfg, - logger: lggr, - client: client.NewNullClient(stdbig.NewInt(42161), lggr), - txm: &txmgr.NullTxManager{ErrMsg: "no txm"}, - headBroadcaster: heads.NewBroadcaster(lggr), - headTracker: heads.NullTracker, - logBroadcaster: &log.NullBroadcaster{ErrMsg: "no log broadcaster"}, - logPoller: logpoller.LogPollerDisabled, - chainConfigMeter: sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), + id: stdbig.NewInt(42161), + cfg: cfg, + logger: lggr, + client: client.NewNullClient(stdbig.NewInt(42161), lggr), + txm: &txmgr.NullTxManager{ErrMsg: "no txm"}, + headBroadcaster: heads.NewBroadcaster(lggr), + headTracker: heads.NullTracker, + logBroadcaster: &log.NullBroadcaster{ErrMsg: "no log broadcaster"}, + logPoller: logpoller.LogPollerDisabled, } + metrics, err := newChainConfigMetrics(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")) + require.NoError(t, err) + c.chainConfigMetrics = metrics require.NoError(t, c.Start(t.Context())) t.Cleanup(func() { assert.NoError(t, c.Close()) }) dp := collectChainConfigInfo(t, reader) assert.Equal(t, int64(1), dp.Value) - assert.Equal(t, "42161", attrsToStrings(dp.Attributes)["chain_id"]) + // The configured OFA URL carries a secret and must never reach the metric. + assert.Equal(t, map[string]string{ + "chain_id": "42161", + "transaction_v2_enabled": "true", + "dual_broadcast": "false", + }, attrsToStrings(dp.Attributes)) } // txV2ChainConfig is a chain config with TransactionManagerV2 enabled, dual diff --git a/pkg/cmd/chainlink-evm/main.go b/pkg/cmd/chainlink-evm/main.go index a271889f41..866abb3649 100644 --- a/pkg/cmd/chainlink-evm/main.go +++ b/pkg/cmd/chainlink-evm/main.go @@ -138,6 +138,7 @@ func (c *pluginRelayer) NewRelayer(ctx context.Context, configTOML string, keyst }, MailMon: mailMon, DS: c.DataSource, + Meter: beholder.GetMeter(), }, }, nil) if err != nil {