Skip to content

Commit

Permalink
feat: add minimum required version metric.
Browse files Browse the repository at this point in the history
Add new metric solana_min_required_version to track the minimum required
Solana version for foundation delegation program across different clusters.
  • Loading branch information
qedgardo committed Jan 3, 2025
1 parent c689d36 commit 22c866f
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 5 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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_min_required_version` | Minimum required Solana version for foundation delegation program | `version`, `cluster` |

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

Expand All @@ -100,6 +101,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` |
38 changes: 36 additions & 2 deletions cmd/solana-exporter/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"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 @@ -28,6 +30,7 @@ const (

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

config *ExporterConfig
Expand All @@ -43,11 +46,13 @@ type SolanaCollector struct {
NodeNumSlotsBehind *GaugeDesc
NodeMinimumLedgerSlot *GaugeDesc
NodeFirstAvailableBlock *GaugeDesc
MinRequiredVersion *prometheus.Desc
}

func NewSolanaCollector(client *rpc.Client, config *ExporterConfig) *SolanaCollector {
func NewSolanaCollector(rpcClient *rpc.Client, config *ExporterConfig) *SolanaCollector {
collector := &SolanaCollector{
rpcClient: client,
rpcClient: rpcClient,
apiClient: api.NewClient(),
logger: slog.Get(),
config: config,
ValidatorActiveStake: NewGaugeDesc(
Expand Down Expand Up @@ -96,6 +101,12 @@ 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.",
),
MinRequiredVersion: prometheus.NewDesc(
"solana_min_required_version",
"Minimum required Solana version for foundation delegation program",
[]string{"version", "cluster"},
nil,
),
}
return collector
}
Expand All @@ -111,6 +122,7 @@ func (c *SolanaCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.NodeNumSlotsBehind.Desc
ch <- c.NodeMinimumLedgerSlot.Desc
ch <- c.NodeFirstAvailableBlock.Desc
ch <- c.MinRequiredVersion
}

func (c *SolanaCollector) collectVoteAccounts(ctx context.Context, ch chan<- prometheus.Metric) {
Expand Down Expand Up @@ -256,5 +268,27 @@ func (c *SolanaCollector) Collect(ch chan<- prometheus.Metric) {
c.collectVersion(ctx, ch)
c.collectBalances(ctx, ch)

// Get cluster from genesis hash
genesisHash, err := c.rpcClient.GetGenesisHash(context.Background())
if err != nil {
c.logger.Errorf("failed to get genesis hash: %v", err)
return
}
cluster := rpc.GetClusterFromGenesisHash(genesisHash)

// Get min required version from API
minVersion, err := c.apiClient.GetMinRequiredVersion(context.Background(), cluster)
if err != nil {
c.logger.Errorf("failed to get min required version: %v", err)
} else {
ch <- prometheus.MustNewConstMetric(
c.MinRequiredVersion,
prometheus.GaugeValue,
1,
minVersion,
cluster,
)
}

c.logger.Info("=========== END COLLECTION ===========")
}
3 changes: 2 additions & 1 deletion cmd/solana-exporter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
71 changes: 71 additions & 0 deletions pkg/api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package api

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

type Client struct {
HttpClient http.Client
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: 6 * time.Hour, // Cache for 6 hours by default
}
}

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()

// Cache miss or expired, fetch new data
url := fmt.Sprintf("https://api.solana.org/api/validators/epoch-stats?cluster=%s&epoch=latest", 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 structure
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
}
9 changes: 9 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package api

type ValidatorEpochStats struct {
Stats struct {
Config struct {
MinVersion string `json:"min_version"`
} `json:"config"`
} `json:"stats"`
}
34 changes: 32 additions & 2 deletions pkg/rpc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/asymmetric-research/solana-exporter/pkg/slog"
"go.uber.org/zap"
"io"
"net/http"
"slices"
"time"

"github.com/asymmetric-research/solana-exporter/pkg/slog"
"go.uber.org/zap"
)

type (
Expand Down Expand Up @@ -43,8 +44,27 @@ const (
CommitmentConfirmed Commitment = "confirmed"
// CommitmentProcessed level represents a transaction that has been received by the network and included in a block.
CommitmentProcessed Commitment = "processed"

// Genesis hashes for different Solana clusters
DevnetGenesisHash = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"
TestnetGenesisHash = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY"
MainnetGenesisHash = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
)

// getClusterFromGenesisHash returns the cluster name based on the genesis hash
func GetClusterFromGenesisHash(hash string) string {
switch hash {
case DevnetGenesisHash:
return "devnet"
case TestnetGenesisHash:
return "testnet"
case MainnetGenesisHash:
return "mainnet-beta"
default:
return "unknown"
}
}

func NewRPCClient(rpcAddr string, httpTimeout time.Duration) *Client {
return &Client{HttpClient: http.Client{}, RpcUrl: rpcAddr, HttpTimeout: httpTimeout, logger: slog.Get()}
}
Expand Down Expand Up @@ -259,3 +279,13 @@ func (c *Client) GetFirstAvailableBlock(ctx context.Context) (int64, error) {
}
return resp.Result, nil
}

// GetGenesisHash returns the hash of the genesis block
// See API docs: https://docs.solana.com/api/http#getgenesishash
func (c *Client) GetGenesisHash(ctx context.Context) (string, error) {
var resp Response[string]
if err := getResponse(ctx, c, "getGenesisHash", []any{}, &resp); err != nil {
return "", err
}
return resp.Result, nil
}
1 change: 1 addition & 0 deletions pkg/rpc/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package rpc

0 comments on commit 22c866f

Please sign in to comment.