From c6d7285bc4f637bb7a7e8d8340ed173bdef8c08b Mon Sep 17 00:00:00 2001 From: Nour <1257310+nourspace@users.noreply.github.com> Date: Thu, 20 Apr 2023 23:44:46 +0200 Subject: [PATCH] feat: public rpc node height (#4) * feat: refactor into cmd package * feat: implement node height * fix: docker build * fix: apply feedback - Returning errors instead of fatalF - Using CMDConfig - Removed usage of metric.type - Removed mutex - Added todos - Using limited reader - Parsing uint64 * chore: rename package to sl-exporter * fix: use is.ReadFile --- .dockerignore | 1 + Dockerfile | 1 + README.md | 9 ++++- cmd/config.go | 70 ++++++++++++++++++++++++++++++++ cmd/metrics.go | 72 +++++++++++++++++++++++++++++++++ cmd/registry.go | 42 +++++++++++++++++++ cmd/root.go | 41 +++++++++++++++++++ cmd/rpc.go | 57 ++++++++++++++++++++++++++ config.yaml | 13 +++++- go.mod | 2 +- main.go | 105 +----------------------------------------------- 11 files changed, 307 insertions(+), 106 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/metrics.go create mode 100644 cmd/registry.go create mode 100644 cmd/root.go create mode 100644 cmd/rpc.go diff --git a/.dockerignore b/.dockerignore index 88782e8..f251619 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ !go.mod !go.sum !*.go +!cmd diff --git a/Dockerfile b/Dockerfile index 1ed22e6..4e8b6b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN go mod download # Copy the go source COPY *.go . +COPY cmd/ cmd/ # Build RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ diff --git a/README.md b/README.md index 9bf1068..4d1df35 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ go build -o exporter main.go Enriches Relayer metrics (e.g. `cosmos_relayer_wallet_balance`) to dynamically create Grafana panels and alerts. - ## More use cases - Chain current block height @@ -49,3 +48,11 @@ docker run --rm -p 9100:9100 -v $(pwd)/config.yaml:/config.yaml sl-exporter --bi ## Release Simply push any tag to this repository + +# Todos + +- Add testing +- Enable linting on CI +- Use https://github.com/spf13/viper to simplify loading config and maybe flags as well +- Use slog for structured logging: https://pkg.go.dev/golang.org/x/exp/slog +- Load config once and don't recreate the registry \ No newline at end of file diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..f1bdcc0 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "flag" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" + "os" + "time" +) + +type Config struct { + Metrics map[string]Metric `yaml:"metrics"` +} + +type Metric struct { + Description string `yaml:"description"` + Labels []string `yaml:"labels"` + Samples []Sample `yaml:"samples,omitempty"` + Chains []ChainWithRPCs `yaml:"chains,omitempty"` +} + +type ChainWithRPCs struct { + Name string `yaml:"name"` + RPCs []string `yaml:"rpcs"` +} + +type Sample struct { + Labels []string `yaml:"labels"` + Value float64 `yaml:"value"` +} + +type CMDConfig struct { + ConfigFile string + Bind string + Interval time.Duration + LogLevel string +} + +var cmdConfig CMDConfig + +func init() { + flag.StringVar(&cmdConfig.ConfigFile, "config", "config.yaml", "configuration file") + flag.StringVar(&cmdConfig.Bind, "bind", "localhost:9100", "bind") + flag.DurationVar(&cmdConfig.Interval, "interval", 15*time.Second, "duration interval") + flag.StringVar(&cmdConfig.LogLevel, "loglevel", "info", "Log level (debug, info, warn, error)") + flag.Parse() + + level, err := log.ParseLevel(cmdConfig.LogLevel) + if err != nil { + log.Fatalf("Invalid log level: %v", err) + } + log.SetLevel(level) + + log.Debugf("Config File: %s\n", cmdConfig.ConfigFile) + log.Debugf("Interval: %s\n", cmdConfig.Interval) + log.Debugf("Bind: %s\n", cmdConfig.Bind) +} + +// readConfig reads config.yaml from disk +func readConfig(filename string) (*Config, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + return &config, nil +} diff --git a/cmd/metrics.go b/cmd/metrics.go new file mode 100644 index 0000000..840c9ef --- /dev/null +++ b/cmd/metrics.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + "net/url" +) + +// registerMetrics iterates config metrics and passes them to relevant handler +func registerMetrics(config *Config, registry *prometheus.Registry) error { + for metricName, metric := range config.Metrics { + var collector prometheus.Collector + + if len(metric.Samples) > 0 { + collector = sampleMetrics(metric, metricName) + } else if len(metric.Chains) > 0 { + collector = rpcMetrics(metric, metricName) + } else { + return fmt.Errorf("unsupported metric: %s", metricName) + } + + if err := registry.Register(collector); err != nil { + return fmt.Errorf("error registering %s: %v", metricName, err) + } + log.Infof("Register collector - %s", metricName) + } + return nil +} + +// sampleMetrics handles static gauge samples +func sampleMetrics(metric Metric, metricName string) *prometheus.GaugeVec { + gaugeVec := prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: prometheus.BuildFQName(namespace, subsystem, metricName), Help: metric.Description}, + metric.Labels, + ) + for _, sample := range metric.Samples { + gaugeVec.WithLabelValues(sample.Labels...).Set(sample.Value) + } + return gaugeVec +} + +// rpcMetrics handles dynamic gauge metrics for public_rpc_node_height +func rpcMetrics(metric Metric, metricName string) *prometheus.GaugeVec { + gaugeVec := prometheus.NewGaugeVec( + prometheus.GaugeOpts{Name: prometheus.BuildFQName(namespace, subsystem, metricName), Help: metric.Description}, + metric.Labels, + ) + + for _, chain := range metric.Chains { + for _, rpc := range chain.RPCs { + // Fetch and set the metric value for the rpc node + value, err := fetchRPCNodeHeight(rpc) + if err != nil { + log.Errorf("Error fetching height for rpc %s: %v", rpc, err) + continue + } + gaugeVec.WithLabelValues(chain.Name, urlHost(rpc)).Set(float64(value)) + } + } + return gaugeVec +} + +// urlHost extracts host from given rpc url +func urlHost(rpc string) string { + parsedURL, err := url.Parse(rpc) + if err != nil { + log.Warnf("Error parsing URL: %v\n", err) + return rpc + } + return parsedURL.Hostname() +} diff --git a/cmd/registry.go b/cmd/registry.go new file mode 100644 index 0000000..60eac64 --- /dev/null +++ b/cmd/registry.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/version" + "net/http" +) + +const ( + collector = "sl_exporter" + // Todo (nour): should we add namespace and subsystem + namespace = "" + subsystem = "" +) + +var ( + metricsRegistry *prometheus.Registry +) + +func updateRegistry(config *Config) (*prometheus.Registry, error) { + // Create sampleMetrics new registry for the updated metrics + newRegistry := prometheus.NewRegistry() + + // Register build_info metric + if err := newRegistry.Register(version.NewCollector(collector)); err != nil { + return nil, err + } + + if err := registerMetrics(config, newRegistry); err != nil { + return nil, err + } + + return newRegistry, nil +} + +func metricsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{}) + handler.ServeHTTP(w, r) + }) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..fbc9ab0 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + "net/http" + "time" +) + +func Execute() { + // Initialize metricsRegistry + metricsRegistry = prometheus.NewRegistry() + + // Start background goroutine to re-evaluate the config and update the registry at `interval` + go func() { + ticker := time.NewTicker(cmdConfig.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + config, err := readConfig(cmdConfig.ConfigFile) + if err != nil { + log.Errorf("Error reading config file %s: %v", cmdConfig.ConfigFile, err) + continue + } + if updatedRegistry, err := updateRegistry(config); err != nil { + log.Errorf("error updating registery: %v", err) + } else { + metricsRegistry = updatedRegistry + } + } + } + }() + + http.Handle("/metrics", metricsHandler()) + log.Infof("Starting Prometheus metrics server - %s", cmdConfig.Bind) + if err := http.ListenAndServe(cmdConfig.Bind, nil); err != nil { + log.Fatalf("Failed to start http server: %v", err) + } +} diff --git a/cmd/rpc.go b/cmd/rpc.go new file mode 100644 index 0000000..58a4246 --- /dev/null +++ b/cmd/rpc.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + log "github.com/sirupsen/logrus" + "io" + "net/http" + "strconv" +) + +type SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` +} + +type Result struct { + SyncInfo SyncInfo `json:"sync_info"` +} + +type Response struct { + Result Result `json:"result"` +} + +// fetchRPCNodeHeight fetches node height from the rpcURL +func fetchRPCNodeHeight(rpcURL string) (uint64, error) { + // Make a GET request to the REST endpoint + resp, err := http.Get(rpcURL + "/status") + if err != nil { + return 0, fmt.Errorf("error making GET request: %v", err) + } + defer resp.Body.Close() + + // Read the response body + limitedReader := &io.LimitedReader{R: resp.Body, N: 4 * 1024} + body, err := io.ReadAll(limitedReader) + if err != nil { + return 0, fmt.Errorf("error reading response body: %v", err) + } + + // Unmarshal JSON data into Response struct + var response Response + err = json.Unmarshal(body, &response) + if err != nil { + return 0, fmt.Errorf("error unmarshaling JSON data: %v", err) + } + + // Extract the latest_block_height as a number + latestBlockHeightStr := response.Result.SyncInfo.LatestBlockHeight + latestBlockHeight, err := strconv.ParseUint(latestBlockHeightStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("error converting latest_block_height to a number: %v", err) + } + + log.Debugf("Latest block height [%s]: %d\n", rpcURL, latestBlockHeight) + + return latestBlockHeight, nil +} diff --git a/config.yaml b/config.yaml index 104c68a..eda536c 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,6 @@ metrics: # Metric name cosmos_asset_exponent: - type: "gauge" description: "Exponent value for a Cosmos asset" # Metric labels labels: [ "chain", "denom" ] @@ -16,3 +15,15 @@ metrics: - { labels: [ "osmosis-1", "uosmo" ], value: 6 } - { labels: [ "sommelier-3", "usomm" ], value: 6 } - { labels: [ "theta-testnet-001", "uatom" ], value: 6 } + public_rpc_node_height: + description: "Node height of a public RPC node" + labels: [ "chain_id", "source" ] + chains: + - name: agoric-emerynet-5 + rpcs: + - https://agoric-rpc.easy2stake.com + - https://agoric-rpc.polkachu.com + - name: axelar-dojo-1 + rpcs: + - https://axelar-rpc.quantnode.tech + - https://rpc.axelar.bh.rocks diff --git a/go.mod b/go.mod index da11141..93ea4de 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module sl-exporter +module github.com/strangelove-ventures/sl-exporter go 1.20 diff --git a/main.go b/main.go index 9c414d0..a1b6a05 100644 --- a/main.go +++ b/main.go @@ -1,108 +1,7 @@ package main -import ( - "flag" - "github.com/prometheus/common/version" - "io" - "net/http" - "os" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - log "github.com/sirupsen/logrus" - "gopkg.in/yaml.v2" -) - -type Config struct { - Metrics map[string]Metric `yaml:"metrics"` -} - -type Metric struct { - Type string `yaml:"type"` - Description string `yaml:"description"` - Labels []string `yaml:"labels"` - Samples []Sample `yaml:"samples"` -} - -type Sample struct { - Labels []string `yaml:"labels"` - Value float64 `yaml:"value"` -} - -const ( - collector = "sl_exporter" -) +import "github.com/strangelove-ventures/sl-exporter/cmd" func main() { - var configFile, bind string - flag.StringVar(&configFile, "config", "config.yaml", "configuration file") - flag.StringVar(&bind, "bind", "localhost:9100", "bind") - flag.Parse() - - config, err := readConfig(configFile) - if err != nil { - log.Fatalf("Error reading config file %s: %v", configFile, err) - } - - // Create registry - registry := prometheus.NewRegistry() - - // Register build_info metric - if err := registry.Register(version.NewCollector(collector)); err != nil { - log.Fatalf("Error registering build_info : %v", err) - } - log.Infof("Register version collector - %s", collector) - - registerStaticMetrics(config, registry) - - http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) - log.Infof("Starting Prometheus metrics server - %s", bind) - if err := http.ListenAndServe(bind, nil); err != nil { - log.Fatalf("Failed to start http server: %v", err) - } -} - -func readConfig(filename string) (*Config, error) { - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - data, err := io.ReadAll(file) - if err != nil { - return nil, err - } - // Load yaml - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - return &config, nil -} - -func registerStaticMetrics(config *Config, registry *prometheus.Registry) { - // Iterate config metrics - for metricName, metric := range config.Metrics { - var collector prometheus.Collector - - switch metric.Type { - case "gauge": - gaugeVec := prometheus.NewGaugeVec( - // Todo (nour): should we add namespace and subsystem - prometheus.GaugeOpts{Name: prometheus.BuildFQName("", "", metricName), Help: metric.Description}, - metric.Labels, - ) - for _, sample := range metric.Samples { - gaugeVec.WithLabelValues(sample.Labels...).Set(sample.Value) - } - collector = gaugeVec - default: - log.Fatalf("Unsupported metric type: %s", metric.Type) - } - - if err := registry.Register(collector); err != nil { - log.Fatalf("Error registering %s: %v", metricName, err) - } - log.Infof("Register %s collector - %s", metric.Type, metricName) - } + cmd.Execute() }