diff --git a/README.md b/README.md index f6200fe6..0a7ec9a7 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ P.S. Consider using `-append-instance-role-label` option to easily distinguish m | include-go-runtime-metrics | REDIS_EXPORTER_INCLUDE_GO_RUNTIME_METRICS | Whether to include Go runtime metrics, defaults to false. | | include-config-metrics | REDIS_EXPORTER_INCL_CONFIG_METRICS | Whether to include all config settings as metrics, defaults to false. | | include-system-metrics | REDIS_EXPORTER_INCL_SYSTEM_METRICS | Whether to include system metrics like `total_system_memory_bytes`, defaults to false. | +| include-rdb-file-size-metric | REDIS_EXPORTER_INCL_RDB_FILE_SIZE_METRIC | Whether to include RDB file size metric (requires filesystem access to RDB file), defaults to false. | | include-modules-metrics | REDIS_EXPORTER_INCL_MODULES_METRICS | Whether to collect Redis Modules metrics, defaults to false. | | include-search-indexes-metrics | REDIS_EXPORTER_INCL_SEARCH_INDEXES_METRICS | Whether to collect Redis Search indexes metrics, defaults to false. | | check-search-indexes | REDIS_EXPORTER_CHECK_SEARCH_INDEXES | Regex pattern for Redis Search indexes to export metrics from FT.INFO command, defaults to ".*". | diff --git a/exporter/exporter.go b/exporter/exporter.go index b2baf152..1bb00ca5 100644 --- a/exporter/exporter.go +++ b/exporter/exporter.go @@ -76,6 +76,7 @@ type Options struct { ExcludeLatencyHistogramMetrics bool RedactConfigMetrics bool InclSystemMetrics bool + InclRdbFileSizeMetric bool SkipTLSVerification bool SetClientName bool IsTile38 bool @@ -542,6 +543,7 @@ func NewRedisExporter(uri string, opts Options) (*Exporter, error) { "master_sync_in_progress": {txt: "Master sync in progress", lbls: []string{"master_host", "master_port"}}, "module_info": {txt: "Information about loaded Redis module", lbls: []string{"name", "ver", "api", "filters", "usedby", "using"}}, "number_of_distinct_key_groups": {txt: `Number of distinct key groups`, lbls: []string{"db"}}, + "rdb_current_size_bytes": {txt: "Current RDB file size in bytes"}, "script_result": {txt: "Result of the collect script evaluation", lbls: []string{"filename"}}, "script_values": {txt: "Values returned by the collect script", lbls: []string{"key", "filename"}}, "search_index_num_docs": {txt: "Number of documents in search index", lbls: []string{"index_name"}}, @@ -824,10 +826,12 @@ func (e *Exporter) scrapeRedisHost(ch chan<- prometheus.Metric) error { } dbCount := 0 + var config []interface{} if e.options.ConfigCommandName == "-" { log.Debugf("Skipping extractConfigMetrics()") } else { - if config, err := redis.Values(doRedisCmd(c, e.options.ConfigCommandName, "GET", "*")); err == nil { + if cfg, err := redis.Values(doRedisCmd(c, e.options.ConfigCommandName, "GET", "*")); err == nil { + config = cfg dbCount, err = e.extractConfigMetrics(ch, config) if err != nil { log.Errorf("Redis extractConfigMetrics() err: %s", err) @@ -936,5 +940,9 @@ func (e *Exporter) scrapeRedisHost(ch chan<- prometheus.Metric) error { } } + if e.options.ConfigCommandName != "-" && e.options.InclRdbFileSizeMetric && len(config) > 0 { + e.extractRdbFileSizeMetric(ch, config) + } + return nil } diff --git a/exporter/rdb.go b/exporter/rdb.go new file mode 100644 index 00000000..045b3ce9 --- /dev/null +++ b/exporter/rdb.go @@ -0,0 +1,83 @@ +package exporter + +import ( + "os" + "path/filepath" + "strings" + + "github.com/gomodule/redigo/redis" + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" +) + +func (e *Exporter) extractRdbFileSizeMetric(ch chan<- prometheus.Metric, config []interface{}) { + if len(config) == 0 { + log.Debugf("Config is empty, cannot extract RDB file size") + return + } + + // Validate config has even number of elements (key-value pairs) + if len(config)%2 != 0 { + log.Warnf("Invalid config format: odd number of elements (%d)", len(config)) + return + } + + // Parse config to find dir and dbfilename + configMap := make(map[string]string) + for i := 0; i < len(config); i += 2 { + key, err := redis.String(config[i], nil) + if err != nil { + log.Warnf("Failed to parse config key at index %d: %s", i, err) + continue + } + value, err := redis.String(config[i+1], nil) + if err != nil { + log.Warnf("Failed to parse config value for key '%s': %s", key, err) + continue + } + configMap[key] = value + } + + dir, dirOk := configMap["dir"] + dbfilename, dbfilenameOk := configMap["dbfilename"] + + if !dirOk || !dbfilenameOk { + log.Warnf("Failed to find 'dir' or 'dbfilename' in config") + return + } + + // Basic path validation to prevent directory traversal + if strings.Contains(dbfilename, "..") { + log.Warnf("Invalid dbfilename contains '..': %s", dbfilename) + return + } + + rdbPath := filepath.Join(dir, dbfilename) + log.Debugf("RDB file path: %s", rdbPath) + + fileInfo, err := os.Stat(rdbPath) + if err != nil { + if os.IsNotExist(err) { + log.Debugf("RDB file does not exist: %s", rdbPath) + // File doesn't exist, report 0 + e.registerConstMetricGauge(ch, "rdb_current_size_bytes", 0) + return + } + if os.IsPermission(err) { + log.Warnf("Permission denied accessing RDB file: %s", rdbPath) + return + } + log.Warnf("Failed to stat RDB file %s: %s", rdbPath, err) + return + } + + // Verify it's a regular file + if !fileInfo.Mode().IsRegular() { + log.Warnf("RDB path is not a regular file: %s (mode: %s)", rdbPath, fileInfo.Mode()) + return + } + + fileSize := float64(fileInfo.Size()) + log.Debugf("RDB file size: %d bytes", fileInfo.Size()) + e.registerConstMetricGauge(ch, "rdb_current_size_bytes", fileSize) +} diff --git a/exporter/rdb_test.go b/exporter/rdb_test.go new file mode 100644 index 00000000..d1261708 --- /dev/null +++ b/exporter/rdb_test.go @@ -0,0 +1,377 @@ +package exporter + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func TestExtractRdbFileSizeMetric(t *testing.T) { + if os.Getenv("TEST_REDIS_URI") == "" { + t.Skipf("TEST_REDIS_URI not set - skipping") + } + + addr := os.Getenv("TEST_REDIS_URI") + e, err := NewRedisExporter(addr, Options{ + Namespace: "test", + InclRdbFileSizeMetric: true, + ConfigCommandName: "CONFIG", + }) + if err != nil { + t.Fatalf("NewRedisExporter() failed: %s", err) + } + + ch := make(chan prometheus.Metric, 100) + go func() { + e.Collect(ch) + close(ch) + }() + + found := false + for m := range ch { + if strings.Contains(m.Desc().String(), "rdb_current_size_bytes") { + found = true + d := &dto.Metric{} + if err := m.Write(d); err == nil && d.GetGauge() != nil { + metricValue := d.GetGauge().GetValue() + if metricValue < 0 { + t.Errorf("rdb_current_size_bytes should not be negative, got %f", metricValue) + } + } + break + } + } + + if !found { + t.Error("rdb_current_size_bytes metric should be present") + } +} + +func TestExtractRdbFileSizeMetricDisabled(t *testing.T) { + if os.Getenv("TEST_REDIS_URI") == "" { + t.Skipf("TEST_REDIS_URI not set - skipping") + } + + addr := os.Getenv("TEST_REDIS_URI") + + e, err := NewRedisExporter(addr, Options{ + Namespace: "test", + InclRdbFileSizeMetric: false, + ConfigCommandName: "CONFIG", + }) + if err != nil { + t.Fatalf("NewRedisExporter() failed: %s", err) + } + + ch := make(chan prometheus.Metric, 100) + go func() { + e.Collect(ch) + close(ch) + }() + + for m := range ch { + if strings.Contains(m.Desc().String(), "rdb_current_size_bytes") { + t.Error("rdb_current_size_bytes metric should NOT be present when disabled") + } + } +} + +func TestExtractRdbFileSizeMetricConfigDisabled(t *testing.T) { + if os.Getenv("TEST_REDIS_URI") == "" { + t.Skipf("TEST_REDIS_URI not set - skipping") + } + + addr := os.Getenv("TEST_REDIS_URI") + + e, err := NewRedisExporter(addr, Options{ + Namespace: "test", + InclRdbFileSizeMetric: true, + ConfigCommandName: "-", + }) + if err != nil { + t.Fatalf("NewRedisExporter() failed: %s", err) + } + + ch := make(chan prometheus.Metric, 100) + go func() { + e.Collect(ch) + close(ch) + }() + + for m := range ch { + if strings.Contains(m.Desc().String(), "rdb_current_size_bytes") { + t.Error("rdb_current_size_bytes metric should NOT be present when CONFIG command is disabled") + } + } +} + +func TestExtractRdbFileSizeMetricEmptyConfig(t *testing.T) { + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + e.extractRdbFileSizeMetric(ch, []interface{}{}) + close(ch) + + // Should not produce any metrics for empty config + count := 0 + for range ch { + count++ + } + if count != 0 { + t.Errorf("Expected 0 metrics for empty config, got %d", count) + } +} + +func TestExtractRdbFileSizeMetricOddConfig(t *testing.T) { + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + // Odd number of elements (invalid config) + e.extractRdbFileSizeMetric(ch, []interface{}{"dir", "/tmp", "dbfilename"}) + close(ch) + + // Should not produce any metrics for invalid config + count := 0 + for range ch { + count++ + } + if count != 0 { + t.Errorf("Expected 0 metrics for odd config, got %d", count) + } +} + +func TestExtractRdbFileSizeMetricMissingKeys(t *testing.T) { + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + // Config without dir or dbfilename + e.extractRdbFileSizeMetric(ch, []interface{}{"maxmemory", "1000000", "timeout", "300"}) + close(ch) + + // Should not produce any metrics when required keys are missing + count := 0 + for range ch { + count++ + } + if count != 0 { + t.Errorf("Expected 0 metrics for config without dir/dbfilename, got %d", count) + } +} + +func TestExtractRdbFileSizeMetricNonExistentFile(t *testing.T) { + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + // Point to a non-existent file + e.extractRdbFileSizeMetric(ch, []interface{}{ + "dir", "/tmp/nonexistent_redis_dir_12345", + "dbfilename", "dump.rdb", + }) + close(ch) + + // Should produce a metric with value 0 for non-existent file + found := false + for m := range ch { + found = true + d := &dto.Metric{} + if err := m.Write(d); err == nil && d.GetGauge() != nil { + metricValue := d.GetGauge().GetValue() + if metricValue != 0 { + t.Errorf("Expected metric value 0 for non-existent file, got %f", metricValue) + } + } + } + if !found { + t.Error("Expected metric to be produced for non-existent file") + } +} + +func TestExtractRdbFileSizeMetricValidFile(t *testing.T) { + // Create a temporary RDB file + tmpDir := t.TempDir() + rdbFile := filepath.Join(tmpDir, "dump.rdb") + testData := []byte("test rdb data content") + if err := os.WriteFile(rdbFile, testData, 0644); err != nil { + t.Fatalf("Failed to create test RDB file: %s", err) + } + + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + e.extractRdbFileSizeMetric(ch, []interface{}{ + "dir", tmpDir, + "dbfilename", "dump.rdb", + }) + close(ch) + + // Should produce a metric with the actual file size + found := false + for m := range ch { + found = true + d := &dto.Metric{} + if err := m.Write(d); err == nil && d.GetGauge() != nil { + metricValue := d.GetGauge().GetValue() + expectedSize := float64(len(testData)) + if metricValue != expectedSize { + t.Errorf("Expected metric value %f, got %f", expectedSize, metricValue) + } + } + } + if !found { + t.Error("Expected metric to be produced for valid file") + } +} + +func TestExtractRdbFileSizeMetricPathTraversal(t *testing.T) { + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + // Try path traversal in dbfilename + e.extractRdbFileSizeMetric(ch, []interface{}{ + "dir", "/tmp", + "dbfilename", "../../../etc/passwd", + }) + close(ch) + + // Should not produce any metrics for path traversal attempt + count := 0 + for range ch { + count++ + } + if count != 0 { + t.Errorf("Expected 0 metrics for path traversal attempt, got %d", count) + } +} + +func TestExtractRdbFileSizeMetricDirectory(t *testing.T) { + // Create a temporary directory (not a file) + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "subdir") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %s", err) + } + + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + e.extractRdbFileSizeMetric(ch, []interface{}{ + "dir", tmpDir, + "dbfilename", "subdir", + }) + close(ch) + + // Should not produce any metrics for a directory + count := 0 + for range ch { + count++ + } + if count != 0 { + t.Errorf("Expected 0 metrics for directory, got %d", count) + } +} + +func TestExtractRdbFileSizeMetricLargeFile(t *testing.T) { + // Create a temporary large file (simulate >2GB scenario with smaller size for testing) + tmpDir := t.TempDir() + rdbFile := filepath.Join(tmpDir, "dump.rdb") + + // Create a 10MB file to test large file handling + largeSize := int64(10 * 1024 * 1024) // 10MB + f, err := os.Create(rdbFile) + if err != nil { + t.Fatalf("Failed to create test file: %s", err) + } + if err := f.Truncate(largeSize); err != nil { + f.Close() + t.Fatalf("Failed to truncate file: %s", err) + } + f.Close() + + e := &Exporter{ + options: Options{ + Namespace: "test", + }, + metricDescriptions: map[string]*prometheus.Desc{ + "rdb_current_size_bytes": newMetricDescr("test", "rdb_current_size_bytes", "test metric", nil), + }, + } + + ch := make(chan prometheus.Metric, 10) + e.extractRdbFileSizeMetric(ch, []interface{}{ + "dir", tmpDir, + "dbfilename", "dump.rdb", + }) + close(ch) + + // Should produce a metric with the correct large file size + found := false + for m := range ch { + found = true + d := &dto.Metric{} + if err := m.Write(d); err == nil && d.GetGauge() != nil { + metricValue := d.GetGauge().GetValue() + expectedSize := float64(largeSize) + if metricValue != expectedSize { + t.Errorf("Expected metric value %f, got %f", expectedSize, metricValue) + } + } + } + if !found { + t.Error("Expected metric to be produced for large file") + } +} + +// Made with Bob diff --git a/main.go b/main.go index 8f2fb120..e35f4cde 100644 --- a/main.go +++ b/main.go @@ -203,6 +203,7 @@ func main() { excludeLatencyHistogramMetrics = flag.Bool("exclude-latency-histogram-metrics", getEnvBool("REDIS_EXPORTER_EXCLUDE_LATENCY_HISTOGRAM_METRICS", false), "Do not try to collect latency histogram metrics") redactConfigMetrics = flag.Bool("redact-config-metrics", getEnvBool("REDIS_EXPORTER_REDACT_CONFIG_METRICS", true), "Whether to redact config settings that include potentially sensitive information like passwords") inclSystemMetrics = flag.Bool("include-system-metrics", getEnvBool("REDIS_EXPORTER_INCL_SYSTEM_METRICS", false), "Whether to include system metrics like e.g. redis_total_system_memory_bytes") + inclRdbFileSizeMetric = flag.Bool("include-rdb-file-size-metric", getEnvBool("REDIS_EXPORTER_INCL_RDB_FILE_SIZE_METRIC", false), "Whether to include RDB file size metric. Note: Requires the exporter to run on the same host as Redis with read access to the RDB directory. Will not work in remote or containerized deployments where the RDB file is not accessible.") skipTLSVerification = flag.Bool("skip-tls-verification", getEnvBool("REDIS_EXPORTER_SKIP_TLS_VERIFICATION", false), "Whether to to skip TLS verification") skipCheckKeysForRoleMaster = flag.Bool("skip-checkkeys-for-role-master", getEnvBool("REDIS_EXPORTER_SKIP_CHECKKEYS_FOR_ROLE_MASTER", false), "Whether to skip gathering the check-keys metrics (size, val) when the instance is of type master (reduce load on master nodes)") basicAuthUsername = flag.String("basic-auth-username", getEnv("REDIS_EXPORTER_BASIC_AUTH_USERNAME", ""), "Username for basic authentication") @@ -275,6 +276,7 @@ func main() { LuaScript: ls, LuaScriptReadOnly: *luaScriptReadOnly, InclSystemMetrics: *inclSystemMetrics, + InclRdbFileSizeMetric: *inclRdbFileSizeMetric, InclConfigMetrics: *inclConfigMetrics, DisableExportingKeyValues: *disableExportingKeyValues, ExcludeLatencyHistogramMetrics: *excludeLatencyHistogramMetrics,