Skip to content

Commit

Permalink
feat: add minimum required version metric.
Browse files Browse the repository at this point in the history
  • Loading branch information
qedgardo committed Jan 15, 2025
1 parent 6311a4c commit c26d17a
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 20 deletions.
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

0 comments on commit c26d17a

Please sign in to comment.