diff --git a/cmd/monitoring/main.go b/cmd/monitoring/main.go index d713de5ad..cd87ae5f7 100644 --- a/cmd/monitoring/main.go +++ b/cmd/monitoring/main.go @@ -76,18 +76,27 @@ func main() { chainReader, logger.With(log, "component", "source-node-balances"), ) + slotHeightSourceFactory := monitoring.NewSlotHeightSourceFactory( + chainReader, + logger.With(log, "component", "souce-slot-height"), + ) monitor.NetworkSourceFactories = append(monitor.NetworkSourceFactories, nodeBalancesSourceFactory, + slotHeightSourceFactory, ) + // exporter names + promExporter := "solana-prom-exporter" + promMetrics := "solana-metrics" + // per-feed exporters feedBalancesExporterFactory := exporter.NewFeedBalancesFactory( - logger.With(log, "component", "solana-prom-exporter"), - metrics.NewFeedBalances(logger.With(log, "component", "solana-metrics")), + logger.With(log, "component", promExporter), + metrics.NewFeedBalances(logger.With(log, "component", promMetrics)), ) reportObservationsFactory := exporter.NewReportObservationsFactory( logger.With(log, "component", "solana-prome-exporter"), - metrics.NewReportObservations(logger.With(log, "component", "solana-metrics")), + metrics.NewReportObservations(logger.With(log, "component", promMetrics)), ) monitor.ExporterFactories = append(monitor.ExporterFactories, feedBalancesExporterFactory, @@ -96,11 +105,16 @@ func main() { // network exporters nodeBalancesExporterFactory := exporter.NewNodeBalancesFactory( - logger.With(log, "component", "solana-prom-exporter"), + logger.With(log, "component", promExporter), metrics.NewNodeBalances, ) + slotHeightExporterFactory := exporter.NewSlotHeightFactory( + logger.With(log, "component", promExporter), + metrics.NewSlotHeight(logger.With(log, "component", promMetrics)), + ) monitor.NetworkExporterFactories = append(monitor.NetworkExporterFactories, nodeBalancesExporterFactory, + slotHeightExporterFactory, ) monitor.Run() diff --git a/pkg/monitoring/chain_reader.go b/pkg/monitoring/chain_reader.go index 760f4f88c..11f19e384 100644 --- a/pkg/monitoring/chain_reader.go +++ b/pkg/monitoring/chain_reader.go @@ -17,6 +17,7 @@ type ChainReader interface { GetBalance(ctx context.Context, account solana.PublicKey, commitment rpc.CommitmentType) (out *rpc.GetBalanceResult, err error) GetSignaturesForAddressWithOpts(ctx context.Context, account solana.PublicKey, opts *rpc.GetSignaturesForAddressOpts) (out []*rpc.TransactionSignature, err error) GetTransaction(ctx context.Context, txSig solana.Signature, opts *rpc.GetTransactionOpts) (out *rpc.GetTransactionResult, err error) + GetSlot(ctx context.Context) (slot uint64, err error) } func NewChainReader(client *rpc.Client) ChainReader { @@ -50,3 +51,7 @@ func (c *chainReader) GetSignaturesForAddressWithOpts(ctx context.Context, accou func (c *chainReader) GetTransaction(ctx context.Context, txSig solana.Signature, opts *rpc.GetTransactionOpts) (out *rpc.GetTransactionResult, err error) { return c.client.GetTransaction(ctx, txSig, opts) } + +func (c *chainReader) GetSlot(ctx context.Context) (uint64, error) { + return c.client.GetSlot(ctx, rpc.CommitmentProcessed) // get latest height +} diff --git a/pkg/monitoring/exporter/slotheight.go b/pkg/monitoring/exporter/slotheight.go new file mode 100644 index 000000000..6982e3c18 --- /dev/null +++ b/pkg/monitoring/exporter/slotheight.go @@ -0,0 +1,50 @@ +package exporter + +import ( + "context" + + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/metrics" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func NewSlotHeightFactory( + _ commonMonitoring.Logger, + metrics metrics.SlotHeight, +) commonMonitoring.ExporterFactory { + return &slotHeightFactory{ + metrics, + } +} + +type slotHeightFactory struct { + metrics metrics.SlotHeight +} + +func (p *slotHeightFactory) NewExporter( + params commonMonitoring.ExporterParams, +) (commonMonitoring.Exporter, error) { + return &slotHeight{ + params.ChainConfig.GetNetworkName(), + params.ChainConfig.GetRPCEndpoint(), + p.metrics, + }, nil +} + +type slotHeight struct { + chain, url string + metrics metrics.SlotHeight +} + +func (p *slotHeight) Export(ctx context.Context, data interface{}) { + slot, ok := data.(types.SlotHeight) + if !ok { + return // skip if input could not be parsed + } + + p.metrics.Set(slot, p.chain, p.url) +} + +func (p *slotHeight) Cleanup(_ context.Context) { + p.metrics.Cleanup() +} diff --git a/pkg/monitoring/exporter/slotheight_test.go b/pkg/monitoring/exporter/slotheight_test.go new file mode 100644 index 000000000..072e27b49 --- /dev/null +++ b/pkg/monitoring/exporter/slotheight_test.go @@ -0,0 +1,36 @@ +package exporter + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "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 TestSlotHeight(t *testing.T) { + ctx := utils.Context(t) + m := mocks.NewSlotHeight(t) + m.On("Set", mock.Anything, mock.Anything, mock.Anything).Once() + m.On("Cleanup").Once() + + factory := NewSlotHeightFactory(logger.Test(t), m) + + chainConfig := testutils.GenerateChainConfig() + exporter, err := factory.NewExporter(commonMonitoring.ExporterParams{ChainConfig: chainConfig}) + require.NoError(t, err) + + // happy path + exporter.Export(ctx, types.SlotHeight(10)) + exporter.Cleanup(ctx) + + // test passing uint64 instead of SlotHeight - should not call mock + // SlotHeight alias of uint64 + exporter.Export(ctx, uint64(10)) +} diff --git a/pkg/monitoring/metrics/metrics.go b/pkg/monitoring/metrics/metrics.go index 2768de97a..32d2d44f8 100644 --- a/pkg/monitoring/metrics/metrics.go +++ b/pkg/monitoring/metrics/metrics.go @@ -64,6 +64,14 @@ func init() { }, feedLabels, ) + + // init gauge for slot height + gauges[types.SlotHeightMetric] = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: types.SlotHeightMetric, + }, + []string{"chain", "url"}, + ) } type FeedInput struct { diff --git a/pkg/monitoring/metrics/mocks/SlotHeight.go b/pkg/monitoring/metrics/mocks/SlotHeight.go new file mode 100644 index 000000000..97f008ed1 --- /dev/null +++ b/pkg/monitoring/metrics/mocks/SlotHeight.go @@ -0,0 +1,38 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + types "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" + mock "github.com/stretchr/testify/mock" +) + +// SlotHeight is an autogenerated mock type for the SlotHeight type +type SlotHeight struct { + mock.Mock +} + +// Cleanup provides a mock function with given fields: +func (_m *SlotHeight) Cleanup() { + _m.Called() +} + +// Set provides a mock function with given fields: slot, chain, url +func (_m *SlotHeight) Set(slot types.SlotHeight, chain string, url string) { + _m.Called(slot, chain, url) +} + +type mockConstructorTestingTNewSlotHeight interface { + mock.TestingT + Cleanup(func()) +} + +// NewSlotHeight creates a new instance of SlotHeight. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSlotHeight(t mockConstructorTestingTNewSlotHeight) *SlotHeight { + mock := &SlotHeight{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/monitoring/metrics/slotheight.go b/pkg/monitoring/metrics/slotheight.go new file mode 100644 index 000000000..2c4c5caf5 --- /dev/null +++ b/pkg/monitoring/metrics/slotheight.go @@ -0,0 +1,37 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +//go:generate mockery --name SlotHeight --output ./mocks/ + +type SlotHeight interface { + Set(slot types.SlotHeight, chain, url string) + Cleanup() +} + +var _ SlotHeight = (*slotHeight)(nil) + +type slotHeight struct { + simpleGauge + labels prometheus.Labels +} + +func NewSlotHeight(log commonMonitoring.Logger) *slotHeight { + return &slotHeight{ + simpleGauge: newSimpleGauge(log, types.SlotHeightMetric), + } +} + +func (sh *slotHeight) Set(slot types.SlotHeight, chain, url string) { + sh.labels = prometheus.Labels{"chain": chain, "url": url} + sh.set(float64(slot), sh.labels) +} + +func (sh *slotHeight) Cleanup() { + sh.delete(sh.labels) +} diff --git a/pkg/monitoring/metrics/slotheight_test.go b/pkg/monitoring/metrics/slotheight_test.go new file mode 100644 index 000000000..d795b219b --- /dev/null +++ b/pkg/monitoring/metrics/slotheight_test.go @@ -0,0 +1,38 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "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 TestSlotHeight(t *testing.T) { + lgr := logger.Test(t) + m := NewSlotHeight(lgr) + + // fetching gauges + g, ok := gauges[types.SlotHeightMetric] + require.True(t, ok) + + v := 100 + + // set gauge + assert.NotPanics(t, func() { m.Set(types.SlotHeight(v), t.Name(), t.Name()+"_url") }) + promBal := testutil.ToFloat64(g.With(prometheus.Labels{ + "chain": t.Name(), + "url": t.Name() + "_url", + })) + assert.Equal(t, float64(v), promBal) + + // cleanup gauges + assert.Equal(t, 1, testutil.CollectAndCount(g)) + assert.NotPanics(t, func() { m.Cleanup() }) + assert.Equal(t, 0, testutil.CollectAndCount(g)) +} diff --git a/pkg/monitoring/mocks/ChainReader.go b/pkg/monitoring/mocks/ChainReader.go index cec4d9d5f..609625c10 100644 --- a/pkg/monitoring/mocks/ChainReader.go +++ b/pkg/monitoring/mocks/ChainReader.go @@ -102,6 +102,30 @@ func (_m *ChainReader) GetSignaturesForAddressWithOpts(ctx context.Context, acco return r0, r1 } +// GetSlot provides a mock function with given fields: ctx +func (_m *ChainReader) GetSlot(ctx context.Context) (uint64, error) { + ret := _m.Called(ctx) + + var r0 uint64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) uint64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(uint64) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetState provides a mock function with given fields: ctx, account, commitment func (_m *ChainReader) GetState(ctx context.Context, account solana.PublicKey, commitment rpc.CommitmentType) (pkgsolana.State, uint64, error) { ret := _m.Called(ctx, account, commitment) diff --git a/pkg/monitoring/source_slotheight.go b/pkg/monitoring/source_slotheight.go new file mode 100644 index 000000000..9f0ae94b3 --- /dev/null +++ b/pkg/monitoring/source_slotheight.go @@ -0,0 +1,44 @@ +package monitoring + +import ( + "context" + + commonMonitoring "github.com/smartcontractkit/chainlink-common/pkg/monitoring" + + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func NewSlotHeightSourceFactory( + client ChainReader, + log commonMonitoring.Logger, +) commonMonitoring.NetworkSourceFactory { + return &slotHeightSourceFactory{ + client, + log, + } +} + +type slotHeightSourceFactory struct { + client ChainReader + log commonMonitoring.Logger +} + +func (s *slotHeightSourceFactory) NewSource( + _ commonMonitoring.ChainConfig, + _ []commonMonitoring.NodeConfig, +) (commonMonitoring.Source, error) { + return &slotHeightSource{s.client}, nil +} + +func (s *slotHeightSourceFactory) GetType() string { + return types.SlotHeightType +} + +type slotHeightSource struct { + client ChainReader +} + +func (t *slotHeightSource) Fetch(ctx context.Context) (interface{}, error) { + slot, err := t.client.GetSlot(ctx) + return types.SlotHeight(slot), err +} diff --git a/pkg/monitoring/source_slotheight_test.go b/pkg/monitoring/source_slotheight_test.go new file mode 100644 index 000000000..45c704748 --- /dev/null +++ b/pkg/monitoring/source_slotheight_test.go @@ -0,0 +1,34 @@ +package monitoring + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/mocks" + "github.com/smartcontractkit/chainlink-solana/pkg/monitoring/types" +) + +func TestSlotHeightSource(t *testing.T) { + cr := mocks.NewChainReader(t) + lgr := logger.Test(t) + ctx := utils.Context(t) + + factory := NewSlotHeightSourceFactory(cr, lgr) + assert.Equal(t, types.SlotHeightType, factory.GetType()) + + // generate source + source, err := factory.NewSource(nil, nil) + cr.On("GetSlot", mock.Anything, mock.Anything, mock.Anything).Return(uint64(1), nil).Once() + + // happy path + out, err := source.Fetch(ctx) + require.NoError(t, err) + slot, ok := out.(types.SlotHeight) + require.True(t, ok) + assert.Equal(t, types.SlotHeight(1), slot) +} diff --git a/pkg/monitoring/types/types.go b/pkg/monitoring/types/types.go new file mode 100644 index 000000000..665a11b0f --- /dev/null +++ b/pkg/monitoring/types/types.go @@ -0,0 +1,11 @@ +package types + +// types.go contains simple types, more complex types should have a separate file +const ( + SlotHeightType = "slot_height" + SlotHeightMetric = "sol_" + SlotHeightType +) + +// SlotHeight type wraps the uint64 type returned by the RPC call +// this helps to delineate types when sending to the exporter +type SlotHeight uint64