diff --git a/cmd/monitoring/main.go b/cmd/monitoring/main.go index 80d58abf8..3fd8c97cf 100644 --- a/cmd/monitoring/main.go +++ b/cmd/monitoring/main.go @@ -95,17 +95,22 @@ func main() { metrics.NewFeedBalances(logger.With(log, "component", promMetrics)), ) reportObservationsFactory := exporter.NewReportObservationsFactory( - logger.With(log, "component", "solana-prome-exporter"), + logger.With(log, "component", promExporter), metrics.NewReportObservations(logger.With(log, "component", promMetrics)), ) feesFactory := exporter.NewFeesFactory( - logger.With(log, "component", "solana-prome-exporter"), + logger.With(log, "component", promExporter), metrics.NewFees(logger.With(log, "component", promMetrics)), ) + nodeSuccessFactory := exporter.NewNodeSuccessFactory( + logger.With(log, "component", promExporter), + metrics.NewNodeSuccess(logger.With(log, "component", promMetrics)), + ) monitor.ExporterFactories = append(monitor.ExporterFactories, feedBalancesExporterFactory, reportObservationsFactory, feesFactory, + nodeSuccessFactory, ) // network exporters diff --git a/pkg/monitoring/exporter/nodesuccess.go b/pkg/monitoring/exporter/nodesuccess.go new file mode 100644 index 000000000..8c3b660b2 --- /dev/null +++ b/pkg/monitoring/exporter/nodesuccess.go @@ -0,0 +1,111 @@ +package exporter + +import ( + "context" + + "github.com/gagliardetto/solana-go" + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/config" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func NewNodeSuccessFactory( + log commonMonitoring.Logger, + metrics metrics.NodeSuccess, +) commonMonitoring.ExporterFactory { + return &nodeSuccessFactory{ + log, + metrics, + } +} + +type nodeSuccessFactory struct { + log commonMonitoring.Logger + metrics metrics.NodeSuccess +} + +func (p *nodeSuccessFactory) NewExporter( + params commonMonitoring.ExporterParams, +) (commonMonitoring.Exporter, error) { + nodes, err := config.MakeSolanaNodeConfigs(params.Nodes) + if err != nil { + return nil, err + } + + nodesMap := map[solana.PublicKey]string{} + for _, v := range nodes { + pubkey, err := v.PublicKey() + if err != nil { + return nil, err + } + nodesMap[pubkey] = v.GetName() + } + + return &nodeSuccess{ + metrics.FeedInput{ + AccountAddress: params.FeedConfig.GetContractAddress(), + FeedID: params.FeedConfig.GetContractAddress(), + ChainID: params.ChainConfig.GetChainID(), + ContractStatus: params.FeedConfig.GetContractStatus(), + ContractType: params.FeedConfig.GetContractType(), + FeedName: params.FeedConfig.GetName(), + FeedPath: params.FeedConfig.GetPath(), + NetworkID: params.ChainConfig.GetNetworkID(), + NetworkName: params.ChainConfig.GetNetworkName(), + }, + nodesMap, + p.log, + p.metrics, + }, nil +} + +type nodeSuccess struct { + feedLabel metrics.FeedInput // static for each feed + nodes map[solana.PublicKey]string + log commonMonitoring.Logger + metrics metrics.NodeSuccess +} + +func (p *nodeSuccess) Export(ctx context.Context, data interface{}) { + details, err := types.MakeTxDetails(data) + if err != nil { + return // skip if input could not be parsed + } + + // skip on no updates + if len(details) == 0 { + return + } + + // calculate count + count := map[solana.PublicKey]int{} + for _, d := range details { + count[d.Sender]++ + } + + for k, v := range count { + name, isOperator := p.nodes[k] + if !isOperator { + p.log.Debugw("Sender does not match known operator", "sender", k) + continue // skip if not known operator + } + + p.metrics.Add(v, metrics.NodeFeedInput{ + NodeAddress: k.String(), + NodeOperator: name, + FeedInput: p.feedLabel, + }) + } +} + +func (p *nodeSuccess) Cleanup(_ context.Context) { + for k, v := range p.nodes { + p.metrics.Cleanup(metrics.NodeFeedInput{ + NodeAddress: k.String(), + NodeOperator: v, + FeedInput: p.feedLabel, + }) + } +} diff --git a/pkg/monitoring/exporter/nodesuccess_test.go b/pkg/monitoring/exporter/nodesuccess_test.go new file mode 100644 index 000000000..3f6f8bfa0 --- /dev/null +++ b/pkg/monitoring/exporter/nodesuccess_test.go @@ -0,0 +1,55 @@ +package exporter + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/config" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics/mocks" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/testutils" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func TestNodeSuccess(t *testing.T) { + zeroAddress := solana.PublicKey{} + ctx := tests.Context(t) + lgr, logs := logger.TestObserved(t, zapcore.DebugLevel) + m := mocks.NewNodeSuccess(t) + m.On("Add", mock.Anything, mock.Anything).Once() + m.On("Cleanup", mock.Anything).Once() + + factory := NewNodeSuccessFactory(lgr, m) + + chainConfig := testutils.GenerateChainConfig() + feedConfig := testutils.GenerateFeedConfig() + exporter, err := factory.NewExporter(commonMonitoring.ExporterParams{ChainConfig: chainConfig, + FeedConfig: feedConfig, + Nodes: []commonMonitoring.NodeConfig{ + config.SolanaNodeConfig{ + NodeAddress: []string{zeroAddress.String()}}, + }}) + require.NoError(t, err) + + // happy path - only one call (only 1 address is recognized) + exporter.Export(ctx, []types.TxDetails{ + {Sender: zeroAddress}, + {Sender: solana.PublicKey{1}}, + }) + exporter.Cleanup(ctx) + assert.Equal(t, 1, logs.FilterMessageSnippet("Sender does not match known operator").Len()) + + // not txdetails type - no calls to mock + assert.NotPanics(t, func() { exporter.Export(ctx, 1) }) + + // zero txdetails - no calls to mock + exporter.Export(ctx, []types.TxDetails{}) +} diff --git a/pkg/monitoring/metrics/common.go b/pkg/monitoring/metrics/common.go index 0de63e97d..5ddf868a7 100644 --- a/pkg/monitoring/metrics/common.go +++ b/pkg/monitoring/metrics/common.go @@ -20,30 +20,30 @@ func newSimpleGauge(log commonMonitoring.Logger, name string) simpleGauge { return simpleGauge{log, name} } -func (sg simpleGauge) set(value float64, labels prometheus.Labels) { +func (sg simpleGauge) run( + f func(*prometheus.GaugeVec), +) { if gauges == nil { sg.log.Fatalw("gauges is nil") return } gauge, ok := gauges[sg.metricName] - if !ok { + if !ok || gauge == nil { sg.log.Errorw("gauge not found", "name", sg.metricName) return } - gauge.With(labels).Set(value) + f(gauge) +} + +func (sg simpleGauge) set(value float64, labels prometheus.Labels) { + sg.run(func(g *prometheus.GaugeVec) { g.With(labels).Set(value) }) } func (sg simpleGauge) delete(labels prometheus.Labels) { - if gauges == nil { - sg.log.Fatalw("gauges is nil") - return - } + sg.run(func(g *prometheus.GaugeVec) { g.Delete(labels) }) +} - gauge, ok := gauges[sg.metricName] - if !ok { - sg.log.Errorw("gauge not found", "name", sg.metricName) - return - } - gauge.Delete(labels) +func (sg simpleGauge) add(value float64, labels prometheus.Labels) { + sg.run(func(g *prometheus.GaugeVec) { g.With(labels).Add(value) }) } diff --git a/pkg/monitoring/metrics/metrics.go b/pkg/monitoring/metrics/metrics.go index e258854bd..b108736eb 100644 --- a/pkg/monitoring/metrics/metrics.go +++ b/pkg/monitoring/metrics/metrics.go @@ -23,6 +23,11 @@ var ( "network_name", } + nodeFeedLabels = append([]string{ + "node_address", + "node_operator", + }, feedLabels...) + nodeLabels = []string{ "account_address", "node_operator", @@ -67,6 +72,14 @@ func init() { ) } + // init gauge for node success per feed per node + gauges[types.NodeSuccessMetric] = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: types.NodeSuccessMetric, + }, + nodeFeedLabels, + ) + // init gauge for slot height gauges[types.SlotHeightMetric] = promauto.NewGaugeVec( prometheus.GaugeOpts{ @@ -93,3 +106,15 @@ func (i FeedInput) ToPromLabels() prometheus.Labels { "network_name": i.NetworkName, } } + +type NodeFeedInput struct { + NodeAddress, NodeOperator string + FeedInput +} + +func (i NodeFeedInput) ToPromLabels() prometheus.Labels { + l := i.FeedInput.ToPromLabels() + l["node_address"] = i.NodeAddress + l["node_operator"] = i.NodeOperator + return l +} diff --git a/pkg/monitoring/metrics/mocks/NodeSuccess.go b/pkg/monitoring/metrics/mocks/NodeSuccess.go new file mode 100644 index 000000000..10c336db1 --- /dev/null +++ b/pkg/monitoring/metrics/mocks/NodeSuccess.go @@ -0,0 +1,38 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + metrics "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics" + mock "github.com/stretchr/testify/mock" +) + +// NodeSuccess is an autogenerated mock type for the NodeSuccess type +type NodeSuccess struct { + mock.Mock +} + +// Add provides a mock function with given fields: count, i +func (_m *NodeSuccess) Add(count int, i metrics.NodeFeedInput) { + _m.Called(count, i) +} + +// Cleanup provides a mock function with given fields: i +func (_m *NodeSuccess) Cleanup(i metrics.NodeFeedInput) { + _m.Called(i) +} + +type mockConstructorTestingTNewNodeSuccess interface { + mock.TestingT + Cleanup(func()) +} + +// NewNodeSuccess creates a new instance of NodeSuccess. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewNodeSuccess(t mockConstructorTestingTNewNodeSuccess) *NodeSuccess { + mock := &NodeSuccess{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/monitoring/metrics/nodesuccess.go b/pkg/monitoring/metrics/nodesuccess.go new file mode 100644 index 000000000..73cc00d94 --- /dev/null +++ b/pkg/monitoring/metrics/nodesuccess.go @@ -0,0 +1,32 @@ +package metrics + +import ( + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +//go:generate mockery --name NodeSuccess --output ./mocks/ + +type NodeSuccess interface { + Add(count int, i NodeFeedInput) + Cleanup(i NodeFeedInput) +} + +var _ NodeSuccess = (*nodeSuccess)(nil) + +type nodeSuccess struct { + simpleGauge +} + +func NewNodeSuccess(log commonMonitoring.Logger) *nodeSuccess { + return &nodeSuccess{newSimpleGauge(log, types.NodeSuccessMetric)} +} + +func (ro *nodeSuccess) Add(count int, i NodeFeedInput) { + ro.add(float64(count), i.ToPromLabels()) +} + +func (ro *nodeSuccess) Cleanup(i NodeFeedInput) { + ro.delete(i.ToPromLabels()) +} diff --git a/pkg/monitoring/metrics/nodesuccess_test.go b/pkg/monitoring/metrics/nodesuccess_test.go new file mode 100644 index 000000000..44f238fdf --- /dev/null +++ b/pkg/monitoring/metrics/nodesuccess_test.go @@ -0,0 +1,36 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func TestNodeSuccess(t *testing.T) { + lgr := logger.Test(t) + m := NewNodeSuccess(lgr) + + // fetching gauges + g, ok := gauges[types.NodeSuccessMetric] + require.True(t, ok) + + v := 100 + inputs := NodeFeedInput{FeedInput: FeedInput{NetworkName: t.Name()}} + + // set gauge + assert.NotPanics(t, func() { m.Add(v, inputs) }) + assert.NotPanics(t, func() { m.Add(v, inputs) }) + promBal := testutil.ToFloat64(g.With(inputs.ToPromLabels())) + assert.Equal(t, float64(2*v), promBal) + + // cleanup gauges + assert.Equal(t, 1, testutil.CollectAndCount(g)) + assert.NotPanics(t, func() { m.Cleanup(inputs) }) + assert.Equal(t, 0, testutil.CollectAndCount(g)) +} diff --git a/pkg/monitoring/types/txdetails.go b/pkg/monitoring/types/txdetails.go index 7788aca28..f5149677a 100644 --- a/pkg/monitoring/types/txdetails.go +++ b/pkg/monitoring/types/txdetails.go @@ -19,7 +19,9 @@ var ( ReportObservationMetric = "report_observations" TxFeeMetric = "tx_fee" ComputeUnitPriceMetric = "tx_compute_unit_price" + NodeSuccessMetric = "node_success" // per node per feed + // these metrics are per feed TxDetailsMetrics = []string{ ReportObservationMetric, TxFeeMetric,