diff --git a/go.mod b/go.mod index dca703bbd3..077468e64d 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..3c2023b92b 100644 --- a/pkg/chains/legacyevm/chain.go +++ b/pkg/chains/legacyevm/chain.go @@ -9,10 +9,12 @@ 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" + "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" @@ -104,17 +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 + 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 @@ -148,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 @@ -329,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, @@ -380,6 +398,9 @@ func (c *chain) Start(ctx context.Context) error { } } + 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 new file mode 100644 index 0000000000..4ea33d4f3d --- /dev/null +++ b/pkg/chains/legacyevm/chain_config_metrics.go @@ -0,0 +1,57 @@ +package legacyevm + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// 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" + +// chainConfigMetrics holds the instruments for reporting a chain's whitelisted +// configuration state. +type chainConfigMetrics struct { + configInfo metric.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 nil, fmt.Errorf("failed to create %s gauge: %w", evmChainConfigInfoMetricName, err) + } + + return &chainConfigMetrics{configInfo: configInfo}, nil +} + +// 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)...)) +} + +// 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 new file mode 100644 index 0000000000..d14346612e --- /dev/null +++ b/pkg/chains/legacyevm/chain_config_metrics_test.go @@ -0,0 +1,144 @@ +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 TestChainConfigAttributes_exactWhitelist(t *testing.T) { + t.Parallel() + + attrs := chainConfigAttributes("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 TestIsTrue_nilIsFalse(t *testing.T) { + t.Parallel() + + assert.False(t, isTrue(nil)) + v := true + assert.True(t, isTrue(&v)) +} + +func TestChainConfigMetrics_recordConfigInfo(t *testing.T) { + t.Parallel() + + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + + metrics, err := newChainConfigMetrics(meter) + require.NoError(t, err) + + 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": "1", + "transaction_v2_enabled": "true", + "dual_broadcast": "false", + }, attrsToStrings(dp.Attributes)) +} + +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, + } + 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) + // 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 +// 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 collectChainConfigInfo(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, "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) + 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 +} 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 {