From 688548d4ac3293449a88913275f886fd2e103cdf Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 09:41:18 -0800 Subject: feat: add Prometheus metrics and YAML config file support ## Metrics Package Comprehensive Prometheus metrics for production observability: Metrics tracked: - Request rate, latency, size per method (histograms) - Active connections and subscriptions (gauges) - Auth success/failure rates (counters) - Rate limit hits (counters) - Storage stats (event count, DB size) - Standard Go runtime metrics Features: - Automatic gRPC instrumentation via interceptors - Low overhead (~300-500ns per request) - Standard Prometheus client - HTTP /metrics endpoint - Grafana dashboard examples ## Config Package YAML configuration file support with environment overrides: Configuration sections: - Server (addresses, timeouts, public URL) - Database (path, connections, lifetime) - Auth (enabled, required, timestamp window, allowed pubkeys) - Rate limiting (per-method and per-user limits) - Metrics (endpoint, namespace) - Logging (level, format, output) - Storage (compaction, retention) Features: - YAML file loading - Environment variable overrides (MUXSTR_
_) - Sensible defaults - Validation on load - Duration and list parsing - Save/export configuration Both packages include comprehensive README with examples, best practices, and usage patterns. Config tests verify YAML parsing, env overrides, validation, and round-trip serialization. --- internal/config/config.go | 324 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 internal/config/config.go (limited to 'internal/config/config.go') diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..87ca4eb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,324 @@ +package config + +import ( + "fmt" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Config holds all configuration for the relay. +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Auth AuthConfig `yaml:"auth"` + RateLimit RateLimitConfig `yaml:"rate_limit"` + Metrics MetricsConfig `yaml:"metrics"` + Logging LoggingConfig `yaml:"logging"` + Storage StorageConfig `yaml:"storage"` +} + +// ServerConfig holds server configuration. +type ServerConfig struct { + GrpcAddr string `yaml:"grpc_addr"` + HttpAddr string `yaml:"http_addr"` + PublicURL string `yaml:"public_url"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` +} + +// DatabaseConfig holds database configuration. +type DatabaseConfig struct { + Path string `yaml:"path"` + MaxConnections int `yaml:"max_connections"` + MaxLifetime time.Duration `yaml:"max_lifetime"` +} + +// AuthConfig holds authentication configuration. +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Required bool `yaml:"required"` + TimestampWindow int64 `yaml:"timestamp_window"` + AllowedPubkeys []string `yaml:"allowed_pubkeys"` + SkipMethods []string `yaml:"skip_methods"` +} + +// RateLimitConfig holds rate limiting configuration. +type RateLimitConfig struct { + Enabled bool `yaml:"enabled"` + DefaultRPS float64 `yaml:"default_rps"` + DefaultBurst int `yaml:"default_burst"` + IPRPS float64 `yaml:"ip_rps"` + IPBurst int `yaml:"ip_burst"` + Methods map[string]MethodLimit `yaml:"methods"` + Users map[string]UserLimit `yaml:"users"` + SkipMethods []string `yaml:"skip_methods"` + SkipUsers []string `yaml:"skip_users"` + CleanupInterval time.Duration `yaml:"cleanup_interval"` + MaxIdleTime time.Duration `yaml:"max_idle_time"` +} + +// MethodLimit defines rate limits for a specific method. +type MethodLimit struct { + RPS float64 `yaml:"rps"` + Burst int `yaml:"burst"` +} + +// UserLimit defines rate limits for a specific user. +type UserLimit struct { + RPS float64 `yaml:"rps"` + Burst int `yaml:"burst"` + Methods map[string]MethodLimit `yaml:"methods"` +} + +// MetricsConfig holds metrics configuration. +type MetricsConfig struct { + Enabled bool `yaml:"enabled"` + Addr string `yaml:"addr"` + Path string `yaml:"path"` + Namespace string `yaml:"namespace"` + Subsystem string `yaml:"subsystem"` +} + +// LoggingConfig holds logging configuration. +type LoggingConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + Output string `yaml:"output"` +} + +// StorageConfig holds storage configuration. +type StorageConfig struct { + AutoCompact bool `yaml:"auto_compact"` + CompactInterval time.Duration `yaml:"compact_interval"` + MaxEventAge time.Duration `yaml:"max_event_age"` +} + +// Default returns the default configuration. +func Default() *Config { + return &Config{ + Server: ServerConfig{ + GrpcAddr: ":50051", + HttpAddr: ":8080", + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + }, + Database: DatabaseConfig{ + Path: "relay.db", + MaxConnections: 10, + MaxLifetime: 1 * time.Hour, + }, + Auth: AuthConfig{ + Enabled: false, + Required: false, + TimestampWindow: 60, + }, + RateLimit: RateLimitConfig{ + Enabled: false, + DefaultRPS: 10, + DefaultBurst: 20, + IPRPS: 5, + IPBurst: 10, + CleanupInterval: 5 * time.Minute, + MaxIdleTime: 10 * time.Minute, + }, + Metrics: MetricsConfig{ + Enabled: true, + Addr: ":9090", + Path: "/metrics", + Namespace: "muxstr", + Subsystem: "relay", + }, + Logging: LoggingConfig{ + Level: "info", + Format: "json", + Output: "stdout", + }, + Storage: StorageConfig{ + AutoCompact: true, + CompactInterval: 24 * time.Hour, + MaxEventAge: 0, // unlimited + }, + } +} + +// Load loads configuration from a YAML file and applies environment variable overrides. +func Load(filename string) (*Config, error) { + // Start with defaults + cfg := Default() + + // Read file if provided + if filename != "" { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + } + + // Apply environment variable overrides + applyEnvOverrides(cfg) + + // Validate + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return cfg, nil +} + +// Validate validates the configuration. +func (c *Config) Validate() error { + // Validate server addresses + if c.Server.GrpcAddr == "" { + return fmt.Errorf("server.grpc_addr is required") + } + if c.Server.HttpAddr == "" { + return fmt.Errorf("server.http_addr is required") + } + + // Validate database path + if c.Database.Path == "" { + return fmt.Errorf("database.path is required") + } + + // Validate metrics config if enabled + if c.Metrics.Enabled { + if c.Metrics.Addr == "" { + return fmt.Errorf("metrics.addr is required when metrics enabled") + } + if c.Metrics.Namespace == "" { + return fmt.Errorf("metrics.namespace is required when metrics enabled") + } + } + + // Validate logging + validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} + if !validLevels[c.Logging.Level] { + return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level) + } + + validFormats := map[string]bool{"json": true, "text": true} + if !validFormats[c.Logging.Format] { + return fmt.Errorf("invalid logging.format: %s (must be json or text)", c.Logging.Format) + } + + return nil +} + +// applyEnvOverrides applies environment variable overrides to the configuration. +// Environment variables follow the pattern: MUXSTR_
_ +func applyEnvOverrides(cfg *Config) { + // Server + if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" { + cfg.Server.GrpcAddr = val + } + if val := os.Getenv("MUXSTR_SERVER_HTTP_ADDR"); val != "" { + cfg.Server.HttpAddr = val + } + if val := os.Getenv("MUXSTR_SERVER_PUBLIC_URL"); val != "" { + cfg.Server.PublicURL = val + } + if val := os.Getenv("MUXSTR_SERVER_READ_TIMEOUT"); val != "" { + if d, err := time.ParseDuration(val); err == nil { + cfg.Server.ReadTimeout = d + } + } + if val := os.Getenv("MUXSTR_SERVER_WRITE_TIMEOUT"); val != "" { + if d, err := time.ParseDuration(val); err == nil { + cfg.Server.WriteTimeout = d + } + } + + // Database + if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { + cfg.Database.Path = val + } + if val := os.Getenv("MUXSTR_DATABASE_MAX_CONNECTIONS"); val != "" { + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err == nil { + cfg.Database.MaxConnections = n + } + } + + // Auth + if val := os.Getenv("MUXSTR_AUTH_ENABLED"); val != "" { + cfg.Auth.Enabled = parseBool(val) + } + if val := os.Getenv("MUXSTR_AUTH_REQUIRED"); val != "" { + cfg.Auth.Required = parseBool(val) + } + if val := os.Getenv("MUXSTR_AUTH_TIMESTAMP_WINDOW"); val != "" { + var n int64 + if _, err := fmt.Sscanf(val, "%d", &n); err == nil { + cfg.Auth.TimestampWindow = n + } + } + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_PUBKEYS"); val != "" { + cfg.Auth.AllowedPubkeys = strings.Split(val, ",") + } + + // Rate limit + if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { + cfg.RateLimit.Enabled = parseBool(val) + } + if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS"); val != "" { + var n float64 + if _, err := fmt.Sscanf(val, "%f", &n); err == nil { + cfg.RateLimit.DefaultRPS = n + } + } + if val := os.Getenv("MUXSTR_RATE_LIMIT_DEFAULT_BURST"); val != "" { + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err == nil { + cfg.RateLimit.DefaultBurst = n + } + } + + // Metrics + if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" { + cfg.Metrics.Enabled = parseBool(val) + } + if val := os.Getenv("MUXSTR_METRICS_ADDR"); val != "" { + cfg.Metrics.Addr = val + } + if val := os.Getenv("MUXSTR_METRICS_PATH"); val != "" { + cfg.Metrics.Path = val + } + + // Logging + if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" { + cfg.Logging.Level = val + } + if val := os.Getenv("MUXSTR_LOGGING_FORMAT"); val != "" { + cfg.Logging.Format = val + } + if val := os.Getenv("MUXSTR_LOGGING_OUTPUT"); val != "" { + cfg.Logging.Output = val + } +} + +// parseBool parses a boolean from a string. +func parseBool(s string) bool { + s = strings.ToLower(s) + return s == "true" || s == "1" || s == "yes" || s == "on" +} + +// Save saves the configuration to a YAML file. +func (c *Config) Save(filename string) error { + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} -- cgit v1.2.3