Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/chains/legacyevm/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -380,6 +384,8 @@ func (c *chain) Start(ctx context.Context) error {
}
}

c.emitNodeConfigInfo(ctx, c.nodeConfigMeter)

return nil
})
}
Expand Down
64 changes: 64 additions & 0 deletions pkg/chains/legacyevm/node_config_metrics.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
157 changes: 157 additions & 0 deletions pkg/chains/legacyevm/node_config_metrics_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading