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_test.go | 288 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 internal/config/config_test.go (limited to 'internal/config/config_test.go') diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..50d9b67 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,288 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestDefault(t *testing.T) { + cfg := Default() + + if cfg.Server.GrpcAddr != ":50051" { + t.Errorf("expected default grpc_addr :50051, got %s", cfg.Server.GrpcAddr) + } + + if cfg.Database.Path != "relay.db" { + t.Errorf("expected default db path relay.db, got %s", cfg.Database.Path) + } + + if cfg.Metrics.Enabled != true { + t.Error("expected metrics enabled by default") + } +} + +func TestLoadYAML(t *testing.T) { + // Create temporary config file + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + configData := ` +server: + grpc_addr: ":9999" + http_addr: ":8888" + +database: + path: "test.db" + +auth: + enabled: true + required: true + timestamp_window: 120 + +rate_limit: + enabled: true + default_rps: 50 + default_burst: 100 + +metrics: + enabled: true + addr: ":9191" + namespace: "test" +` + + if _, err := tmpfile.Write([]byte(configData)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + // Load config + cfg, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify values + if cfg.Server.GrpcAddr != ":9999" { + t.Errorf("expected grpc_addr :9999, got %s", cfg.Server.GrpcAddr) + } + + if cfg.Database.Path != "test.db" { + t.Errorf("expected db path test.db, got %s", cfg.Database.Path) + } + + if !cfg.Auth.Enabled { + t.Error("expected auth enabled") + } + + if !cfg.Auth.Required { + t.Error("expected auth required") + } + + if cfg.Auth.TimestampWindow != 120 { + t.Errorf("expected timestamp window 120, got %d", cfg.Auth.TimestampWindow) + } + + if cfg.RateLimit.DefaultRPS != 50 { + t.Errorf("expected rate limit 50, got %.1f", cfg.RateLimit.DefaultRPS) + } + + if cfg.Metrics.Namespace != "test" { + t.Errorf("expected metrics namespace test, got %s", cfg.Metrics.Namespace) + } +} + +func TestEnvOverrides(t *testing.T) { + // Set environment variables + os.Setenv("MUXSTR_SERVER_GRPC_ADDR", ":7777") + os.Setenv("MUXSTR_AUTH_ENABLED", "true") + os.Setenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS", "200") + defer func() { + os.Unsetenv("MUXSTR_SERVER_GRPC_ADDR") + os.Unsetenv("MUXSTR_AUTH_ENABLED") + os.Unsetenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS") + }() + + // Load with empty file (just defaults + env) + cfg, err := Load("") + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify env overrides + if cfg.Server.GrpcAddr != ":7777" { + t.Errorf("expected env override :7777, got %s", cfg.Server.GrpcAddr) + } + + if !cfg.Auth.Enabled { + t.Error("expected auth enabled from env") + } + + if cfg.RateLimit.DefaultRPS != 200 { + t.Errorf("expected rate limit 200 from env, got %.1f", cfg.RateLimit.DefaultRPS) + } +} + +func TestValidation(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + }{ + { + name: "valid default config", + cfg: Default(), + wantErr: false, + }, + { + name: "missing grpc_addr", + cfg: &Config{ + Server: ServerConfig{ + HttpAddr: ":8080", + }, + Database: DatabaseConfig{ + Path: "test.db", + }, + }, + wantErr: true, + }, + { + name: "missing http_addr", + cfg: &Config{ + Server: ServerConfig{ + GrpcAddr: ":50051", + }, + Database: DatabaseConfig{ + Path: "test.db", + }, + }, + wantErr: true, + }, + { + name: "missing database path", + cfg: &Config{ + Server: ServerConfig{ + GrpcAddr: ":50051", + HttpAddr: ":8080", + }, + Database: DatabaseConfig{}, + }, + wantErr: true, + }, + { + name: "invalid log level", + cfg: &Config{ + Server: ServerConfig{ + GrpcAddr: ":50051", + HttpAddr: ":8080", + }, + Database: DatabaseConfig{ + Path: "test.db", + }, + Logging: LoggingConfig{ + Level: "invalid", + Format: "json", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSaveAndLoad(t *testing.T) { + // Create config + cfg := Default() + cfg.Server.GrpcAddr = ":9999" + cfg.Auth.Enabled = true + cfg.RateLimit.DefaultRPS = 100 + + // Save to temp file + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + tmpfile.Close() + + if err := cfg.Save(tmpfile.Name()); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + // Load it back + loaded, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify + if loaded.Server.GrpcAddr != ":9999" { + t.Errorf("expected grpc_addr :9999, got %s", loaded.Server.GrpcAddr) + } + + if !loaded.Auth.Enabled { + t.Error("expected auth enabled") + } + + if loaded.RateLimit.DefaultRPS != 100 { + t.Errorf("expected rate limit 100, got %.1f", loaded.RateLimit.DefaultRPS) + } +} + +func TestDurationParsing(t *testing.T) { + // Create config with durations + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + configData := ` +server: + grpc_addr: ":50051" + http_addr: ":8080" + read_timeout: "1m" + write_timeout: "2m" + +database: + path: "test.db" + max_lifetime: "30m" + +rate_limit: + cleanup_interval: "10m" + max_idle_time: "20m" +` + + if _, err := tmpfile.Write([]byte(configData)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + cfg, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + if cfg.Server.ReadTimeout != 1*time.Minute { + t.Errorf("expected read timeout 1m, got %v", cfg.Server.ReadTimeout) + } + + if cfg.Server.WriteTimeout != 2*time.Minute { + t.Errorf("expected write timeout 2m, got %v", cfg.Server.WriteTimeout) + } + + if cfg.Database.MaxLifetime != 30*time.Minute { + t.Errorf("expected max lifetime 30m, got %v", cfg.Database.MaxLifetime) + } +} -- cgit v1.2.3