Repository: oliver006/redis_exporter
Pull request #989: Add discovery endpoint for Redis cluster
Author: login: steveteuber
Created: 2025-03-27T14:16:40Z | Merged: 2025-04-10T04:31:42Z

Add discovery endpoint that can be used to discover all nodes in a Redis cluster.


Diff:
diff --git a/README.md b/README.md
index 2dbcf256..3ce0cd48 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,33 @@ The `targets-redis-instances.json` should look something like this:
 
 Prometheus uses file watches and all changes to the json file are applied immediately.
 
+### Prometheus Configuration to Scrape All Nodes in a Redis Cluster
+
+When using a Redis Cluster, the exporter provides a discovery endpoint that can be used to discover all nodes in the cluster.
+To use this feature, the exporter must be started with the `--is-cluster` flag.\
+The discovery endpoint is available at `/discover-cluster-nodes` and can be used in the Prometheus configuration like this:
+
+```yaml
+scrape_configs:
+  - job_name: 'redis_exporter_cluster_nodes'
+    http_sd_configs:
+      - url: http://<<REDIS-EXPORTER-HOSTNAME>>:9121/discover-cluster-nodes
+        refresh_interval: 10m
+    metrics_path: /scrape
+    relabel_configs:
+      - source_labels: [__address__]
+        target_label: __param_target
+      - source_labels: [__param_target]
+        target_label: instance
+      - target_label: __address__
+        replacement: <<REDIS-EXPORTER-HOSTNAME>>:9121
+
+  ## config for scraping the exporter itself
+  - job_name: 'redis_exporter'
+    static_configs:
+      - targets:
+        - <<REDIS-EXPORTER-HOSTNAME>>:9121
+```
 
 ### Command line flags
 
@@ -207,7 +234,7 @@ Alternatively, you can provide the username and/or password using the `--redis.u
 If you want to use a dedicated Redis user for the redis_exporter (instead of the default user) then you need enable a list of commands for that user.
 You can use the following Redis command to set up the user, just replace `<<<USERNAME>>>` and `<<<PASSWORD>>>` with your desired values.
 ```
-ACL SETUSER <<<USERNAME>>> -@all +@connection +memory -readonly +strlen +config|get +xinfo +pfcount -quit +zcard +type +xlen -readwrite -command +client -wait +scard +llen +hlen +get +eval +slowlog +cluster|info -hello -echo +info +latency +scan -reset -auth -asking ><<<PASSWORD>>>
+ACL SETUSER <<<USERNAME>>> -@all +@connection +memory -readonly +strlen +config|get +xinfo +pfcount -quit +zcard +type +xlen -readwrite -command +client -wait +scard +llen +hlen +get +eval +slowlog +cluster|info +cluster|slots +cluster|nodes -hello -echo +info +latency +scan -reset -auth -asking ><<<PASSWORD>>>
 ```
 
 For monitoring a Sentinel-node you may use the following command with the right ACL:
diff --git a/exporter/exporter.go b/exporter/exporter.go
index f19757b8..60d37fde 100644
--- a/exporter/exporter.go
+++ b/exporter/exporter.go
@@ -509,6 +509,7 @@ func NewRedisExporter(uri string, opts Options) (*Exporter, error) {
 
 	e.mux.HandleFunc("/", e.indexHandler)
 	e.mux.HandleFunc("/scrape", e.scrapeHandler)
+	e.mux.HandleFunc("/discover-cluster-nodes", e.discoverClusterNodesHandler)
 	e.mux.HandleFunc("/health", e.healthHandler)
 	e.mux.HandleFunc("/-/reload", e.reloadPwdFile)
 
diff --git a/exporter/http.go b/exporter/http.go
index 3ee53bc4..97d321d5 100644
--- a/exporter/http.go
+++ b/exporter/http.go
@@ -2,6 +2,7 @@ package exporter
 
 import (
 	"crypto/subtle"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
@@ -103,6 +104,54 @@ func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) {
 	).ServeHTTP(w, r)
 }
 
+func (e *Exporter) discoverClusterNodesHandler(w http.ResponseWriter, r *http.Request) {
+	if !e.options.IsCluster {
+		http.Error(w, "The discovery endpoint is only available on a redis cluster", http.StatusBadRequest)
+		return
+	}
+
+	c, err := e.connectToRedisCluster()
+	if err != nil {
+		http.Error(w, "Couldn't connect to redis cluster", http.StatusInternalServerError)
+		return
+	}
+	defer c.Close()
+
+	nodes, err := e.getClusterNodes(c)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to fetch cluster nodes: %s", err), http.StatusInternalServerError)
+		return
+	}
+
+	discovery := []struct {
+		Targets []string          `json:"targets"`
+		Labels  map[string]string `json:"labels"`
+	}{
+		{
+			Targets: make([]string, len(nodes)),
+			Labels:  make(map[string]string, 0),
+		},
+	}
+
+	isTls := strings.HasPrefix(e.redisAddr, "rediss://")
+	for i, node := range nodes {
+		if isTls {
+			discovery[0].Targets[i] = "rediss://" + node
+		} else {
+			discovery[0].Targets[i] = "redis://" + node
+		}
+	}
+
+	data, err := json.MarshalIndent(discovery, "", "  ")
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to marshal discovery data: %s", err), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(data)
+}
+
 func (e *Exporter) reloadPwdFile(w http.ResponseWriter, r *http.Request) {
 	if e.options.RedisPwdFile == "" {
 		http.Error(w, "There is no pwd file specified", http.StatusBadRequest)
diff --git a/exporter/http_test.go b/exporter/http_test.go
index fb2ff3b3..ebe18ee8 100644
--- a/exporter/http_test.go
+++ b/exporter/http_test.go
@@ -321,6 +321,56 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoverClusterNodesHandlers(t *testing.T) {
+	if os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") == "" {
+		t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI not set - skipping")
+	}
+
+	tests := []struct {
+		path      string
+		want      string
+		isCluster bool
+	}{
+		{
+			path:      "/discover-cluster-nodes",
+			want:      "redis://127.0.0.1:7000",
+			isCluster: true,
+		},
+		{
+			path:      "/discover-cluster-nodes",
+			want:      "redis://127.0.0.1:7001",
+			isCluster: true,
+		},
+		{
+			path:      "/discover-cluster-nodes",
+			want:      "redis://127.0.0.1:7002",
+			isCluster: true,
+		},
+		{
+			path:      "/discover-cluster-nodes",
+			want:      "The discovery endpoint is only available on a redis cluster",
+			isCluster: false,
+		},
+	}
+
+	for _, tst := range tests {
+		t.Run(fmt.Sprintf("path: %s, isCluster: %v", tst.path, tst.isCluster), func(t *testing.T) {
+			e, _ := NewRedisExporter(os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI"), Options{
+				Namespace: "test",
+				Registry:  prometheus.NewRegistry(),
+				IsCluster: tst.isCluster,
+			})
+			ts := httptest.NewServer(e)
+			defer ts.Close()
+
+			body := downloadURL(t, ts.URL+tst.path)
+			if !strings.Contains(body, tst.want) {
+				t.Fatalf(`error, expected string "%s" in body, got body: \n\n%s`, tst.want, body)
+			}
+		})
+	}
+}
+
 func TestReloadHandlers(t *testing.T) {
 	if os.Getenv("TEST_PWD_REDIS_URI") == "" {
 		t.Skipf("TEST_PWD_REDIS_URI not set - skipping")
diff --git a/exporter/nodes.go b/exporter/nodes.go
new file mode 100644
index 00000000..b5562657
--- /dev/null
+++ b/exporter/nodes.go
@@ -0,0 +1,52 @@
+package exporter
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
+
+func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) {
+	output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES"))
+	if err != nil {
+		log.Errorf("Error getting cluster nodes: %s", err)
+		return nil, err
+	}
+
+	lines := strings.Split(output, "\n")
+	nodes := []string{}
+
+	for _, line := range lines {
+		if node, ok := parseClusterNodeString(line); ok {
+			nodes = append(nodes, node)
+		}
+	}
+
+	return nodes, nil
+}
+
+/*
+<id> <ip:port@cport[,hostname]> <flags> <master> <ping-sent> <pong-recv> <config-epoch> <link-state> <slot> <slot> ... <slot>
+eaf69c70d876558a948ba62af0884a37d42c9627 127.0.0.1:7002@17002 master - 0 1742836359057 3 connected 10923-16383
+*/
+func parseClusterNodeString(node string) (string, bool) {
+	log.Debugf("parseClusterNodeString node: [%s]", node)
+
+	fields := strings.Fields(node)
+	if len(fields) < 2 {
+		log.Debugf("Invalid field count for node: %s", node)
+		return "", false
+	}
+
+	address := reNodeAddress.FindStringSubmatch(fields[1])
+	if len(address) < 3 {
+		log.Debugf("Invalid format for node address, got: %s", fields[1])
+		return "", false
+	}
+
+	return address[1] + ":" + address[2], true
+}
diff --git a/exporter/nodes_test.go b/exporter/nodes_test.go
new file mode 100644
index 00000000..5f3adb71
--- /dev/null
+++ b/exporter/nodes_test.go
@@ -0,0 +1,89 @@
+package exporter
+
+import (
+	"os"
+	"slices"
+	"testing"
+)
+
+func TestNodesGetClusterNodes(t *testing.T) {
+	if os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") == "" {
+		t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI not set - skipping")
+	}
+
+	host := os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI")
+	e, _ := NewRedisExporter(host, Options{})
+	c, err := e.connectToRedisCluster()
+	if err != nil {
+		t.Fatalf("connectToRedisCluster() err: %s", err)
+	}
+	defer c.Close()
+
+	nodes, err := e.getClusterNodes(c)
+	if err != nil {
+		t.Fatalf("getClusterNodes() err: %s", err)
+	}
+
+	tsts := []struct {
+		node string
+		ok   bool
+	}{
+		{node: "127.0.0.1:7003", ok: true},
+		{node: "127.0.0.1:7002", ok: true},
+		{node: "127.0.0.1:7005", ok: true},
+		{node: "127.0.0.1:7001", ok: true},
+		{node: "127.0.0.1:7004", ok: true},
+		{node: "127.0.0.1:7000", ok: true},
+
+		{node: "", ok: false},
+		{node: " ", ok: false},
+		{node: "127.0.0.1", ok: false},
+		{node: "127.0.0.1:8000", ok: false},
+	}
+
+	for _, tst := range tsts {
+		t.Run(tst.node, func(t *testing.T) {
+			found := slices.Contains(nodes, tst.node)
+			if found != tst.ok {
+				t.Errorf("Test failed for node: %s expected: %t, got: %t", tst.node, tst.ok, found)
+			}
+		})
+	}
+}
+
+func TestParseClusterNodeString(t *testing.T) {
+	tsts := []struct {
+		line string
+		node string
+		ok   bool
+	}{
+		// The following are examples of the output of the CLUSTER NODES command.
+		// https://redis.io/docs/latest/commands/cluster-nodes/
+		{line: "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004,hostname4 slave e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 0 1426238317239 4 connected", node: "127.0.0.1:30004", ok: true},
+		{line: "67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 127.0.0.1:30002@31002,hostname2 master - 0 1426238316232 2 connected 5461-10922", node: "127.0.0.1:30002", ok: true},
+		{line: "292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 127.0.0.1:30003@31003,hostname3 master - 0 1426238318243 3 connected 10923-16383", node: "127.0.0.1:30003", ok: true},
+		{line: "6ec23923021cf3ffec47632106199cb7f496ce01 127.0.0.1:30005@31005,hostname5 slave 67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 0 1426238316232 5 connected", node: "127.0.0.1:30005", ok: true},
+		{line: "824fe116063bc5fcf9f4ffd895bc17aee7731ac3 127.0.0.1:30006@31006,hostname6 slave 292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 0 1426238317741 6 connected", node: "127.0.0.1:30006", ok: true},
+		{line: "e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001,hostname1 myself,master - 0 0 1 connected 0-5460", node: "127.0.0.1:30001", ok: true},
+		{line: "e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460", node: "127.0.0.1:30001", ok: true},
+
+		{line: "07c37dfeb235213a872192d90877d0cd55635b91", ok: false},
+		{line: "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004,hostname4 slave", ok: false},
+		{line: "127.0.0.1:30005,hostname5", ok: false},
+	}
+
+	for _, tst := range tsts {
+		t.Run(tst.line, func(t *testing.T) {
+			node, ok := parseClusterNodeString(tst.line)
+
+			if ok != tst.ok {
+				t.Errorf("Test failed for line: %s", tst.line)
+				return
+			}
+			if node != tst.node {
+				t.Errorf("Node not matching, expected: %s, got: %s", tst.node, node)
+				return
+			}
+		})
+	}
+}


Reviews:
user: login: oliver006
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-02T17:31:46Z
user: login: steveteuber
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:00:19Z
user: login: steveteuber
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:01:28Z
user: login: steveteuber
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:09:22Z
user: login: steveteuber
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:13:35Z
user: login: oliver006
body: Changes look good. Just some minor changes, I think you simplify `parseClusterNodeString()` and directly return `host+ip`
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-04T13:58:24Z
user: login: oliver006
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-04T14:05:54Z
user: login: steveteuber
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:09:21Z
user: login: oliver006
body: Looks good - nice work!

Let's merge this and if you want to add the cluster_addr parameter to the discover endpoint then do it in a follow-up PR.
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-10T04:26:34Z

Review comments:
diff_hunk: @@ -103,6 +111,50 @@ func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) {
 	).ServeHTTP(w, r)
 }
 
+func (e *Exporter) discoveryHandler(w http.ResponseWriter, r *http.Request) {
+	if !e.options.IsCluster {
+		http.Error(w, "The discovery endpoint is only available on a redis cluster", http.StatusBadRequest)
+		return
+	}
+
+	c, err := e.connectToRedisCluster()
+	if err != nil {
+		http.Error(w, "Couldn't connect to redis cluster", http.StatusInternalServerError)
path: exporter/http.go
user: login: oliver006
body: this needs a return here? 
created_at: 2025-04-02T14:15:58Z
author_association: OWNER
diff_hunk: @@ -103,6 +111,50 @@ func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) {
 	).ServeHTTP(w, r)
 }
 
+func (e *Exporter) discoveryHandler(w http.ResponseWriter, r *http.Request) {
+	if !e.options.IsCluster {
+		http.Error(w, "The discovery endpoint is only available on a redis cluster", http.StatusBadRequest)
+		return
+	}
+
+	c, err := e.connectToRedisCluster()
+	if err != nil {
+		http.Error(w, "Couldn't connect to redis cluster", http.StatusInternalServerError)
+	}
+	defer c.Close()
+
+	nodes, err := e.getClusterNodes(c)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to fetch cluster nodes: %s", err), http.StatusInternalServerError)
+		return
+	}
+
+	discovery := &discoveryTargetList{
path: exporter/http.go
user: login: oliver006
body: make this `discovery := discoveryTargetList{` so it'll save you a bunch of those awkward `(*discovery)` casts later? 
You can then pass it like this `json.MarshalIndent(&discovery, ...) `
created_at: 2025-04-02T14:17:58Z
author_association: OWNER
diff_hunk: @@ -321,6 +322,70 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoveryHandlers(t *testing.T) {
path: exporter/http_test.go
user: login: oliver006
body: Thanks for adding this test!

I think you can simplify it a bit.

Use the loop structure (golang-style table-driven tests) from the second test (isCluster==False) and add the isCluster variable to the test inputs and pass it when creating the exporter. Now you can add 2-3 of the "targets" to the "want" list for when isCluster==true (no need to check for the exact json being returned), that let's you do the whole test with isCluster==true/false in one loop
Let me know if that makes sense
created_at: 2025-04-02T14:23:20Z
author_association: OWNER
diff_hunk: @@ -321,6 +322,70 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoveryHandlers(t *testing.T) {
+	os.Setenv("TEST_REDIS_CLUSTER_MASTER_URI", "redis://localhost:17000")
path: exporter/http_test.go
user: login: oliver006
body: this shouldn't be set here but during invocation (e.g. it's already Makefile or set it locally when you manually run `go test ...`)
created_at: 2025-04-02T14:23:48Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,60 @@
+package exporter
+
+import (
+	"regexp"
+	"sort"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
+
+func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) {
+	output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES"))
+	if err != nil {
+		log.Errorf("Error getting cluster nodes: %s", err)
+		return nil, err
+	}
+
+	lines := strings.Split(output, "\n")
+	nodes := []string{}
+
+	for _, line := range lines {
+		if host, port, ok := parseClusterNodeString(line); ok {
+			nodes = append(nodes, host+":"+port)
+		}
+	}
+
+	sort.Strings(nodes)
path: exporter/nodes.go
user: login: oliver006
body: why sort the results? is it needed? 
created_at: 2025-04-02T14:25:20Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,60 @@
+package exporter
+
+import (
+	"regexp"
+	"sort"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
path: exporter/nodes.go
user: login: oliver006
body: nice!
created_at: 2025-04-02T14:27:03Z
author_association: OWNER
diff_hunk: @@ -103,6 +111,50 @@ func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) {
 	).ServeHTTP(w, r)
 }
 
+func (e *Exporter) discoveryHandler(w http.ResponseWriter, r *http.Request) {
+	if !e.options.IsCluster {
+		http.Error(w, "The discovery endpoint is only available on a redis cluster", http.StatusBadRequest)
+		return
+	}
+
+	c, err := e.connectToRedisCluster()
+	if err != nil {
+		http.Error(w, "Couldn't connect to redis cluster", http.StatusInternalServerError)
path: exporter/http.go
user: login: steveteuber
body: Yes, of course!
created_at: 2025-04-03T07:00:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -321,6 +322,70 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoveryHandlers(t *testing.T) {
+	os.Setenv("TEST_REDIS_CLUSTER_MASTER_URI", "redis://localhost:17000")
path: exporter/http_test.go
user: login: steveteuber
body: It was just a quick and dirty workaround to run this single test directly in the IDE...
created_at: 2025-04-03T07:01:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+package exporter
+
+import (
+	"regexp"
+	"sort"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
+
+func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) {
+	output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES"))
+	if err != nil {
+		log.Errorf("Error getting cluster nodes: %s", err)
+		return nil, err
+	}
+
+	lines := strings.Split(output, "\n")
+	nodes := []string{}
+
+	for _, line := range lines {
+		if host, port, ok := parseClusterNodeString(line); ok {
+			nodes = append(nodes, host+":"+port)
+		}
+	}
+
+	sort.Strings(nodes)
path: exporter/nodes.go
user: login: steveteuber
body: Yes... isn't needed 😄 
created_at: 2025-04-03T07:09:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -321,6 +322,70 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoveryHandlers(t *testing.T) {
path: exporter/http_test.go
user: login: steveteuber
body: This makes sense, I will do that!
created_at: 2025-04-03T07:13:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+package exporter
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
+
+func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) {
+	output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES"))
+	if err != nil {
+		log.Errorf("Error getting cluster nodes: %s", err)
+		return nil, err
+	}
+
+	lines := strings.Split(output, "\n")
+	nodes := []string{}
+
+	for _, line := range lines {
+		if host, port, ok := parseClusterNodeString(line); ok {
+			nodes = append(nodes, host+":"+port)
+		}
+	}
+
+	return nodes, nil
+}
+
+/*
+<id> <ip:port@cport[,hostname]> <flags> <master> <ping-sent> <pong-recv> <config-epoch> <link-state> <slot> <slot> ... <slot>
+eaf69c70d876558a948ba62af0884a37d42c9627 127.0.0.1:7002@17002 master - 0 1742836359057 3 connected 10923-16383
+*/
+func parseClusterNodeString(node string) (ip string, port string, ok bool) {
path: exporter/nodes.go
user: login: oliver006
body: You don't ever need the ip/port independently - how about returning the `host+":"+port` and make the return type `(string, bool)`

Also, I prefer not using named return values as it makes the return statements clearer.
created_at: 2025-04-04T13:51:09Z
author_association: OWNER
diff_hunk: @@ -321,6 +321,56 @@ func TestHttpHandlers(t *testing.T) {
 	}
 }
 
+func TestHttpDiscoveryHandlers(t *testing.T) {
+	if os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") == "" {
+		t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI not set - skipping")
+	}
+
+	tests := []struct {
path: exporter/http_test.go
user: login: oliver006
body: nice!
created_at: 2025-04-04T13:52:59Z
author_association: OWNER
diff_hunk: @@ -13,6 +14,13 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+type discoveryTargetList []*discoveryTargetGroup
+
+type discoveryTargetGroup struct {
path: exporter/http.go
user: login: oliver006
body: the two types aren't really used anywhere else, can you just inline this in line 133? 
created_at: 2025-04-04T13:54:20Z
author_association: OWNER
diff_hunk: @@ -509,6 +509,7 @@ func NewRedisExporter(uri string, opts Options) (*Exporter, error) {
 
 	e.mux.HandleFunc("/", e.indexHandler)
 	e.mux.HandleFunc("/scrape", e.scrapeHandler)
+	e.mux.HandleFunc("/discovery", e.discoveryHandler)
path: exporter/exporter.go
user: login: oliver006
body: Oh, one more thing, can you please call this `/discover-cluster-nodes` ? 
It's a bit longer but makes it more clear that this is for discovering cluster nodes.
created_at: 2025-04-04T14:05:54Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,57 @@
+package exporter
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/gomodule/redigo/redis"
+	log "github.com/sirupsen/logrus"
+)
+
+var reNodeAddress = regexp.MustCompile(`^(?P<ip>.+):(?P<port>\d+)@(?P<cport>\d+)(?:,(?P<hostname>.+))?`)
+
+func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) {
+	output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES"))
+	if err != nil {
+		log.Errorf("Error getting cluster nodes: %s", err)
+		return nil, err
+	}
+
+	lines := strings.Split(output, "\n")
+	nodes := []string{}
+
+	for _, line := range lines {
+		if host, port, ok := parseClusterNodeString(line); ok {
+			nodes = append(nodes, host+":"+port)
+		}
+	}
+
+	return nodes, nil
+}
+
+/*
+<id> <ip:port@cport[,hostname]> <flags> <master> <ping-sent> <pong-recv> <config-epoch> <link-state> <slot> <slot> ... <slot>
+eaf69c70d876558a948ba62af0884a37d42c9627 127.0.0.1:7002@17002 master - 0 1742836359057 3 connected 10923-16383
+*/
+func parseClusterNodeString(node string) (ip string, port string, ok bool) {
path: exporter/nodes.go
user: login: steveteuber
body: Ok, done.
created_at: 2025-04-07T14:09:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: oliver006
created_at: 2025-03-31T06:23:48Z
body: Thanks for the PR, I'll try to have a look and review it this week!
author_association: OWNER
user: login: codecov[bot]
created_at: 2025-03-31T06:27:58Z
body: ## [Codecov](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver) Report
Attention: Patch coverage is `72.85714%` with `19 lines` in your changes missing coverage. Please review.
> Project coverage is 80.59%. Comparing base [(`e062c5e`)](https://app.codecov.io/gh/oliver006/redis_exporter/commit/e062c5e2d458890bdb5baebcc4dbacd28b06af35?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver) to head [(`a1998d7`)](https://app.codecov.io/gh/oliver006/redis_exporter/commit/a1998d751d5914891a53debde238298b798d15e4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver).
> Report is 1 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver) | Patch % | Lines |
|---|---|---|
| [exporter/http.go](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?src=pr&el=tree&filepath=exporter%2Fhttp.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver#diff-ZXhwb3J0ZXIvaHR0cC5nbw==) | 63.41% | [10 Missing and 5 partials :warning: ](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver) |
| [exporter/nodes.go](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?src=pr&el=tree&filepath=exporter%2Fnodes.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver#diff-ZXhwb3J0ZXIvbm9kZXMuZ28=) | 85.71% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #989      +/-   ##
==========================================
- Coverage   80.78%   80.59%   -0.19%     
==========================================
  Files          18       19       +1     
  Lines        2873     2943      +70     
==========================================
+ Hits         2321     2372      +51     
- Misses        446      459      +13     
- Partials      106      112       +6     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/oliver006/redis_exporter/pull/989?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Oliver).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: coveralls
created_at: 2025-03-31T06:28:50Z
body: ## Pull Request Test Coverage Report for [Build 14372242130](https://coveralls.io/builds/73159182)


### Details

* **57** of **70**   **(81.43%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.07%**) to **84.451%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [exporter/nodes.go](https://coveralls.io/builds/73159182/source?filename=exporter%2Fnodes.go#L16) | 25 | 28 | 89.29%
| [exporter/http.go](https://coveralls.io/builds/73159182/source?filename=exporter%2Fhttp.go#L115) | 31 | 41 | 75.61%
<!-- | **Total:** | **57** | **70** | **81.43%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73159182/badge)](https://coveralls.io/builds/73159182) |
| :-- | --: |
| Change from base [Build 14233335775](https://coveralls.io/builds/73053750): |  -0.07% |
| Covered Lines: | 2493 |
| Relevant Lines: | 2952 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: oliver006
created_at: 2025-04-02T14:30:45Z
body: Overall approach looks good, left a few minor comments but I like the approach, that's a neat idea to use the discovery endpoint and feed it right back into the Prometheus server.

One question: how does this work when auth is set? Is auth the same for all nodes and "it just works"? 
(I only have limited experience with cluster mode)

author_association: OWNER
user: login: steveteuber
created_at: 2025-04-02T16:14:57Z
body: Hey, thanks for your time. Which comments do you mean? I can't find any in the code preview.
And yes, when auth is enabled, it's the same for all nodes. So it just works.
author_association: CONTRIBUTOR
user: login: oliver006
created_at: 2025-04-02T17:32:40Z
body: > Hey, thanks for your time. Which comments do you mean? I can't find any in the code preview.

My bad, forgot to hit "submit" 🙈 

And I need to fix the linter error, will get to that later today.
author_association: OWNER
user: login: oliver006
created_at: 2025-04-03T02:02:35Z
body: Can you please rebase to pick up #991  ? Thx!
author_association: OWNER
user: login: steveteuber
created_at: 2025-04-03T17:59:42Z
body: I'm done you can now run the tests 😉
author_association: CONTRIBUTOR
user: login: oliver006
created_at: 2025-04-04T14:06:28Z
body: Here's a general question that I'm trying to wrap my head around:

I really like how this /discovery endpoint is directly tied into the SD http discovery, this works really well for one cluster that is statically configured via the redis_exporter cmd line. 

So far so good.

Is there a way to make this work for multiple clusters with one redis_exporter? 

Say I run three clusters and I want to use only one exporter and pass the addresses of one of each of the cluster nodes and then the discover endpoint looks up all the cluster nodes and the prometheus server knows where to find them and scrape them.
I don't see a way to use the same "source_labels & replacement" mechanism that's used when the redis instance hostname is passed to the /scrape endpoint .

How would you go about this? 
(and it's ok if there's no way to do this right now  this feature is already an improvement) 
author_association: OWNER
user: login: steveteuber
created_at: 2025-04-07T16:13:44Z
body: Well, as you already mentioned, the current implementation of the discovery endpoint is only working with a single Redis cluster at a time... I think it would be a good idea to add a parameter that allows you to define a cluster. Something like:

```yaml
scrape_configs:
  - job_name: 'redis_exporter_cluster'
    http_sd_configs:
      - url: http://<<REDIS-EXPORTER-HOSTNAME>>:9121/discover-cluster-nodes?target=cluster1.example.com
    metrics_path: /scrape
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: <<REDIS-EXPORTER-HOSTNAME>>:9121
```
author_association: CONTRIBUTOR
user: login: oliver006
created_at: 2025-04-10T04:16:42Z
body: > I think it would be a good idea to add a parameter that allows you to define a cluster. Something like

Makes sense - want to add this as part of this PR?
author_association: OWNER
user: login: steveteuber
created_at: 2025-04-10T06:49:47Z
body: Awesome! Can you create a new release with this feature?
Then I can finally remove my custom build and switch back to your container image 😃 
author_association: CONTRIBUTOR
user: login: oliver006
created_at: 2025-04-12T05:12:47Z
body: Released as [v1.70.0](https://github.com/oliver006/redis_exporter/releases/tag/v1.70.0)
author_association: OWNER