skeleton, poll, heartbeat, configs, healthz, dockerfile

This commit is contained in:
Felipe Andrade 2023-07-11 14:50:31 -07:00
commit 3718a7cbe9
14 changed files with 586 additions and 0 deletions

3
op-ufm/op-ufm/.gitignore vendored Normal file

@ -0,0 +1,3 @@
bin
config.toml

30
op-ufm/op-ufm/Dockerfile Normal file

@ -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"]

23
op-ufm/op-ufm/README.md Normal file

@ -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`.

80
op-ufm/op-ufm/cmd/main.go Normal file

@ -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
}

@ -0,0 +1,6 @@
#!/bin/sh
echo "Updating CA certificates."
update-ca-certificates
echo "Running CMD."
exec "$@"

@ -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"

26
op-ufm/op-ufm/go.mod Normal file

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

43
op-ufm/op-ufm/go.sum Normal file

@ -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=

@ -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
}

@ -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
}

@ -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)
}

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

@ -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"))
}

@ -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")
}