commit 3718a7cbe9d5e2afd626c77b4bd997f8380e635f Author: Felipe Andrade Date: Tue Jul 11 14:50:31 2023 -0700 skeleton, poll, heartbeat, configs, healthz, dockerfile diff --git a/op-ufm/op-ufm/.gitignore b/op-ufm/op-ufm/.gitignore new file mode 100644 index 0000000..65e6a82 --- /dev/null +++ b/op-ufm/op-ufm/.gitignore @@ -0,0 +1,3 @@ +bin + +config.toml diff --git a/op-ufm/op-ufm/Dockerfile b/op-ufm/op-ufm/Dockerfile new file mode 100644 index 0000000..f3cebc8 --- /dev/null +++ b/op-ufm/op-ufm/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.20.4-alpine3.18 as builder + +ARG GITCOMMIT=docker +ARG GITDATE=docker +ARG GITVERSION=docker + +RUN apk add make jq git gcc musl-dev linux-headers + +COPY ./ufm /app + +WORKDIR /app + +RUN make ufm + +FROM alpine:3.18 + +COPY ./ufm/entrypoint.sh /bin/entrypoint.sh + +RUN apk update && \ + apk add ca-certificates && \ + chmod +x /bin/entrypoint.sh + +EXPOSE 8080 + +VOLUME /etc/ufm + +COPY --from=builder /app/bin/ufm /bin/ufm + +ENTRYPOINT ["/bin/entrypoint.sh"] +CMD ["/bin/ufm", "/etc/ufm/config.toml"] diff --git a/op-ufm/op-ufm/README.md b/op-ufm/op-ufm/README.md new file mode 100644 index 0000000..235273a --- /dev/null +++ b/op-ufm/op-ufm/README.md @@ -0,0 +1,23 @@ +# OP User Facing Monitoring + +This project simulates a synthetic user interacting with a OP Stack chain. + +It is intended to be used as a tool for monitoring +the health of the network by measuring end-to-end transaction latency. + + +## Metrics + +* Round-trip duration time to get transaction receipt (from creation timestamp) + +* First-seen duration time (from creation timestamp) + + +## Usage + +Run `make ufm` to build the binary. No additional dependencies are necessary. + +Copy `example.config.toml` to `config.toml` and edit the file to configure the service. + +Start the service with `ufm config.toml`. + diff --git a/op-ufm/op-ufm/cmd/main.go b/op-ufm/op-ufm/cmd/main.go new file mode 100644 index 0000000..dfee0b2 --- /dev/null +++ b/op-ufm/op-ufm/cmd/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "op-ufm/pkg/config" + "op-ufm/pkg/service" + "os" + "os/signal" + "syscall" + + "github.com/ethereum/go-ethereum/log" +) + +var ( + GitVersion = "" + GitCommit = "" + GitDate = "" +) + +func main() { + log.Root().SetHandler( + log.LvlFilterHandler( + log.LvlInfo, + log.StreamHandler(os.Stdout, log.JSONFormat()), + ), + ) + + log.Info("initializing", "version", GitVersion, "commit", GitCommit, "date", GitDate) + + if len(os.Args) < 2 { + log.Crit("must specify a config file on the command line") + } + cfg := initConfig(os.Args[1]) + + ctx := context.Background() + svc := service.New(cfg) + svc.Start(ctx) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + recvSig := <-sig + log.Info("caught signal, shutting down", "signal", recvSig) + + svc.Shutdown() +} + +func initConfig(cfgFile string) *config.Config { + cfg, err := config.New(cfgFile) + if err != nil { + log.Crit("error reading config file", "file", cfgFile, "err", err) + } + + // update log level from config + logLevel, err := log.LvlFromString(cfg.LogLevel) + if err != nil { + logLevel = log.LvlInfo + if cfg.LogLevel != "" { + log.Warn("invalid server.log_level set: " + cfg.LogLevel) + } + } + log.Root().SetHandler( + log.LvlFilterHandler( + logLevel, + log.StreamHandler(os.Stdout, log.JSONFormat()), + ), + ) + + // readable parsed config + jsonCfg, _ := json.MarshalIndent(cfg, "", " ") + fmt.Printf("%s", string(jsonCfg)) + + err = cfg.Validate() + if err != nil { + log.Crit("invalid config", "err", err) + } + + return cfg +} diff --git a/op-ufm/op-ufm/entrypoint.sh b/op-ufm/op-ufm/entrypoint.sh new file mode 100644 index 0000000..ef83fa8 --- /dev/null +++ b/op-ufm/op-ufm/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +echo "Updating CA certificates." +update-ca-certificates +echo "Running CMD." +exec "$@" \ No newline at end of file diff --git a/op-ufm/op-ufm/example.config.toml b/op-ufm/op-ufm/example.config.toml new file mode 100644 index 0000000..f4abab7 --- /dev/null +++ b/op-ufm/op-ufm/example.config.toml @@ -0,0 +1,60 @@ +# Log level. +# Possible values: trace | debug | info | warn | error | crit +# Default: debug +log_level = "debug" + +[signer_service] +# URL to the signer service +url = "http://localhost:1234" + +[healthz] +# Whether or not to enable healthz endpoint +enabled = true +# Host for the healthz endpoint to listen on +host = "0.0.0.0" +# Port for the above. +port = 8080 + +[metrics] +# Whether or not to enable Prometheus metrics +enabled = true +# Host for the Prometheus metrics endpoint to listen on. +host = "0.0.0.0" +# Port for the above. +port = 9761 + +[wallets.default] +# OP Stack Chain ID +# see https://community.optimism.io/docs/useful-tools/networks/ +chain_id = 420 +# Signer method to use +# Possible values: signer | static +signer_method = "signer" +# Address used to send transactions +address="0x0123" +# For static signer method, the private key to use +# private_key="" + +[providers.p1] +# URL to the RPC provider +url = "http://localhost:8551" +# Read only providers are only used to check for transactions +read_only = true +# Interval to poll the provider for expected transactions +read_interval = "1s" +# Interval to submit new transactions to the provider +send_interval = "5s" +# Wallet to be used for sending transactions +wallet = "default" + +[providers.p2] +# URL to the RPC provider +url = "http://localhost:8552" +# Read only providers are only used to check for transactions +read_only = false +# Interval to poll the provider for expected transactions +read_interval = "2s" +# Interval to submit new transactions to the provider +send_interval = "3s" +# Wallet to be used for sending transactions +wallet = "default" diff --git a/op-ufm/op-ufm/go.mod b/op-ufm/op-ufm/go.mod new file mode 100644 index 0000000..342006e --- /dev/null +++ b/op-ufm/op-ufm/go.mod @@ -0,0 +1,26 @@ +module op-ufm + +go 1.20 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/ethereum/go-ethereum v1.12.0 + github.com/gorilla/mux v1.8.0 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.14.0 + github.com/rs/cors v1.7.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/holiman/uint256 v1.2.2-0.20230321075855-87b91420868c // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.39.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + golang.org/x/sys v0.7.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect +) diff --git a/op-ufm/op-ufm/go.sum b/op-ufm/op-ufm/go.sum new file mode 100644 index 0000000..520eb79 --- /dev/null +++ b/op-ufm/op-ufm/go.sum @@ -0,0 +1,43 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/ethereum/go-ethereum v1.12.0 h1:bdnhLPtqETd4m3mS8BGMNvBTf36bO5bx/hxE2zljOa0= +github.com/ethereum/go-ethereum v1.12.0/go.mod h1:/oo2X/dZLJjf2mJ6YT9wcWxa4nNJDBKDBU6sFIpx1Gs= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/holiman/uint256 v1.2.2-0.20230321075855-87b91420868c h1:DZfsyhDK1hnSS5lH8l+JggqzEleHteTYfutAiVlSUM8= +github.com/holiman/uint256 v1.2.2-0.20230321075855-87b91420868c/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZmPzLUTxw= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= +github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI= +github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/op-ufm/op-ufm/pkg/config/config.go b/op-ufm/op-ufm/pkg/config/config.go new file mode 100644 index 0000000..bf8416c --- /dev/null +++ b/op-ufm/op-ufm/pkg/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "github.com/BurntSushi/toml" + "github.com/pkg/errors" +) + +type Config struct { + LogLevel string `toml:"log_level"` + + Signer SignerServiceConfig `toml:"signer_service"` + Metrics MetricsConfig `toml:"metrics"` + Healthz HealthzConfig `toml:"healthz"` + + Wallets map[string]*WalletConfig `toml:"wallets"` + Providers map[string]*ProviderConfig `toml:"providers"` +} + +type SignerServiceConfig struct { + URL string `toml:"url"` +} + +type MetricsConfig struct { + Enabled bool `toml:"enabled"` + Host string `toml:"host"` + Port int `toml:"port"` +} + +type HealthzConfig struct { + Enabled bool `toml:"enabled"` + Host string `toml:"host"` + Port int `toml:"port"` +} + +type WalletConfig struct { + // default: 420 (Optimism Goerli) + ChainID uint `toml:"chain_id"` + + // signer | static, default: signer + SignerMethod string `toml:"signer_method"` + + // for static signing + Address string `toml:"address"` + PrivateKey string `toml:"private_key"` +} + +type ProviderConfig struct { + URL string `toml:"url"` + Wallet string `toml:"wallet"` + ReadOnly bool `toml:"read_only"` + ReadInterval TOMLDuration `toml:"read_interval"` + SendInterval TOMLDuration `toml:"send_interval"` +} + +func New(file string) (*Config, error) { + cfg := &Config{} + if _, err := toml.DecodeFile(file, cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) Validate() error { + if c.Metrics.Enabled { + if c.Metrics.Host == "" || c.Metrics.Port == 0 { + return errors.New("metrics is enabled but host or port are missing") + } + } + if c.Healthz.Enabled { + if c.Healthz.Host == "" || c.Healthz.Port == 0 { + return errors.New("healthz is enabled but host or port are missing") + } + } + + if len(c.Wallets) == 0 { + return errors.New("at least one wallet must be set") + } + + if len(c.Providers) == 0 { + return errors.New("at least one provider must be set") + } + + for name, wallet := range c.Wallets { + if wallet.ChainID == 0 { + return errors.Errorf("wallet [%s] chain_id is missing", name) + } + if wallet.SignerMethod != "signer" && wallet.SignerMethod != "static" { + return errors.Errorf("wallet [%s] signer_method is invalid", name) + } + if wallet.SignerMethod == "signer" { + if c.Signer.URL == "" { + return errors.New("signer url is missing") + } + } + if wallet.SignerMethod == "static" { + if wallet.PrivateKey == "" { + return errors.Errorf("wallet [%s] private_key is missing", name) + } + } + if wallet.Address == "" { + return errors.Errorf("wallet [%s] address is missing", name) + } + } + + for name, provider := range c.Providers { + if provider.URL == "" { + return errors.Errorf("provider [%s] url is missing", name) + } + if provider.ReadInterval == 0 { + return errors.Errorf("provider [%s] read interval is missing", name) + } + if provider.SendInterval == 0 { + return errors.Errorf("provider [%s] send interval is missing", name) + } + if provider.Wallet == "" { + return errors.Errorf("provider [%s] wallet is missing", name) + } + if _, ok := c.Wallets[provider.Wallet]; !ok { + return errors.Errorf("provider [%s] has an invalid wallet [%s]", name, provider.Wallet) + } + } + + if c.LogLevel == "" { + c.LogLevel = "debug" + } + + return nil +} diff --git a/op-ufm/op-ufm/pkg/config/toml_duration.go b/op-ufm/op-ufm/pkg/config/toml_duration.go new file mode 100644 index 0000000..64fe368 --- /dev/null +++ b/op-ufm/op-ufm/pkg/config/toml_duration.go @@ -0,0 +1,15 @@ +package config + +import "time" + +type TOMLDuration time.Duration + +func (t *TOMLDuration) UnmarshalText(b []byte) error { + d, err := time.ParseDuration(string(b)) + if err != nil { + return err + } + + *t = TOMLDuration(d) + return nil +} diff --git a/op-ufm/op-ufm/pkg/provider/handlers.go b/op-ufm/op-ufm/pkg/provider/handlers.go new file mode 100644 index 0000000..46759e6 --- /dev/null +++ b/op-ufm/op-ufm/pkg/provider/handlers.go @@ -0,0 +1,17 @@ +package provider + +import ( + "context" + + "github.com/ethereum/go-ethereum/log" +) + +// Heartbeat poll for expected transactions +func (p *Provider) Heartbeat(ctx context.Context) { + log.Debug("heartbeat", "provider", p.name) +} + +// Roundtrip send a new transaction to measure round trip latency +func (p *Provider) Roundtrip(ctx context.Context) { + log.Debug("roundtrip", "provider", p.name) +} diff --git a/op-ufm/op-ufm/pkg/provider/provider.go b/op-ufm/op-ufm/pkg/provider/provider.go new file mode 100644 index 0000000..ea22ae4 --- /dev/null +++ b/op-ufm/op-ufm/pkg/provider/provider.go @@ -0,0 +1,57 @@ +package provider + +import ( + "context" + "net/http" + "op-ufm/pkg/config" + "time" +) + +type Provider struct { + name string + config *config.ProviderConfig + cancelFunc context.CancelFunc + + client *http.Client +} + +func New(name string, cfg *config.ProviderConfig) *Provider { + p := &Provider{ + name: name, + config: cfg, + + client: http.DefaultClient, + } + return p +} + +func (p *Provider) Start(ctx context.Context) { + providerCtx, cancelFunc := context.WithCancel(ctx) + p.cancelFunc = cancelFunc + schedule(providerCtx, time.Duration(p.config.ReadInterval), p.Heartbeat) + if !p.config.ReadOnly { + schedule(providerCtx, time.Duration(p.config.SendInterval), p.Roundtrip) + } +} + +func (p *Provider) Shutdown() { + if p.cancelFunc != nil { + p.cancelFunc() + } +} + +func schedule(ctx context.Context, interval time.Duration, handler func(ctx context.Context)) { + go func() { + for { + timer := time.NewTimer(interval) + handler(ctx) + + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return + } + } + }() +} diff --git a/op-ufm/op-ufm/pkg/service/healthz.go b/op-ufm/op-ufm/pkg/service/healthz.go new file mode 100644 index 0000000..6a3d378 --- /dev/null +++ b/op-ufm/op-ufm/pkg/service/healthz.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + "github.com/ethereum/go-ethereum/log" + "github.com/pkg/errors" + + "github.com/gorilla/mux" + "github.com/rs/cors" +) + +type Healthz struct { + ctx context.Context + server *http.Server +} + +func (h *Healthz) Start(ctx context.Context, host string, port int) { + go func() { + hdlr := mux.NewRouter() + hdlr.HandleFunc("/healthz", h.Handle).Methods("GET") + addr := fmt.Sprintf("%s:%d", host, port) + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + }) + server := &http.Server{ + Handler: c.Handler(hdlr), + Addr: addr, + } + h.server = server + h.ctx = ctx + err := h.server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Crit("error starting healthz server", "err", err) + } + }() +} + +func (h *Healthz) Shutdown() error { + return h.server.Shutdown(h.ctx) +} + +func (h *Healthz) Handle(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) +} diff --git a/op-ufm/op-ufm/pkg/service/service.go b/op-ufm/op-ufm/pkg/service/service.go new file mode 100644 index 0000000..483bfff --- /dev/null +++ b/op-ufm/op-ufm/pkg/service/service.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "op-ufm/pkg/config" + "op-ufm/pkg/provider" + + "github.com/ethereum/go-ethereum/log" +) + +type Service struct { + Config *config.Config + Healthz *Healthz + Providers map[string]*provider.Provider +} + +func New(cfg *config.Config) *Service { + s := &Service{ + Config: cfg, + Healthz: &Healthz{}, + Providers: make(map[string]*provider.Provider, len(cfg.Providers)), + } + return s +} + +func (s *Service) Start(ctx context.Context) { + log.Info("service starting") + if s.Config.Healthz.Enabled { + s.Healthz.Start(ctx, s.Config.Healthz.Host, s.Config.Healthz.Port) + log.Info("healthz started") + } + for name, providerConfig := range s.Config.Providers { + s.Providers[name] = provider.New(name, providerConfig) + s.Providers[name].Start(ctx) + log.Info("provider started", "provider", name) + } + log.Info("service started") +} + +func (s *Service) Shutdown() { + log.Info("service shutting down") + if s.Config.Healthz.Enabled { + s.Healthz.Shutdown() + log.Info("healthz stopped") + } + for name, provider := range s.Providers { + provider.Shutdown() + log.Info("provider stopped", "provider", name) + } + log.Info("service stopped") +}