diff --git a/README.md b/README.md index 12d83d3..6c638d9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The exporter is configured via the following command line arguments: | Option | Description | Default | |--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `-active-identity` | Validator identity public key used to determine if the node is considered active in the `solana_node_is_active` metric. | N/A | | `-balance-address` | Address to monitor SOL balances for, in addition to the identity and vote accounts of the provided nodekeys - can be set multiple times. | N/A | | `-comprehensive-slot-tracking` | Set this flag to track `solana_leader_slots_by_epoch` for all validators. | `false` | | `-http-timeout` | HTTP timeout to use, in seconds. | `60` | @@ -72,6 +73,7 @@ The table below describes all the metrics collected by the `solana-exporter`: | `solana_account_balance` | Solana account balances. | `address` | | `solana_node_version` | Node version of solana.* | `version` | | `solana_node_is_healthy` | Whether the node is healthy.* | N/A | +| `solana_node_is_active` | Whether the node is active and participating in consensus. | `identity` | | `solana_node_num_slots_behind` | The number of slots that the node is behind the latest cluster confirmed slot.* | N/A | | `solana_node_minimum_ledger_slot` | The lowest slot that the node has information about in its ledger.* | N/A | | `solana_node_first_available_block` | The slot of the lowest confirmed block that has not been purged from the node's ledger.* | N/A | diff --git a/cmd/solana-exporter/collector.go b/cmd/solana-exporter/collector.go index 7a6de72..8e08d5b 100644 --- a/cmd/solana-exporter/collector.go +++ b/cmd/solana-exporter/collector.go @@ -46,6 +46,7 @@ type SolanaCollector struct { NodeNumSlotsBehind *GaugeDesc NodeMinimumLedgerSlot *GaugeDesc NodeFirstAvailableBlock *GaugeDesc + NodeIsActive *GaugeDesc } func NewSolanaCollector(client *rpc.Client, config *ExporterConfig) *SolanaCollector { @@ -104,6 +105,11 @@ func NewSolanaCollector(client *rpc.Client, config *ExporterConfig) *SolanaColle "solana_node_first_available_block", "The slot of the lowest confirmed block that has not been purged from the node's ledger.", ), + NodeIsActive: NewGaugeDesc( + "solana_node_is_active", + fmt.Sprintf("Whether the node is active and participating in consensus (using %s pubkey)", IdentityLabel), + IdentityLabel, + ), } return collector } @@ -120,6 +126,7 @@ func (c *SolanaCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.NodeNumSlotsBehind.Desc ch <- c.NodeMinimumLedgerSlot.Desc ch <- c.NodeFirstAvailableBlock.Desc + ch <- c.NodeIsActive.Desc } func (c *SolanaCollector) collectVoteAccounts(ctx context.Context, ch chan<- prometheus.Metric) { @@ -177,6 +184,15 @@ func (c *SolanaCollector) collectIdentity(ctx context.Context, ch chan<- prometh return } + if c.config.ActiveIdentity != "" { + isActive := 0 + if c.config.ActiveIdentity == identity { + isActive = 1 + } + ch <- c.NodeIsActive.MustNewConstMetric(float64(isActive), identity) + c.logger.Info("NodeIsActive collected.") + } + ch <- c.NodeIdentity.MustNewConstMetric(1, identity) c.logger.Info("Identity collected.") } diff --git a/cmd/solana-exporter/collector_test.go b/cmd/solana-exporter/collector_test.go index 8b41e36..56c557a 100644 --- a/cmd/solana-exporter/collector_test.go +++ b/cmd/solana-exporter/collector_test.go @@ -4,16 +4,17 @@ import ( "bytes" "context" "fmt" - "github.com/asymmetric-research/solana-exporter/pkg/rpc" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/testutil" - "github.com/stretchr/testify/assert" "math" "math/rand" "slices" "strings" "testing" "time" + + "github.com/asymmetric-research/solana-exporter/pkg/rpc" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" ) type ( @@ -192,16 +193,17 @@ func newTestConfig(simulator *Simulator, fast bool) *ExporterConfig { pace = time.Duration(500) * time.Millisecond } config := ExporterConfig{ - time.Second * time.Duration(1), - simulator.Server.URL(), - ":8080", - simulator.Nodekeys, - simulator.Votekeys, - nil, - true, - true, - false, - pace, + HttpTimeout: time.Second * time.Duration(1), + RpcUrl: simulator.Server.URL(), + ListenAddress: ":8080", + NodeKeys: simulator.Nodekeys, + VoteKeys: simulator.Votekeys, + BalanceAddresses: nil, + ComprehensiveSlotTracking: true, + MonitorBlockSizes: true, + LightMode: false, + SlotPace: pace, + ActiveIdentity: simulator.Nodekeys[0], } return &config } @@ -240,6 +242,9 @@ func TestSolanaCollector(t *testing.T) { collector.NodeIdentity.makeCollectionTest( NewLV(1, "testIdentity"), ), + collector.NodeIsActive.makeCollectionTest( + NewLV(0, "testIdentity"), + ), collector.NodeIsHealthy.makeCollectionTest( NewLV(1), ), diff --git a/cmd/solana-exporter/config.go b/cmd/solana-exporter/config.go index ba9128b..ce98019 100644 --- a/cmd/solana-exporter/config.go +++ b/cmd/solana-exporter/config.go @@ -4,9 +4,10 @@ import ( "context" "flag" "fmt" + "time" + "github.com/asymmetric-research/solana-exporter/pkg/rpc" "github.com/asymmetric-research/solana-exporter/pkg/slog" - "time" ) type ( @@ -23,6 +24,7 @@ type ( MonitorBlockSizes bool LightMode bool SlotPace time.Duration + ActiveIdentity string } ) @@ -46,6 +48,7 @@ func NewExporterConfig( monitorBlockSizes bool, lightMode bool, slotPace time.Duration, + activeIdentity string, ) (*ExporterConfig, error) { logger := slog.Get() logger.Infow( @@ -58,6 +61,7 @@ func NewExporterConfig( "comprehensiveSlotTracking", comprehensiveSlotTracking, "monitorBlockSizes", monitorBlockSizes, "lightMode", lightMode, + "activeIdentity", activeIdentity, ) if lightMode { if comprehensiveSlotTracking { @@ -97,6 +101,7 @@ func NewExporterConfig( MonitorBlockSizes: monitorBlockSizes, LightMode: lightMode, SlotPace: slotPace, + ActiveIdentity: activeIdentity, } return &config, nil } @@ -112,6 +117,7 @@ func NewExporterConfigFromCLI(ctx context.Context) (*ExporterConfig, error) { monitorBlockSizes bool lightMode bool slotPace int + activeIdentity string ) flag.IntVar( &httpTimeout, @@ -171,6 +177,12 @@ func NewExporterConfigFromCLI(ctx context.Context) (*ExporterConfig, error) { 1, "This is the time between slot-watching metric collections, defaults to 1s.", ) + flag.StringVar( + &activeIdentity, + "active-identity", + "", + "Validator identity public key that determines if the node is considered active in the 'solana_node_is_active' metric.", + ) flag.Parse() config, err := NewExporterConfig( @@ -184,6 +196,7 @@ func NewExporterConfigFromCLI(ctx context.Context) (*ExporterConfig, error) { monitorBlockSizes, lightMode, time.Duration(slotPace)*time.Second, + activeIdentity, ) if err != nil { return nil, err diff --git a/cmd/solana-exporter/config_test.go b/cmd/solana-exporter/config_test.go index 520033a..42a22d8 100644 --- a/cmd/solana-exporter/config_test.go +++ b/cmd/solana-exporter/config_test.go @@ -2,9 +2,10 @@ package main import ( "context" - "github.com/stretchr/testify/assert" "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestNewExporterConfig(t *testing.T) { @@ -22,6 +23,7 @@ func TestNewExporterConfig(t *testing.T) { slotPace time.Duration wantErr bool expectedVoteKeys []string + activeIdentity string }{ { name: "valid configuration", @@ -36,6 +38,7 @@ func TestNewExporterConfig(t *testing.T) { slotPace: time.Second, wantErr: false, expectedVoteKeys: simulator.Votekeys, + activeIdentity: simulator.Nodekeys[0], }, { name: "light mode with incompatible options", @@ -50,6 +53,7 @@ func TestNewExporterConfig(t *testing.T) { slotPace: time.Second, wantErr: true, expectedVoteKeys: nil, + activeIdentity: simulator.Nodekeys[0], }, { name: "empty node keys", @@ -64,6 +68,7 @@ func TestNewExporterConfig(t *testing.T) { slotPace: time.Second, wantErr: false, expectedVoteKeys: []string{}, + activeIdentity: simulator.Nodekeys[0], }, } @@ -80,6 +85,7 @@ func TestNewExporterConfig(t *testing.T) { tt.monitorBlockSizes, tt.lightMode, tt.slotPace, + tt.activeIdentity, ) // Check error expectation diff --git a/cmd/solana-exporter/main.go b/cmd/solana-exporter/main.go index 3012b94..23f029e 100644 --- a/cmd/solana-exporter/main.go +++ b/cmd/solana-exporter/main.go @@ -2,11 +2,12 @@ package main import ( "context" + "net/http" + "github.com/asymmetric-research/solana-exporter/pkg/rpc" "github.com/asymmetric-research/solana-exporter/pkg/slog" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - "net/http" ) func main() { diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index b21b512..c3d1041 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -2,8 +2,9 @@ package rpc import ( "context" - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func newMethodTester(t *testing.T, method string, result any) (*MockServer, *Client) { @@ -244,3 +245,15 @@ func TestClient_GetVoteAccounts(t *testing.T) { voteAccounts, ) } + +func TestClient_GetIdentity(t *testing.T) { + _, client := newMethodTester(t, "getIdentity", map[string]string{ + "identity": "random2r1F4iWqVcb8M1DbAjQuFpebkQuW2DJtestkey", + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + identity, err := client.GetIdentity(ctx) + assert.NoError(t, err) + assert.Equal(t, "random2r1F4iWqVcb8M1DbAjQuFpebkQuW2DJtestkey", identity) +}