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 +}