From 25cec502f22101bf20b94c91af74d243bbe8fafa Mon Sep 17 00:00:00 2001 From: kvisidisi Date: Thu, 17 Jul 2025 00:03:30 +0300 Subject: [PATCH 1/3] feat: hot reload --- cmd/exporter/main.go | 15 ++++++++++- internal/collector/collector.go | 45 ++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cmd/exporter/main.go b/cmd/exporter/main.go index b71c1b3..7514786 100644 --- a/cmd/exporter/main.go +++ b/cmd/exporter/main.go @@ -59,7 +59,7 @@ func main() { exec := &executor.BashExecutor{} - metricsCollector := collector.NewCollector(&cfg, slog.Default(), exec, cache) + metricsCollector := collector.NewCollector(&cfg, slog.Default(), exec, cache, configPath) registry := prometheus.NewRegistry() registry.MustRegister(metricsCollector) @@ -90,6 +90,19 @@ func main() { } }() + hReload := make(chan os.Signal, 1) + signal.Notify(hReload, syscall.SIGHUP) + + go func() { + for { + <-hReload + slog.Info("received SIGHUP, attempting to reload config") + if err := metricsCollector.ReloadConfig(); err != nil { + slog.Error("config reload failed", "error", err) + } + } + }() + quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/collector/collector.go b/internal/collector/collector.go index f9d87d1..3d2d5d3 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -15,22 +15,29 @@ type Executor interface { } type Collector struct { - config *config.Config - logger *slog.Logger - executor Executor - cache *cache.Cache + config *config.Config + logger *slog.Logger + executor Executor + cache *cache.Cache + configPath string + + mu sync.RWMutex } -func NewCollector(cfg *config.Config, logger *slog.Logger, exec Executor, cache *cache.Cache) *Collector { +func NewCollector(cfg *config.Config, logger *slog.Logger, exec Executor, cache *cache.Cache, configPath string) *Collector { return &Collector{ - config: cfg, - logger: logger, - executor: exec, - cache: cache, + config: cfg, + logger: logger, + executor: exec, + cache: cache, + configPath: configPath, } } func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + c.mu.RLock() + defer c.mu.RUnlock() + for _, metricConfig := range c.config.Metrics { if len(metricConfig.SubMetrics) == 0 { dynLblNames := getLabelNames(metricConfig.DynamicLabels) @@ -65,7 +72,27 @@ func (c *Collector) Describe(ch chan<- *prometheus.Desc) { } +func (c *Collector) ReloadConfig() error { + var newCfg config.Config + + if err := config.Load(c.configPath, &newCfg); err != nil { + c.logger.Error("failed to reload config", "error", err) + return err + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.config = &newCfg + + c.logger.Info("config reloaded successfully") + return nil +} + func (c *Collector) Collect(ch chan<- prometheus.Metric) { + c.mu.RLock() + defer c.mu.RUnlock() + start := time.Now() defer func() { From ff1be88fe44d9487853f96ff8230825955a388f2 Mon Sep 17 00:00:00 2001 From: kvisidisi Date: Thu, 17 Jul 2025 00:05:12 +0300 Subject: [PATCH 2/3] fix: colletor test add configPath to Collector constructor call --- internal/collector/collector_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 7deeeda..68aef9c 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -396,7 +396,7 @@ not_blacklisted_metric 1 for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - collector := NewCollector(tc.config, logger, tc.executor, cache.New()) + collector := NewCollector(tc.config, logger, tc.executor, cache.New(), "") reg := prometheus.NewRegistry() reg.MustRegister(collector) @@ -610,7 +610,7 @@ func TestInternalMetrics(t *testing.T) { err: errors.New("error"), } - collector := NewCollector(cfg, logger, executor, cache) + collector := NewCollector(cfg, logger, executor, cache, "") ch := make(chan prometheus.Metric, 10) go func() { From 48f7ce82085bc2863748388d83df1d1773c49346 Mon Sep 17 00:00:00 2001 From: kvisidisi Date: Thu, 17 Jul 2025 00:23:08 +0300 Subject: [PATCH 3/3] test: hot reload test --- internal/collector/collector_test.go | 71 +++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 68aef9c..09df3fa 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -6,6 +6,7 @@ import ( "github.com/prometheus/client_golang/prometheus/testutil" "io" "log/slog" + "os" "pg-bash-exporter/internal/cache" "pg-bash-exporter/internal/config" "strings" @@ -238,7 +239,7 @@ matched_metric_mem{label_name="label2"} 200 Name: "connections", Help: "number of connetions.", Type: "gauge", - Command: "echo -e 'tcp 150\nudp 25", + Command: "echo -e 'tcp 150\nudp 25'", Field: 1, DynamicLabels: []config.DynamicLabel{ {Name: "type", Field: 0}, @@ -649,3 +650,71 @@ func TestInternalMetrics(t *testing.T) { t.Errorf("CacheMisses: wnted 2, got %v", val) } } + +func TestReloadConfig(t *testing.T) { + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + configV1 := ` +server: + listen_address: ":8080" + metrics_path: "/metrics" +logging: + level: "info" +metrics: + - name: "metric_v1" + help: "help v1" + type: "gauge" + command: "echo 1" +` + configV2 := ` +server: + listen_address: ":8080" + metrics_path: "/metrics" +logging: + level: "info" +metrics: + - name: "metric_v2" + help: "help v2" + type: "counter" + command: "echo 2" +` + + tmpfile, err := os.CreateTemp(t.TempDir(), "test-config-*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write([]byte(configV1)); err != nil { + t.Fatalf("failed to write v1 config: %v", err) + } + + var cfg config.Config + if err := config.Load(tmpfile.Name(), &cfg); err != nil { + t.Fatalf("failed to load v1 config: %v", err) + } + + collector := NewCollector(&cfg, logger, &mockExecutor{}, cache.New(), tmpfile.Name()) + + if collector.config.Metrics[0].Name != "metric_v1" { + t.Fatalf("expected initial metric to be metric_v1, got %s", collector.config.Metrics[0].Name) + } + + if err := os.WriteFile(tmpfile.Name(), []byte(configV2), 0644); err != nil { + t.Fatalf("failed to write v2 config: %v", err) + } + + if err := collector.ReloadConfig(); err != nil { + t.Fatalf("reload failed: %v", err) + } + + if len(collector.config.Metrics) != 1 { + t.Fatalf("expected 1 metric after reload, got %d", len(collector.config.Metrics)) + } + if collector.config.Metrics[0].Name != "metric_v2" { + t.Errorf("expected metric_v2 after reload, got %s", collector.config.Metrics[0].Name) + } + if collector.config.Metrics[0].Type != "counter" { + t.Errorf("expected counter type after reload, got %s", collector.config.Metrics[0].Type) + } +}