From 22c866f220e35da79b9f7cc729b54da74601836a Mon Sep 17 00:00:00 2001 From: Edgardo Quiroga Date: Fri, 3 Jan 2025 13:15:20 -0300 Subject: [PATCH] feat: add minimum required version metric. Add new metric solana_min_required_version to track the minimum required Solana version for foundation delegation program across different clusters. --- README.md | 2 + cmd/solana-exporter/collector.go | 38 ++++++++++++++++- cmd/solana-exporter/config.go | 3 +- pkg/api/client.go | 71 ++++++++++++++++++++++++++++++++ pkg/api/types.go | 9 ++++ pkg/rpc/client.go | 34 ++++++++++++++- pkg/rpc/types.go | 1 + 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 pkg/api/client.go create mode 100644 pkg/api/types.go create mode 100644 pkg/rpc/types.go diff --git a/README.md b/README.md index 12d83d3..4db3f1e 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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` | diff --git a/cmd/solana-exporter/collector.go b/cmd/solana-exporter/collector.go index 2eddcbd..b542c9a 100644 --- a/cmd/solana-exporter/collector.go +++ b/cmd/solana-exporter/collector.go @@ -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" @@ -28,6 +30,7 @@ const ( type SolanaCollector struct { rpcClient *rpc.Client + apiClient *api.Client logger *zap.SugaredLogger config *ExporterConfig @@ -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( @@ -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 } @@ -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) { @@ -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 ===========") } diff --git a/cmd/solana-exporter/config.go b/cmd/solana-exporter/config.go index ba9128b..5080742 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 ( diff --git a/pkg/api/client.go b/pkg/api/client.go new file mode 100644 index 0000000..d69c2c8 --- /dev/null +++ b/pkg/api/client.go @@ -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 +} diff --git a/pkg/api/types.go b/pkg/api/types.go new file mode 100644 index 0000000..fd73185 --- /dev/null +++ b/pkg/api/types.go @@ -0,0 +1,9 @@ +package api + +type ValidatorEpochStats struct { + Stats struct { + Config struct { + MinVersion string `json:"min_version"` + } `json:"config"` + } `json:"stats"` +} diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index 0578819..6b8febd 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -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 ( @@ -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()} } @@ -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 +} diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go new file mode 100644 index 0000000..9ab1e3e --- /dev/null +++ b/pkg/rpc/types.go @@ -0,0 +1 @@ +package rpc