diff options
| author | bndw <ben@bdw.to> | 2026-02-14 09:41:18 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 09:41:18 -0800 |
| commit | 688548d4ac3293449a88913275f886fd2e103cdf (patch) | |
| tree | 5bf83c9a9b50863b6201ebf5066ee6855fefe725 /internal/config/config_test.go | |
| parent | f0169fa1f9d2e2a5d1c292b9080da10ef0878953 (diff) | |
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_<SECTION>_<KEY>)
- 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.
Diffstat (limited to 'internal/config/config_test.go')
| -rw-r--r-- | internal/config/config_test.go | 288 |
1 files changed, 288 insertions, 0 deletions
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 @@ | |||
| 1 | package config | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestDefault(t *testing.T) { | ||
| 10 | cfg := Default() | ||
| 11 | |||
| 12 | if cfg.Server.GrpcAddr != ":50051" { | ||
| 13 | t.Errorf("expected default grpc_addr :50051, got %s", cfg.Server.GrpcAddr) | ||
| 14 | } | ||
| 15 | |||
| 16 | if cfg.Database.Path != "relay.db" { | ||
| 17 | t.Errorf("expected default db path relay.db, got %s", cfg.Database.Path) | ||
| 18 | } | ||
| 19 | |||
| 20 | if cfg.Metrics.Enabled != true { | ||
| 21 | t.Error("expected metrics enabled by default") | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | func TestLoadYAML(t *testing.T) { | ||
| 26 | // Create temporary config file | ||
| 27 | tmpfile, err := os.CreateTemp("", "config-*.yaml") | ||
| 28 | if err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | defer os.Remove(tmpfile.Name()) | ||
| 32 | |||
| 33 | configData := ` | ||
| 34 | server: | ||
| 35 | grpc_addr: ":9999" | ||
| 36 | http_addr: ":8888" | ||
| 37 | |||
| 38 | database: | ||
| 39 | path: "test.db" | ||
| 40 | |||
| 41 | auth: | ||
| 42 | enabled: true | ||
| 43 | required: true | ||
| 44 | timestamp_window: 120 | ||
| 45 | |||
| 46 | rate_limit: | ||
| 47 | enabled: true | ||
| 48 | default_rps: 50 | ||
| 49 | default_burst: 100 | ||
| 50 | |||
| 51 | metrics: | ||
| 52 | enabled: true | ||
| 53 | addr: ":9191" | ||
| 54 | namespace: "test" | ||
| 55 | ` | ||
| 56 | |||
| 57 | if _, err := tmpfile.Write([]byte(configData)); err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | tmpfile.Close() | ||
| 61 | |||
| 62 | // Load config | ||
| 63 | cfg, err := Load(tmpfile.Name()) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatalf("failed to load config: %v", err) | ||
| 66 | } | ||
| 67 | |||
| 68 | // Verify values | ||
| 69 | if cfg.Server.GrpcAddr != ":9999" { | ||
| 70 | t.Errorf("expected grpc_addr :9999, got %s", cfg.Server.GrpcAddr) | ||
| 71 | } | ||
| 72 | |||
| 73 | if cfg.Database.Path != "test.db" { | ||
| 74 | t.Errorf("expected db path test.db, got %s", cfg.Database.Path) | ||
| 75 | } | ||
| 76 | |||
| 77 | if !cfg.Auth.Enabled { | ||
| 78 | t.Error("expected auth enabled") | ||
| 79 | } | ||
| 80 | |||
| 81 | if !cfg.Auth.Required { | ||
| 82 | t.Error("expected auth required") | ||
| 83 | } | ||
| 84 | |||
| 85 | if cfg.Auth.TimestampWindow != 120 { | ||
| 86 | t.Errorf("expected timestamp window 120, got %d", cfg.Auth.TimestampWindow) | ||
| 87 | } | ||
| 88 | |||
| 89 | if cfg.RateLimit.DefaultRPS != 50 { | ||
| 90 | t.Errorf("expected rate limit 50, got %.1f", cfg.RateLimit.DefaultRPS) | ||
| 91 | } | ||
| 92 | |||
| 93 | if cfg.Metrics.Namespace != "test" { | ||
| 94 | t.Errorf("expected metrics namespace test, got %s", cfg.Metrics.Namespace) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | func TestEnvOverrides(t *testing.T) { | ||
| 99 | // Set environment variables | ||
| 100 | os.Setenv("MUXSTR_SERVER_GRPC_ADDR", ":7777") | ||
| 101 | os.Setenv("MUXSTR_AUTH_ENABLED", "true") | ||
| 102 | os.Setenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS", "200") | ||
| 103 | defer func() { | ||
| 104 | os.Unsetenv("MUXSTR_SERVER_GRPC_ADDR") | ||
| 105 | os.Unsetenv("MUXSTR_AUTH_ENABLED") | ||
| 106 | os.Unsetenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS") | ||
| 107 | }() | ||
| 108 | |||
| 109 | // Load with empty file (just defaults + env) | ||
| 110 | cfg, err := Load("") | ||
| 111 | if err != nil { | ||
| 112 | t.Fatalf("failed to load config: %v", err) | ||
| 113 | } | ||
| 114 | |||
| 115 | // Verify env overrides | ||
| 116 | if cfg.Server.GrpcAddr != ":7777" { | ||
| 117 | t.Errorf("expected env override :7777, got %s", cfg.Server.GrpcAddr) | ||
| 118 | } | ||
| 119 | |||
| 120 | if !cfg.Auth.Enabled { | ||
| 121 | t.Error("expected auth enabled from env") | ||
| 122 | } | ||
| 123 | |||
| 124 | if cfg.RateLimit.DefaultRPS != 200 { | ||
| 125 | t.Errorf("expected rate limit 200 from env, got %.1f", cfg.RateLimit.DefaultRPS) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | func TestValidation(t *testing.T) { | ||
| 130 | tests := []struct { | ||
| 131 | name string | ||
| 132 | cfg *Config | ||
| 133 | wantErr bool | ||
| 134 | }{ | ||
| 135 | { | ||
| 136 | name: "valid default config", | ||
| 137 | cfg: Default(), | ||
| 138 | wantErr: false, | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | name: "missing grpc_addr", | ||
| 142 | cfg: &Config{ | ||
| 143 | Server: ServerConfig{ | ||
| 144 | HttpAddr: ":8080", | ||
| 145 | }, | ||
| 146 | Database: DatabaseConfig{ | ||
| 147 | Path: "test.db", | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | wantErr: true, | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | name: "missing http_addr", | ||
| 154 | cfg: &Config{ | ||
| 155 | Server: ServerConfig{ | ||
| 156 | GrpcAddr: ":50051", | ||
| 157 | }, | ||
| 158 | Database: DatabaseConfig{ | ||
| 159 | Path: "test.db", | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | wantErr: true, | ||
| 163 | }, | ||
| 164 | { | ||
| 165 | name: "missing database path", | ||
| 166 | cfg: &Config{ | ||
| 167 | Server: ServerConfig{ | ||
| 168 | GrpcAddr: ":50051", | ||
| 169 | HttpAddr: ":8080", | ||
| 170 | }, | ||
| 171 | Database: DatabaseConfig{}, | ||
| 172 | }, | ||
| 173 | wantErr: true, | ||
| 174 | }, | ||
| 175 | { | ||
| 176 | name: "invalid log level", | ||
| 177 | cfg: &Config{ | ||
| 178 | Server: ServerConfig{ | ||
| 179 | GrpcAddr: ":50051", | ||
| 180 | HttpAddr: ":8080", | ||
| 181 | }, | ||
| 182 | Database: DatabaseConfig{ | ||
| 183 | Path: "test.db", | ||
| 184 | }, | ||
| 185 | Logging: LoggingConfig{ | ||
| 186 | Level: "invalid", | ||
| 187 | Format: "json", | ||
| 188 | }, | ||
| 189 | }, | ||
| 190 | wantErr: true, | ||
| 191 | }, | ||
| 192 | } | ||
| 193 | |||
| 194 | for _, tt := range tests { | ||
| 195 | t.Run(tt.name, func(t *testing.T) { | ||
| 196 | err := tt.cfg.Validate() | ||
| 197 | if (err != nil) != tt.wantErr { | ||
| 198 | t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) | ||
| 199 | } | ||
| 200 | }) | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | func TestSaveAndLoad(t *testing.T) { | ||
| 205 | // Create config | ||
| 206 | cfg := Default() | ||
| 207 | cfg.Server.GrpcAddr = ":9999" | ||
| 208 | cfg.Auth.Enabled = true | ||
| 209 | cfg.RateLimit.DefaultRPS = 100 | ||
| 210 | |||
| 211 | // Save to temp file | ||
| 212 | tmpfile, err := os.CreateTemp("", "config-*.yaml") | ||
| 213 | if err != nil { | ||
| 214 | t.Fatal(err) | ||
| 215 | } | ||
| 216 | defer os.Remove(tmpfile.Name()) | ||
| 217 | tmpfile.Close() | ||
| 218 | |||
| 219 | if err := cfg.Save(tmpfile.Name()); err != nil { | ||
| 220 | t.Fatalf("failed to save config: %v", err) | ||
| 221 | } | ||
| 222 | |||
| 223 | // Load it back | ||
| 224 | loaded, err := Load(tmpfile.Name()) | ||
| 225 | if err != nil { | ||
| 226 | t.Fatalf("failed to load config: %v", err) | ||
| 227 | } | ||
| 228 | |||
| 229 | // Verify | ||
| 230 | if loaded.Server.GrpcAddr != ":9999" { | ||
| 231 | t.Errorf("expected grpc_addr :9999, got %s", loaded.Server.GrpcAddr) | ||
| 232 | } | ||
| 233 | |||
| 234 | if !loaded.Auth.Enabled { | ||
| 235 | t.Error("expected auth enabled") | ||
| 236 | } | ||
| 237 | |||
| 238 | if loaded.RateLimit.DefaultRPS != 100 { | ||
| 239 | t.Errorf("expected rate limit 100, got %.1f", loaded.RateLimit.DefaultRPS) | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | func TestDurationParsing(t *testing.T) { | ||
| 244 | // Create config with durations | ||
| 245 | tmpfile, err := os.CreateTemp("", "config-*.yaml") | ||
| 246 | if err != nil { | ||
| 247 | t.Fatal(err) | ||
| 248 | } | ||
| 249 | defer os.Remove(tmpfile.Name()) | ||
| 250 | |||
| 251 | configData := ` | ||
| 252 | server: | ||
| 253 | grpc_addr: ":50051" | ||
| 254 | http_addr: ":8080" | ||
| 255 | read_timeout: "1m" | ||
| 256 | write_timeout: "2m" | ||
| 257 | |||
| 258 | database: | ||
| 259 | path: "test.db" | ||
| 260 | max_lifetime: "30m" | ||
| 261 | |||
| 262 | rate_limit: | ||
| 263 | cleanup_interval: "10m" | ||
| 264 | max_idle_time: "20m" | ||
| 265 | ` | ||
| 266 | |||
| 267 | if _, err := tmpfile.Write([]byte(configData)); err != nil { | ||
| 268 | t.Fatal(err) | ||
| 269 | } | ||
| 270 | tmpfile.Close() | ||
| 271 | |||
| 272 | cfg, err := Load(tmpfile.Name()) | ||
| 273 | if err != nil { | ||
| 274 | t.Fatalf("failed to load config: %v", err) | ||
| 275 | } | ||
| 276 | |||
| 277 | if cfg.Server.ReadTimeout != 1*time.Minute { | ||
| 278 | t.Errorf("expected read timeout 1m, got %v", cfg.Server.ReadTimeout) | ||
| 279 | } | ||
| 280 | |||
| 281 | if cfg.Server.WriteTimeout != 2*time.Minute { | ||
| 282 | t.Errorf("expected write timeout 2m, got %v", cfg.Server.WriteTimeout) | ||
| 283 | } | ||
| 284 | |||
| 285 | if cfg.Database.MaxLifetime != 30*time.Minute { | ||
| 286 | t.Errorf("expected max lifetime 30m, got %v", cfg.Database.MaxLifetime) | ||
| 287 | } | ||
| 288 | } | ||
