Skip to content

feat: add minimum required version metric. #85

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ The table below describes all the metrics collected by the `solana-exporter`:
| `solana_validator_fee_rewards` | Transaction fee rewards earned. | `nodekey`, `epoch` |
| `solana_validator_block_size` | Number of transactions per block. | `nodekey`, `transaction_type` |
| `solana_node_block_height` | The current block height of the node.* | N/A |
| `solana_foundation_min_required_version` | Minimum required Solana version for the [solana foundation delegation program](https://solana.org/delegation-program) | `version`, `cluster` |

***NOTE***: An `*` in the description indicates that the metric **is** tracked in `-light-mode`.

Expand All @@ -102,6 +103,7 @@ The table below describes the various metric labels:
| `votekey` | Validator vote account address. | e.g., `CertusDeBmqN8ZawdkxK5kFGMwBXdudvWHYwtNgNhvLu` |
| `address` | Solana account address. | e.g., `Certusm1sa411sMpV9FPqU5dXAYhmmhygvxJ23S6hJ24` |
| `version` | Solana node version. | e.g., `v1.18.23` |
| `cluster` | Solana cluster name. | `mainnet-beta`, `testnet`, `devnet` |
| `status` | Whether a slot was skipped or valid | `valid`, `skipped` |
| `epoch` | Solana epoch number. | e.g., `663` |
| `transaction_type` | General transaction type. | `vote`, `non_vote` |
67 changes: 53 additions & 14 deletions cmd/solana-exporter/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/asymmetric-research/solana-exporter/pkg/api"
"github.com/asymmetric-research/solana-exporter/pkg/rpc"
"github.com/asymmetric-research/solana-exporter/pkg/slog"
"github.com/prometheus/client_golang/prometheus"
Expand All @@ -17,6 +18,7 @@ const (
VotekeyLabel = "votekey"
VersionLabel = "version"
IdentityLabel = "identity"
ClusterLabel = "cluster"
AddressLabel = "address"
EpochLabel = "epoch"
TransactionTypeLabel = "transaction_type"
Expand All @@ -30,28 +32,31 @@ const (

type SolanaCollector struct {
rpcClient *rpc.Client
apiClient *api.Client
logger *zap.SugaredLogger

config *ExporterConfig

/// descriptors:
ValidatorActiveStake *GaugeDesc
ValidatorLastVote *GaugeDesc
ValidatorRootSlot *GaugeDesc
ValidatorDelinquent *GaugeDesc
AccountBalances *GaugeDesc
NodeVersion *GaugeDesc
NodeIdentity *GaugeDesc
NodeIsHealthy *GaugeDesc
NodeNumSlotsBehind *GaugeDesc
NodeMinimumLedgerSlot *GaugeDesc
NodeFirstAvailableBlock *GaugeDesc
NodeIsActive *GaugeDesc
ValidatorActiveStake *GaugeDesc
ValidatorLastVote *GaugeDesc
ValidatorRootSlot *GaugeDesc
ValidatorDelinquent *GaugeDesc
AccountBalances *GaugeDesc
NodeVersion *GaugeDesc
NodeIdentity *GaugeDesc
NodeIsHealthy *GaugeDesc
NodeNumSlotsBehind *GaugeDesc
NodeMinimumLedgerSlot *GaugeDesc
NodeFirstAvailableBlock *GaugeDesc
NodeIsActive *GaugeDesc
FoundationMinRequiredVersion *GaugeDesc
}

func NewSolanaCollector(client *rpc.Client, config *ExporterConfig) *SolanaCollector {
func NewSolanaCollector(rpcClient *rpc.Client, apiClient *api.Client, config *ExporterConfig) *SolanaCollector {
collector := &SolanaCollector{
rpcClient: client,
rpcClient: rpcClient,
apiClient: apiClient,
logger: slog.Get(),
config: config,
ValidatorActiveStake: NewGaugeDesc(
Expand Down Expand Up @@ -110,6 +115,11 @@ func NewSolanaCollector(client *rpc.Client, config *ExporterConfig) *SolanaColle
fmt.Sprintf("Whether the node is active and participating in consensus (using %s pubkey)", IdentityLabel),
IdentityLabel,
),
FoundationMinRequiredVersion: NewGaugeDesc(
"solana_foundation_min_required_version",
"Minimum required Solana version for the foundation delegation program",
VersionLabel, ClusterLabel,
),
}
return collector
}
Expand All @@ -127,6 +137,7 @@ func (c *SolanaCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.NodeMinimumLedgerSlot.Desc
ch <- c.NodeFirstAvailableBlock.Desc
ch <- c.NodeIsActive.Desc
ch <- c.FoundationMinRequiredVersion.Desc
}

func (c *SolanaCollector) collectVoteAccounts(ctx context.Context, ch chan<- prometheus.Metric) {
Expand Down Expand Up @@ -296,6 +307,34 @@ func (c *SolanaCollector) Collect(ch chan<- prometheus.Metric) {
c.collectVersion(ctx, ch)
c.collectIdentity(ctx, ch)
c.collectBalances(ctx, ch)
c.collectMinRequiredVersion(ctx, ch)

c.logger.Info("=========== END COLLECTION ===========")
}

func (c *SolanaCollector) collectMinRequiredVersion(ctx context.Context, ch chan<- prometheus.Metric) {
c.logger.Info("Collecting minimum required version...")

genesisHash, err := c.rpcClient.GetGenesisHash(ctx)
if err != nil {
c.logger.Errorf("failed to get genesis hash: %v", err)
ch <- c.FoundationMinRequiredVersion.NewInvalidMetric(err)
return
}
cluster, err := rpc.GetClusterFromGenesisHash(genesisHash)
if err != nil {
c.logger.Errorf("failed to determine cluster: %v", err)
ch <- c.FoundationMinRequiredVersion.NewInvalidMetric(err)
return
}

minVersion, err := c.apiClient.GetMinRequiredVersion(ctx, cluster)
if err != nil {
c.logger.Errorf("failed to get min required version: %v", err)
ch <- c.FoundationMinRequiredVersion.NewInvalidMetric(err)
} else {
ch <- c.FoundationMinRequiredVersion.MustNewConstMetric(1, minVersion, cluster)
}

c.logger.Info("Minimum required version collected.")
}
15 changes: 14 additions & 1 deletion cmd/solana-exporter/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"
"time"

"github.com/asymmetric-research/solana-exporter/pkg/api"
"github.com/asymmetric-research/solana-exporter/pkg/rpc"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
Expand Down Expand Up @@ -210,7 +211,16 @@ func newTestConfig(simulator *Simulator, fast bool) *ExporterConfig {

func TestSolanaCollector(t *testing.T) {
simulator, client := NewSimulator(t, 35)
collector := NewSolanaCollector(client, newTestConfig(simulator, false))
simulator.Server.SetOpt(rpc.EasyResultsOpt, "getGenesisHash", rpc.MainnetGenesisHash)

mock := api.NewMockClient()
mock.SetMinRequiredVersion("2.0.20")

collector := NewSolanaCollector(
client,
mock.Client,
newTestConfig(simulator, false),
)
prometheus.NewPedanticRegistry().MustRegister(collector)

stake := float64(1_000_000) / rpc.LamportsInSol
Expand Down Expand Up @@ -265,6 +275,9 @@ func TestSolanaCollector(t *testing.T) {
collector.NodeFirstAvailableBlock.makeCollectionTest(
NewLV(11),
),
collector.FoundationMinRequiredVersion.makeCollectionTest(
NewLV(1, "mainnet-beta", "2.0.20"),
),
}

for _, test := range testCases {
Expand Down
8 changes: 5 additions & 3 deletions cmd/solana-exporter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"net/http"

"github.com/asymmetric-research/solana-exporter/pkg/api"
"github.com/asymmetric-research/solana-exporter/pkg/rpc"
"github.com/asymmetric-research/solana-exporter/pkg/slog"
"github.com/prometheus/client_golang/prometheus"
Expand All @@ -26,9 +27,10 @@ func main() {
)
}

client := rpc.NewRPCClient(config.RpcUrl, config.HttpTimeout)
collector := NewSolanaCollector(client, config)
slotWatcher := NewSlotWatcher(client, config)
rpcClient := rpc.NewRPCClient(config.RpcUrl, config.HttpTimeout)
apiClient := api.NewClient()
collector := NewSolanaCollector(rpcClient, apiClient, config)
slotWatcher := NewSlotWatcher(rpcClient, config)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go slotWatcher.WatchSlots(ctx)
Expand Down
81 changes: 81 additions & 0 deletions pkg/api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package api

import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)

const (
// CacheTimeout defines how often to refresh the minimum required version (6 hours)
CacheTimeout = 6 * time.Hour

// SolanaEpochStatsAPI is the base URL for the Solana validators epoch stats API
SolanaEpochStatsAPI = "https://api.solana.org/api/validators/epoch-stats"
)

type Client struct {
HttpClient http.Client
baseURL string
cache struct {
version string
lastCheck time.Time
}
mu sync.RWMutex
// How often to refresh the cache
cacheTimeout time.Duration
}

func NewClient() *Client {
return &Client{
HttpClient: http.Client{},
cacheTimeout: CacheTimeout,
baseURL: SolanaEpochStatsAPI,
}
}

func (c *Client) GetMinRequiredVersion(ctx context.Context, cluster string) (string, error) {
// Check cache first
c.mu.RLock()
if !c.cache.lastCheck.IsZero() && time.Since(c.cache.lastCheck) < c.cacheTimeout {
version := c.cache.version
c.mu.RUnlock()
return version, nil
}
c.mu.RUnlock()

// Make API request
url := fmt.Sprintf("%s?cluster=%s&epoch=latest", c.baseURL, cluster)

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

resp, err := c.HttpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch min required version: %w", err)
}
defer resp.Body.Close()

var stats ValidatorEpochStats
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}

// Validate the response
if stats.Stats.Config.MinVersion == "" {
return "", fmt.Errorf("min_version not found in response")
}

// Update cache
c.mu.Lock()
c.cache.version = stats.Stats.Config.MinVersion
c.cache.lastCheck = time.Now()
c.mu.Unlock()

return stats.Stats.Config.MinVersion, nil
}
101 changes: 101 additions & 0 deletions pkg/api/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestClient_GetMinRequiredVersion(t *testing.T) {
tests := []struct {
name string
cluster string
mockJSON string
wantErr bool
wantErrMsg string
want string
}{
{
name: "valid mainnet response",
cluster: "mainnet-beta",
mockJSON: `{
"stats": {
"config": {
"min_version": "2.0.20"
}
}
}`,
want: "2.0.20",
},
{
name: "valid testnet response",
cluster: "testnet",
mockJSON: `{
"stats": {
"config": {
"min_version": "2.1.6"
}
}
}`,
want: "2.1.6",
},
{
name: "invalid json response",
cluster: "mainnet-beta",
mockJSON: `{"invalid": "json"`,
wantErr: true,
wantErrMsg: "failed to decode response",
},
{
name: "missing version in response",
cluster: "mainnet-beta",
mockJSON: `{"stats": {"config": {}}}`,
wantErr: true,
wantErrMsg: "min_version not found in response",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "/api/validators/epoch-stats", r.URL.Path)
assert.Equal(t, tt.cluster, r.URL.Query().Get("cluster"))
assert.Equal(t, "latest", r.URL.Query().Get("epoch"))

// Send response
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(tt.mockJSON))
}))
defer server.Close()

// Create client with test server URL
client := &Client{
HttpClient: http.Client{},
baseURL: server.URL + "/api/validators/epoch-stats",
cacheTimeout: time.Hour,
}

// Test GetMinRequiredVersion
got, err := client.GetMinRequiredVersion(context.Background(), tt.cluster)
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrMsg)
return
}

assert.NoError(t, err)
assert.Equal(t, tt.want, got)

// Test caching
cachedVersion, err := client.GetMinRequiredVersion(context.Background(), tt.cluster)
assert.NoError(t, err)
assert.Equal(t, tt.want, cachedVersion)
})
}
}
Loading
Loading