Skip to content
Merged
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
65 changes: 43 additions & 22 deletions pkg/chains/legacyevm/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO will still be part of the current ticket?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its a chicken and egg problem. Once this lands, I can land a version of this smartcontractkit/chainlink#23726, and then undo the TODO :)

Up to you how many tickets you wanna have for the work.

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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
})
}
Expand Down
57 changes: 57 additions & 0 deletions pkg/chains/legacyevm/chain_config_metrics.go
Original file line number Diff line number Diff line change
@@ -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 }
144 changes: 144 additions & 0 deletions pkg/chains/legacyevm/chain_config_metrics_test.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions pkg/cmd/chainlink-evm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading