Commit 256b4e47 authored by refcell's avatar refcell Committed by GitHub

feat(op-dispute-mon): initial setup (#9359)

parent eac6e991
# op-dispute-mon
The `op-dispute-mon` is an off-chain service to monitor dispute games.
## Quickstart
Clone this repo. Then run:
```shell
make op-dispute-mon
```
This will build the `op-dispute-mon` binary which can be run with
`./op-dispute-mon/bin/op-dispute-mon`.
## Usage
`op-dispute-mon` is configurable via command line flags and environment variables. The help menu
shows the available config options and can be accessed by running `./op-dispute-mon --help`.
package main
import (
"context"
"os"
"github.com/urfave/cli/v2"
"github.com/ethereum/go-ethereum/log"
monitor "github.com/ethereum-optimism/optimism/op-dispute-mon"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config"
"github.com/ethereum-optimism/optimism/op-dispute-mon/flags"
"github.com/ethereum-optimism/optimism/op-dispute-mon/version"
opservice "github.com/ethereum-optimism/optimism/op-service"
"github.com/ethereum-optimism/optimism/op-service/cliapp"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
"github.com/ethereum-optimism/optimism/op-service/opio"
)
var (
GitCommit = ""
GitDate = ""
)
// VersionWithMeta holds the textual version string including the metadata.
var VersionWithMeta = opservice.FormatVersion(version.Version, GitCommit, GitDate, version.Meta)
func main() {
args := os.Args
ctx := opio.WithInterruptBlocker(context.Background())
if err := run(ctx, args, monitor.Main); err != nil {
log.Crit("Application failed", "err", err)
}
}
type ConfiguredLifecycle func(ctx context.Context, log log.Logger, config *config.Config) (cliapp.Lifecycle, error)
func run(ctx context.Context, args []string, action ConfiguredLifecycle) error {
oplog.SetupDefaults()
app := cli.NewApp()
app.Version = VersionWithMeta
app.Flags = cliapp.ProtectFlags(flags.Flags)
app.Name = "op-dispute-mon"
app.Usage = "Monitor dispute games"
app.Description = "Monitors output proposals and dispute games."
app.Action = cliapp.LifecycleCmd(func(ctx *cli.Context, close context.CancelCauseFunc) (cliapp.Lifecycle, error) {
logger, err := setupLogging(ctx)
if err != nil {
return nil, err
}
logger.Info("Starting op-dispute-mon", "version", VersionWithMeta)
cfg, err := flags.NewConfigFromCLI(ctx)
if err != nil {
return nil, err
}
return action(ctx.Context, logger, cfg)
})
return app.RunContext(ctx, args)
}
func setupLogging(ctx *cli.Context) (log.Logger, error) {
logCfg := oplog.ReadCLIConfig(ctx)
logger := oplog.NewLogger(oplog.AppOut(ctx), logCfg)
oplog.SetGlobalLogHandler(logger.GetHandler())
return logger, nil
}
package config
import (
"errors"
"fmt"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof"
)
var (
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
)
// Config is a well typed config that is parsed from the CLI params.
// It also contains config options for auxiliary services.
type Config struct {
L1EthRpc string // L1 RPC Url
MetricsConfig opmetrics.CLIConfig
PprofConfig oppprof.CLIConfig
}
func NewConfig(l1EthRpc string) Config {
return Config{
L1EthRpc: l1EthRpc,
MetricsConfig: opmetrics.DefaultCLIConfig(),
PprofConfig: oppprof.DefaultCLIConfig(),
}
}
func (c Config) Check() error {
if c.L1EthRpc == "" {
return ErrMissingL1EthRPC
}
if err := c.MetricsConfig.Check(); err != nil {
return fmt.Errorf("metrics config: %w", err)
}
if err := c.PprofConfig.Check(); err != nil {
return fmt.Errorf("pprof config: %w", err)
}
return nil
}
package config
import (
"testing"
"github.com/stretchr/testify/require"
)
var (
validL1EthRpc = "http://localhost:8545"
)
func validConfig() Config {
return NewConfig(validL1EthRpc)
}
func TestL1EthRpcRequired(t *testing.T) {
config := validConfig()
config.L1EthRpc = ""
require.ErrorIs(t, config.Check(), ErrMissingL1EthRPC)
}
package flags
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config"
opservice "github.com/ethereum-optimism/optimism/op-service"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof"
)
const (
envVarPrefix = "OP_DISPUTE_MON"
)
func prefixEnvVars(name string) []string {
return opservice.PrefixEnvVar(envVarPrefix, name)
}
var (
// Required Flags
L1EthRpcFlag = &cli.StringFlag{
Name: "l1-eth-rpc",
Usage: "HTTP provider URL for L1.",
EnvVars: prefixEnvVars("L1_ETH_RPC"),
}
// Optional Flags
)
// requiredFlags are checked by [CheckRequired]
var requiredFlags = []cli.Flag{
L1EthRpcFlag,
}
// optionalFlags is a list of unchecked cli flags
var optionalFlags = []cli.Flag{}
func init() {
optionalFlags = append(optionalFlags, oplog.CLIFlags(envVarPrefix)...)
optionalFlags = append(optionalFlags, opmetrics.CLIFlags(envVarPrefix)...)
optionalFlags = append(optionalFlags, oppprof.CLIFlags(envVarPrefix)...)
Flags = append(requiredFlags, optionalFlags...)
}
// Flags contains the list of configuration options available to the binary.
var Flags []cli.Flag
func CheckRequired(ctx *cli.Context) error {
for _, f := range requiredFlags {
if !ctx.IsSet(f.Names()[0]) {
return fmt.Errorf("flag %s is required", f.Names()[0])
}
}
return nil
}
// NewConfigFromCLI parses the Config from the provided flags or environment variables.
func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) {
if err := CheckRequired(ctx); err != nil {
return nil, err
}
metricsConfig := opmetrics.ReadCLIConfig(ctx)
pprofConfig := oppprof.ReadCLIConfig(ctx)
return &config.Config{
L1EthRpc: ctx.String(L1EthRpcFlag.Name),
MetricsConfig: metricsConfig,
PprofConfig: pprofConfig,
}, nil
}
package flags
import (
"fmt"
"reflect"
"strings"
"testing"
opservice "github.com/ethereum-optimism/optimism/op-service"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
// TestUniqueFlags asserts that all flag names are unique, to avoid accidental conflicts between the many flags.
func TestUniqueFlags(t *testing.T) {
seenCLI := make(map[string]struct{})
for _, flag := range Flags {
for _, name := range flag.Names() {
if _, ok := seenCLI[name]; ok {
t.Errorf("duplicate flag %s", name)
continue
}
seenCLI[name] = struct{}{}
}
}
}
// TestUniqueEnvVars asserts that all flag env vars are unique, to avoid accidental conflicts between the many flags.
func TestUniqueEnvVars(t *testing.T) {
seenCLI := make(map[string]struct{})
for _, flag := range Flags {
envVar := envVarForFlag(flag)
if _, ok := seenCLI[envVar]; envVar != "" && ok {
t.Errorf("duplicate flag env var %s", envVar)
continue
}
seenCLI[envVar] = struct{}{}
}
}
func TestCorrectEnvVarPrefix(t *testing.T) {
for _, flag := range Flags {
envVar := envVarForFlag(flag)
if envVar == "" {
t.Errorf("Failed to find EnvVar for flag %v", flag.Names()[0])
}
if !strings.HasPrefix(envVar, fmt.Sprintf("%s_", envVarPrefix)) {
t.Errorf("Flag %v env var (%v) does not start with %s_", flag.Names()[0], envVar, envVarPrefix)
}
if strings.Contains(envVar, "__") {
t.Errorf("Flag %v env var (%v) has duplicate underscores", flag.Names()[0], envVar)
}
}
}
func envVarForFlag(flag cli.Flag) string {
values := reflect.ValueOf(flag)
envVarValue := values.Elem().FieldByName("EnvVars")
if envVarValue == (reflect.Value{}) || envVarValue.Len() == 0 {
return ""
}
return envVarValue.Index(0).String()
}
func TestEnvVarFormat(t *testing.T) {
for _, flag := range Flags {
flag := flag
flagName := flag.Names()[0]
t.Run(flagName, func(t *testing.T) {
envFlagGetter, ok := flag.(interface {
GetEnvVars() []string
})
envFlags := envFlagGetter.GetEnvVars()
require.True(t, ok, "must be able to cast the flag to an EnvVar interface")
require.Equal(t, 1, len(envFlags), "flags should have exactly one env var")
expectedEnvVar := opservice.FlagNameToEnvVarName(flagName, envVarPrefix)
require.Equal(t, expectedEnvVar, envFlags[0])
})
}
}
package metrics
import (
"io"
"github.com/ethereum-optimism/optimism/op-service/sources/caching"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/ethereum-optimism/optimism/op-service/httputil"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
)
const Namespace = "op_dispute_mon"
type Metricer interface {
RecordInfo(version string)
RecordUp()
caching.Metrics
}
// Metrics implementation must implement RegistryMetricer to allow the metrics server to work.
var _ opmetrics.RegistryMetricer = (*Metrics)(nil)
type Metrics struct {
ns string
registry *prometheus.Registry
factory opmetrics.Factory
*opmetrics.CacheMetrics
info prometheus.GaugeVec
up prometheus.Gauge
}
func (m *Metrics) Registry() *prometheus.Registry {
return m.registry
}
var _ Metricer = (*Metrics)(nil)
func NewMetrics() *Metrics {
registry := opmetrics.NewRegistry()
factory := opmetrics.With(registry)
return &Metrics{
ns: Namespace,
registry: registry,
factory: factory,
CacheMetrics: opmetrics.NewCacheMetrics(factory, Namespace, "provider_cache", "Provider cache"),
info: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "info",
Help: "Pseudo-metric tracking version and config info",
}, []string{
"version",
}),
up: factory.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "up",
Help: "1 if the op-challenger has finished starting up",
}),
}
}
func (m *Metrics) Start(host string, port int) (*httputil.HTTPServer, error) {
return opmetrics.StartServer(m.registry, host, port)
}
func (m *Metrics) StartBalanceMetrics(
l log.Logger,
client *ethclient.Client,
account common.Address,
) io.Closer {
return opmetrics.LaunchBalanceMetrics(l, m.registry, m.ns, client, account)
}
// RecordInfo sets a pseudo-metric that contains versioning and
// config info for the op-proposer.
func (m *Metrics) RecordInfo(version string) {
m.info.WithLabelValues(version).Set(1)
}
// RecordUp sets the up metric to 1.
func (m *Metrics) RecordUp() {
prometheus.MustRegister()
m.up.Set(1)
}
func (m *Metrics) Document() []opmetrics.DocumentedMetric {
return m.factory.Document()
}
package metrics
type NoopMetricsImpl struct{}
var NoopMetrics Metricer = new(NoopMetricsImpl)
func (*NoopMetricsImpl) RecordInfo(version string) {}
func (*NoopMetricsImpl) RecordUp() {}
func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {}
func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {}
package mon
import (
"context"
"errors"
"fmt"
"sync/atomic"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config"
"github.com/ethereum-optimism/optimism/op-dispute-mon/metrics"
"github.com/ethereum-optimism/optimism/op-dispute-mon/version"
"github.com/ethereum-optimism/optimism/op-service/dial"
"github.com/ethereum-optimism/optimism/op-service/httputil"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof"
)
type Service struct {
logger log.Logger
metrics metrics.Metricer
l1Client *ethclient.Client
pprofService *oppprof.Service
metricsSrv *httputil.HTTPServer
stopped atomic.Bool
}
// NewService creates a new Service.
func NewService(ctx context.Context, logger log.Logger, cfg *config.Config) (*Service, error) {
s := &Service{
logger: logger,
metrics: metrics.NewMetrics(),
}
if err := s.initFromConfig(ctx, cfg); err != nil {
return nil, errors.Join(fmt.Errorf("failed to init service: %w", err), s.Stop(ctx))
}
return s, nil
}
func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error {
if err := s.initL1Client(ctx, cfg); err != nil {
return fmt.Errorf("failed to init l1 client: %w", err)
}
if err := s.initPProf(&cfg.PprofConfig); err != nil {
return fmt.Errorf("failed to init profiling: %w", err)
}
if err := s.initMetricsServer(&cfg.MetricsConfig); err != nil {
return fmt.Errorf("failed to init metrics server: %w", err)
}
s.metrics.RecordInfo(version.SimpleWithMeta)
s.metrics.RecordUp()
return nil
}
func (s *Service) initL1Client(ctx context.Context, cfg *config.Config) error {
l1Client, err := dial.DialEthClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.L1EthRpc)
if err != nil {
return fmt.Errorf("failed to dial L1: %w", err)
}
s.l1Client = l1Client
return nil
}
func (s *Service) initPProf(cfg *oppprof.CLIConfig) error {
s.pprofService = oppprof.New(
cfg.ListenEnabled,
cfg.ListenAddr,
cfg.ListenPort,
cfg.ProfileType,
cfg.ProfileDir,
cfg.ProfileFilename,
)
if err := s.pprofService.Start(); err != nil {
return fmt.Errorf("failed to start pprof service: %w", err)
}
return nil
}
func (s *Service) initMetricsServer(cfg *opmetrics.CLIConfig) error {
if !cfg.Enabled {
return nil
}
s.logger.Debug("starting metrics server", "addr", cfg.ListenAddr, "port", cfg.ListenPort)
m, ok := s.metrics.(opmetrics.RegistryMetricer)
if !ok {
return fmt.Errorf("metrics were enabled, but metricer %T does not expose registry for metrics-server", s.metrics)
}
metricsSrv, err := opmetrics.StartServer(m.Registry(), cfg.ListenAddr, cfg.ListenPort)
if err != nil {
return fmt.Errorf("failed to start metrics server: %w", err)
}
s.logger.Info("started metrics server", "addr", metricsSrv.Addr())
s.metricsSrv = metricsSrv
return nil
}
func (s *Service) Start(ctx context.Context) error {
s.logger.Info("starting scheduler")
s.logger.Info("starting monitoring")
s.logger.Info("dispute monitor game service start completed")
return nil
}
func (s *Service) Stopped() bool {
return s.stopped.Load()
}
func (s *Service) Stop(ctx context.Context) error {
s.logger.Info("stopping dispute mon service")
var result error
if s.pprofService != nil {
if err := s.pprofService.Stop(ctx); err != nil {
result = errors.Join(result, fmt.Errorf("failed to close pprof server: %w", err))
}
}
if s.metricsSrv != nil {
if err := s.metricsSrv.Stop(ctx); err != nil {
result = errors.Join(result, fmt.Errorf("failed to close metrics server: %w", err))
}
}
s.stopped.Store(true)
s.logger.Info("stopped dispute mon service", "err", result)
return result
}
package monitor
import (
"context"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon"
"github.com/ethereum-optimism/optimism/op-service/cliapp"
)
func Main(ctx context.Context, logger log.Logger, cfg *config.Config) (cliapp.Lifecycle, error) {
if err := cfg.Check(); err != nil {
return nil, err
}
return mon.NewService(ctx, logger, cfg)
}
package monitor
import (
"context"
"testing"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
func TestMainShouldReturnErrorWhenConfigInvalid(t *testing.T) {
cfg := &config.Config{}
app, err := Main(context.Background(), testlog.Logger(t, log.LvlInfo), cfg)
require.ErrorIs(t, err, cfg.Check())
require.Nil(t, app)
}
package version
var (
Version = "v0.1.0"
Meta = "dev"
)
var SimpleWithMeta = func() string {
v := Version
if Meta != "" {
v += "-" + Meta
}
return v
}()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment