Repository: oliver006/redis_exporter

Issue:
Feature: Add Redis search module indexes metrics collection
**Describe the problem**

Redis modules generic metrics collection was added in https://github.com/oliver006/redis_exporter/issues/942, now I think it's worth to add per-index metrics collection with optional pattern to match indexes, similar to streams metrics collection now.

Example of index info output (no difference in fields between v7.4 and v8.0):

```log
> ft.info idx:bicycle
 1) index_name
 2) idx:bicycle
 3) index_options
 4) (empty array)
 5) index_definition
 6) 1) key_type
    2) JSON
    3) prefixes
    4) 1) bicycle:
    5) default_score
    6) "1"
    7) indexes_all
    8) false
 7) attributes
 8) 1) 1) identifier
       2) $.brand
       3) attribute
       4) brand
       5) type
       6) TEXT
       7) WEIGHT
       8) "1"
    2) 1) identifier
       2) $.model
       3) attribute
       4) model
       5) type
       6) TEXT
       7) WEIGHT
       8) "1"
    3) 1) identifier
       2) $.description
       3) attribute
       4) description
       5) type
       6) TEXT
       7) WEIGHT
       8) "1"
    4) 1) identifier
       2) $.price
       3) attribute
       4) price
       5) type
       6) NUMERIC
    5) 1) identifier
       2) $.condition
       3) attribute
       4) condition
       5) type
       6) TAG
       7) SEPARATOR
       8) ,
 9) num_docs
10) (integer) 3
11) max_doc_id
12) (integer) 3
13) num_terms
14) (integer) 33
15) num_records
16) (integer) 105
17) inverted_sz_mb
18) "0.003833770751953125"
19) vector_index_sz_mb
20) "0"
21) total_inverted_index_blocks
22) (integer) 35
23) offset_vectors_sz_mb
24) "1.0013580322265625e-4"
25) doc_table_size_mb
26) "0.015542030334472656"
27) sortable_values_size_mb
28) "0"
29) key_table_size_mb
30) "1.4495849609375e-4"
31) tag_overhead_sz_mb
32) "4.57763671875e-5"
33) text_overhead_sz_mb
34) "0.0011014938354492188"
35) total_index_memory_sz_mb
36) "0.021017074584960938"
37) geoshapes_sz_mb
38) "0"
39) records_per_doc_avg
40) "35"
41) bytes_per_record_avg
42) "38.28571319580078"
43) offsets_per_term_avg
44) "1"
45) offset_bits_per_record_avg
46) "8"
47) hash_indexing_failures
48) (integer) 0
49) total_indexing_time
50) "3.956614"
51) indexing
52) (integer) 0
53) percent_indexed
54) "1"
55) number_of_uses
56) (integer) 2
57) cleaning
58) (integer) 0
59) gc_stats
60)  1) bytes_collected
     2) "0"
     3) total_ms_run
     4) "0"
     5) total_cycles
     6) "0"
     7) average_cycle_time_ms
     8) "nan"
     9) last_run_time_ms
    10) "0"
    11) gc_numeric_trees_missed
    12) "0"
    13) gc_blocks_denied
    14) "0"
61) cursor_stats
62) 1) global_idle
    2) (integer) 0
    3) global_total
    4) (integer) 0
    5) index_capacity
    6) (integer) 128
    7) index_total
    8) (integer) 0
63) dialect_stats
64) 1) dialect_1
    2) (integer) 1
    3) dialect_2
    4) (integer) 0
    5) dialect_3
    6) (integer) 0
    7) dialect_4
    8) (integer) 0
65) Index Errors
66) 1) indexing failures
    2) (integer) 0
    3) last indexing error
    4) N/A
    5) last indexing error key
    6) "N/A"
    7) background indexing status
    8) OK
67) field statistics
68) 1) 1) identifier
       2) $.brand
       3) attribute
       4) brand
       5) Index Errors
       6) 1) indexing failures
          2) (integer) 0
          3) last indexing error
          4) N/A
          5) last indexing error key
          6) "N/A"
    2) 1) identifier
       2) $.model
       3) attribute
       4) model
       5) Index Errors
       6) 1) indexing failures
          2) (integer) 0
          3) last indexing error
          4) N/A
          5) last indexing error key
          6) "N/A"
    3) 1) identifier
       2) $.description
       3) attribute
       4) description
       5) Index Errors
       6) 1) indexing failures
          2) (integer) 0
          3) last indexing error
          4) N/A
          5) last indexing error key
          6) "N/A"
    4) 1) identifier
       2) $.price
       3) attribute
       4) price
       5) Index Errors
       6) 1) indexing failures
          2) (integer) 0
          3) last indexing error
          4) N/A
          5) last indexing error key
          6) "N/A"
    5) 1) identifier
       2) $.condition
       3) attribute
       4) condition
       5) Index Errors
       6) 1) indexing failures
          2) (integer) 0
          3) last indexing error
          4) N/A
          5) last indexing error key
          6) "N/A"
```

After some consideration, I think it's worth to collect these metrics and add more later if needed (particularly `gc_stats` and `cursor_stats`):

* index_name
* num_docs
* max_doc_id
* num_terms
* num_records
* inverted_sz_mb
* total_inverted_index_blocks
* vector_index_sz_mb
* offset_vectors_sz_mb
* doc_table_size_mb
* sortable_values_size_mb
* key_table_size_mb
* tag_overhead_sz_mb
* text_overhead_sz_mb
* total_index_memory_sz_mb
* geoshapes_sz_mb
* records_per_doc_avg
* bytes_per_record_avg
* offsets_per_term_avg
* offset_bits_per_record_avg
* indexing
* percent_indexed
* hash_indexing_failures
* number_of_uses
* cleaning

Output described here https://redis.io/docs/latest/commands/ft.info/

**Running the exporter**

Not related, feature request.

**Additional context**

Generic modules metrics are providing total overview of modules metrics, but it's useful to have per-index metrics as well.

Discussion:

Solution patch:
diff --git a/Makefile b/Makefile
index 0369abb..d28564a 100644
--- a/Makefile
+++ b/Makefile
@@ -37,6 +37,7 @@ test:
 	TEST_REDIS_URI="redis://localhost:6379" \
 	TEST_VALKEY7_URI="valkey://localhost:16384" \
 	TEST_VALKEY8_URI="valkey://localhost:16382" \
+	TEST_VALKEY8_BUNDLE_URI="valkey://localhost:16389" \
 	TEST_VALKEY8_TLS_URI="valkeys://localhost:16386" \
 	TEST_REDIS7_TLS_URI="rediss://localhost:16387" \
 	TEST_REDIS8_URI="redis://localhost:16388" \
diff --git a/README.md b/README.md
index aae3aea..0902ed7 100644
--- a/README.md
+++ b/README.md
@@ -191,6 +191,8 @@ scrape_configs:
 | 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-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 ".*".                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
 | exclude-latency-histogram-metrics   | REDIS_EXPORTER_EXCLUDE_LATENCY_HISTOGRAM_METRICS | Do not try to collect latency histogram metrics (to avoid `WARNING, LOGGED ONCE ONLY: cmd LATENCY HISTOGRAM` error on Redis < v7).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 | redact-config-metrics               | REDIS_EXPORTER_REDACT_CONFIG_METRICS             | Whether to redact config settings that include potentially sensitive information like passwords.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
 | ping-on-connect                     | REDIS_EXPORTER_PING_ON_CONNECT                   | Whether to ping the redis instance after connecting and record the duration as a metric, defaults to false.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
diff --git a/docker-compose.yml b/docker-compose.yml
index 4d7be49..50c33aa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,6 +20,12 @@ services:
     ports:
       - "16386:6379"
 
+  valkey8-bundle:
+    image: valkey/valkey-bundle:8
+    command: "valkey-server --enable-debug-command yes --protected-mode no"
+    ports:
+      - "16389:6379"
+
   valkey7:
     image: valkey/valkey:7
     command: "valkey-server --enable-debug-command yes --protected-mode no"
diff --git a/exporter/exporter.go b/exporter/exporter.go
index 3b380a5..dd6bc06 100644
--- a/exporter/exporter.go
+++ b/exporter/exporter.go
@@ -68,6 +68,8 @@ type Options struct {
 	CaCertFile                     string
 	InclConfigMetrics              bool
 	InclModulesMetrics             bool
+	InclSearchIndexesMetrics       bool
+	CheckSearchIndexes             string
 	DisableExportingKeyValues      bool
 	ExcludeLatencyHistogramMetrics bool
 	RedactConfigMetrics            bool
@@ -480,6 +482,30 @@ func NewRedisExporter(uri string, opts Options) (*Exporter, error) {
 		"number_of_distinct_key_groups":                      {txt: `Number of distinct key groups`, lbls: []string{"db"}},
 		"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"}},
+		"search_index_max_doc_id":                            {txt: "Maximum document ID in search index", lbls: []string{"index_name"}},
+		"search_index_num_terms":                             {txt: "Number of distinct terms in search index", lbls: []string{"index_name"}},
+		"search_index_num_records":                           {txt: "Total number of records in search index", lbls: []string{"index_name"}},
+		"search_index_inverted_size_bytes":                   {txt: "Memory used by the inverted index", lbls: []string{"index_name"}},
+		"search_index_total_inverted_index_blocks":           {txt: "Total number of blocks in the inverted index", lbls: []string{"index_name"}},
+		"search_index_vector_index_size_bytes":               {txt: "Memory used by the vector index, stores vectors associated with each document", lbls: []string{"index_name"}},
+		"search_index_offset_vectors_size_bytes":             {txt: "Memory used by the offset vectors, store positional information for terms in documents", lbls: []string{"index_name"}},
+		"search_index_doc_table_size_bytes":                  {txt: "Memory used by the document table, contains metadata about each document in the index", lbls: []string{"index_name"}},
+		"search_index_sortable_values_size_bytes":            {txt: "Memory used by sortable values, used for sorting purposes", lbls: []string{"index_name"}},
+		"search_index_key_table_size_bytes":                  {txt: "Memory used by the key table, stores mapping between document IDs and keys", lbls: []string{"index_name"}},
+		"search_index_tag_overhead_size_bytes":               {txt: "Size of the TAG index structures used for optimising performance", lbls: []string{"index_name"}},
+		"search_index_text_overhead_size_bytes":              {txt: "Size of the TEXT index structures used for optimising performance", lbls: []string{"index_name"}},
+		"search_index_total_index_memory_size_bytes":         {txt: "Total memory consumed by all indexes in the DB", lbls: []string{"index_name"}},
+		"search_index_geoshapes_size_bytes":                  {txt: "Memory used by GEO-related fields", lbls: []string{"index_name"}},
+		"search_index_avg_per_doc_records":                   {txt: "Average number of records (including deletions) per document", lbls: []string{"index_name"}},
+		"search_index_avg_per_record_bytes":                  {txt: "Average size of each record in bytes", lbls: []string{"index_name"}},
+		"search_index_avg_per_term_offsets":                  {txt: "Average number of offsets (position information) per term", lbls: []string{"index_name"}},
+		"search_index_avg_per_record_offset_bits":            {txt: "Average number of bits used for offsets per record", lbls: []string{"index_name"}},
+		"search_index_indexing":                              {txt: "Indicates whether the index is currently being generated", lbls: []string{"index_name"}},
+		"search_index_percent_indexed":                       {txt: "Percentage of the index that has been successfully generated (0-1)", lbls: []string{"index_name"}},
+		"search_index_hash_indexing_failures":                {txt: "Number of failures encountered during indexing", lbls: []string{"index_name"}},
+		"search_index_number_of_uses_total":                  {txt: "Number of times the index has been used", lbls: []string{"index_name"}},
+		"search_index_cleaning":                              {txt: "Index deletion flag. A value of 1 indicates index deletion is in progress", lbls: []string{"index_name"}},
 		"sentinel_master_ckquorum_status":                    {txt: "Master ckquorum status", lbls: []string{"master_name", "message"}},
 		"sentinel_master_ok_sentinels":                       {txt: "The number of okay sentinels monitoring this master", lbls: []string{"master_name", "master_address"}},
 		"sentinel_master_ok_slaves":                          {txt: "The number of okay slaves of the master", lbls: []string{"master_name", "master_address"}},
@@ -828,6 +854,10 @@ func (e *Exporter) scrapeRedisHost(ch chan<- prometheus.Metric) error {
 		e.extractModulesMetrics(ch, c)
 	}
 
+	if e.options.InclSearchIndexesMetrics {
+		e.extractSearchIndexesMetrics(ch, c)
+	}
+
 	if len(e.options.LuaScript) > 0 {
 		for filename, script := range e.options.LuaScript {
 			if err := e.extractLuaScriptMetrics(ch, c, filename, script); err != nil {
diff --git a/exporter/search_indexes.go b/exporter/search_indexes.go
new file mode 100644
index 0000000..3fa721a
--- /dev/null
+++ b/exporter/search_indexes.go
@@ -0,0 +1,96 @@
+package exporter
+
+import (
+	"regexp"
+
+	"github.com/gomodule/redigo/redis"
+	"github.com/prometheus/client_golang/prometheus"
+	log "github.com/sirupsen/logrus"
+)
+
+// All fields of the searchIndexInfo struct must be exported
+// because of redis.ScanStruct (reflect) limitations
+type searchIndexInfo struct {
+	IndexName                string  `redis:"index_name"`
+	NumDocs                  int64   `redis:"num_docs"`
+	MaxDocId                 int64   `redis:"max_doc_id"`
+	NumTerms                 int64   `redis:"num_terms"`
+	NumRecords               int64   `redis:"num_records"`
+	InvertedSizeMb           float64 `redis:"inverted_sz_mb"`
+	TotalInvertedIndexBlocks int64   `redis:"total_inverted_index_blocks"`
+	VectorIndexSizeMb        float64 `redis:"vector_index_sz_mb"`
+	OffsetVectorsSizeMb      float64 `redis:"offset_vectors_sz_mb"`
+	DocTableSizeMb           float64 `redis:"doc_table_size_mb"`
+	SortableValuesSizeMb     float64 `redis:"sortable_values_size_mb"`
+	KeyTableSizeMb           float64 `redis:"key_table_size_mb"`
+	TagOverheadSizeMb        float64 `redis:"tag_overhead_sz_mb"`
+	TextOverheadSizeMb       float64 `redis:"text_overhead_sz_mb"`
+	TotalIndexMemorySizeMb   float64 `redis:"total_index_memory_sz_mb"`
+	GeoshapesSizeMb          float64 `redis:"geoshapes_sz_mb"`
+	RecordsPerDocAvg         float64 `redis:"records_per_doc_avg"`
+	BytesPerRecordAvg        float64 `redis:"bytes_per_record_avg"`
+	OffsetsPerTermAvg        float64 `redis:"offsets_per_term_avg"`
+	OffsetBitsPerRecordAvg   float64 `redis:"offset_bits_per_record_avg"`
+	Indexing                 int64   `redis:"indexing"`
+	PercentIndexed           float64 `redis:"percent_indexed"`
+	HashIndexingFailures     int64   `redis:"hash_indexing_failures"`
+	NumberOfUses             int64   `redis:"number_of_uses"`
+	Cleaning                 int64   `redis:"cleaning"`
+}
+
+func (e *Exporter) extractSearchIndexesMetrics(ch chan<- prometheus.Metric, c redis.Conn) {
+	var searchIndexes []string
+	allSearchIndexes, err := redis.Strings(doRedisCmd(c, "FT._LIST"))
+	if err != nil {
+		log.Errorf("extractSearchIndexesMetrics() err: %s", err)
+		return
+	}
+
+	// Get indexes list based on check-search-indexes regex
+	checkIndexRegex := regexp.MustCompile(e.options.CheckSearchIndexes)
+	for _, index := range allSearchIndexes {
+		if checkIndexRegex.MatchString(index) {
+			searchIndexes = append(searchIndexes, index)
+		}
+	}
+
+	for _, index := range searchIndexes {
+		values, err := redis.Values(doRedisCmd(c, "FT.INFO", index))
+		if err != nil {
+			log.Errorf("extractSearchIndexesMetrics() err: %s", err)
+			return
+		}
+
+		// Scan slice to struct
+		var indexInfo searchIndexInfo
+		if err := redis.ScanStruct(values, &indexInfo); err != nil {
+			log.Errorf("Couldn't scan search index '%s': %s", index, err)
+			continue
+		}
+		// Register search index metrics
+		e.registerConstMetricGauge(ch, "search_index_num_docs", float64(indexInfo.NumDocs), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_max_doc_id", float64(indexInfo.MaxDocId), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_num_terms", float64(indexInfo.NumTerms), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_num_records", float64(indexInfo.NumRecords), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_inverted_size_bytes", indexInfo.InvertedSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_total_inverted_index_blocks", float64(indexInfo.TotalInvertedIndexBlocks), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_vector_index_size_bytes", indexInfo.VectorIndexSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_offset_vectors_size_bytes", indexInfo.OffsetVectorsSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_doc_table_size_bytes", indexInfo.DocTableSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_sortable_values_size_bytes", indexInfo.SortableValuesSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_key_table_size_bytes", indexInfo.KeyTableSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_tag_overhead_size_bytes", indexInfo.TagOverheadSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_text_overhead_size_bytes", indexInfo.TextOverheadSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_total_index_memory_size_bytes", indexInfo.TotalIndexMemorySizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_geoshapes_size_bytes", indexInfo.GeoshapesSizeMb*1048576, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_avg_per_doc_records", indexInfo.RecordsPerDocAvg, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_avg_per_record_bytes", indexInfo.BytesPerRecordAvg, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_avg_per_term_offsets", indexInfo.OffsetsPerTermAvg, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_avg_per_record_offset_bits", indexInfo.OffsetBitsPerRecordAvg, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_indexing", float64(indexInfo.Indexing), indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_percent_indexed", indexInfo.PercentIndexed, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_hash_indexing_failures", float64(indexInfo.HashIndexingFailures), indexInfo.IndexName)
+		e.registerConstMetric(ch, "search_index_number_of_uses_total", float64(indexInfo.NumberOfUses), prometheus.CounterValue, indexInfo.IndexName)
+		e.registerConstMetricGauge(ch, "search_index_cleaning", float64(indexInfo.Cleaning), indexInfo.IndexName)
+	}
+}
diff --git a/main.go b/main.go
index 8c129d3..a2818eb 100644
--- a/main.go
+++ b/main.go
@@ -183,6 +183,8 @@ func main() {
 		pingOnConnect                  = flag.Bool("ping-on-connect", getEnvBool("REDIS_EXPORTER_PING_ON_CONNECT", false), "Whether to ping the redis instance after connecting")
 		inclConfigMetrics              = flag.Bool("include-config-metrics", getEnvBool("REDIS_EXPORTER_INCL_CONFIG_METRICS", false), "Whether to include all config settings as metrics")
 		inclModulesMetrics             = flag.Bool("include-modules-metrics", getEnvBool("REDIS_EXPORTER_INCL_MODULES_METRICS", false), "Whether to collect Redis Modules metrics")
+		inclSearchIndexesMetrics       = flag.Bool("include-search-indexes-metrics", getEnvBool("REDIS_EXPORTER_INCL_SEARCH_INDEXES_METRICS", false), "Whether to collect Redis Search indexes metrics")
+		checkSearchIndexes             = flag.String("check-search-indexes", getEnv("REDIS_EXPORTER_CHECK_SEARCH_INDEXES", ".*"), "Regex pattern for Redis Search indexes to export metrics from FT.INFO command")
 		disableExportingKeyValues      = flag.Bool("disable-exporting-key-values", getEnvBool("REDIS_EXPORTER_DISABLE_EXPORTING_KEY_VALUES", false), "Whether to disable values of keys stored in redis as labels or not when using check-keys/check-single-key")
 		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")
@@ -263,6 +265,8 @@ func main() {
 			IsTile38:                       *isTile38,
 			IsCluster:                      *isCluster,
 			InclModulesMetrics:             *inclModulesMetrics,
+			InclSearchIndexesMetrics:       *inclSearchIndexesMetrics,
+			CheckSearchIndexes:             *checkSearchIndexes,
 			ExportClientList:               *exportClientList,
 			ExportClientsInclPort:          *exportClientPort,
 			SkipCheckKeysForRoleMaster:     *skipCheckKeysForRoleMaster,



Test patch:
diff --git a/exporter/modules_test.go b/exporter/modules_test.go
index 7ed241e..9116b46 100644
--- a/exporter/modules_test.go
+++ b/exporter/modules_test.go
@@ -74,8 +74,8 @@ func TestModulesv74(t *testing.T) {
 }
 
 func TestModulesv80(t *testing.T) {
-	if os.Getenv("TEST_REDIS8_URI") == "" {
-		t.Skipf("TEST_REDIS8_URI not set - skipping")
+	if os.Getenv("TEST_REDIS8_URI") == "" || os.Getenv("TEST_REDIS_URI") == "" {
+		t.Skipf("TEST_REDIS8_URI or TEST_REDIS_URI aren't set - skipping")
 	}
 
 	tsts := []struct {
diff --git a/exporter/search_indexes_test.go b/exporter/search_indexes_test.go
new file mode 100644
index 0000000..6c6e2bb
--- /dev/null
+++ b/exporter/search_indexes_test.go
@@ -0,0 +1,111 @@
+package exporter
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/gomodule/redigo/redis"
+	"github.com/prometheus/client_golang/prometheus"
+	log "github.com/sirupsen/logrus"
+)
+
+func setupSearchIndex(t *testing.T, addr string) error {
+	c, err := redis.DialURL(addr)
+	if err != nil {
+		t.Fatalf("setupSearchIndex() - couldn't setup redis for uri %s, err: %s ", addr, err)
+		return err
+	}
+	defer c.Close()
+
+	// create search index, based on https://redis.io/docs/latest/commands/ft.create and https://valkey.io/commands/ft.create
+	if _, err := doRedisCmd(c, "FT.CREATE", "test_index", "SCHEMA", "my_hash_field_key", "VECTOR", "HNSW", "10", "TYPE", "FLOAT32", "DIM", "20", "DISTANCE_METRIC", "COSINE", "M", "4", "EF_CONSTRUCTION", "100"); err != nil {
+		log.Printf("setupSearchIndex() - couldn't create search index, err: %s ", err)
+		return err
+	}
+	return nil
+}
+
+func TestExtractSearchIndexesMetrics(t *testing.T) {
+	test_redis8_uri := os.Getenv("TEST_REDIS8_URI")
+	test_valkey8_bundle_uri := os.Getenv("TEST_VALKEY8_BUNDLE_URI")
+	if test_redis8_uri == "" || test_valkey8_bundle_uri == "" {
+		t.Skipf("TEST_REDIS8_URI or TEST_VALKEY8_BUNDLE_URI aren't set - skipping")
+	}
+	if err := setupSearchIndex(t, test_redis8_uri); err != nil {
+		t.Fatalf("couldn't create search index in TEST_REDIS8_URI (%s), err: %s ", test_redis8_uri, err)
+	}
+	if err := setupSearchIndex(t, test_valkey8_bundle_uri); err != nil {
+		t.Fatalf("couldn't create search index in TEST_VALKEY8_BUNDLE_URI (%s), err: %s ", test_valkey8_bundle_uri, err)
+	}
+
+	tsts := []struct {
+		addr                     string
+		inclSearchIndexesMetrics bool
+		wantSearchIndexesMetrics bool
+	}{
+		{addr: test_redis8_uri, inclSearchIndexesMetrics: true, wantSearchIndexesMetrics: true},
+		{addr: test_redis8_uri, inclSearchIndexesMetrics: false, wantSearchIndexesMetrics: false},
+		{addr: test_valkey8_bundle_uri, inclSearchIndexesMetrics: true, wantSearchIndexesMetrics: true},
+		{addr: test_valkey8_bundle_uri, inclSearchIndexesMetrics: false, wantSearchIndexesMetrics: false},
+	}
+
+	for _, tst := range tsts {
+		e, _ := NewRedisExporter(tst.addr, Options{Namespace: "test", InclSearchIndexesMetrics: tst.inclSearchIndexesMetrics})
+
+		chM := make(chan prometheus.Metric)
+		go func() {
+			e.Collect(chM)
+			close(chM)
+		}()
+
+		wantedMetrics := map[string]bool{
+			"search_index_num_docs":                      false,
+			"search_index_max_doc_id":                    false,
+			"search_index_num_terms":                     false,
+			"search_index_num_records":                   false,
+			"search_index_inverted_size_bytes":           false,
+			"search_index_total_inverted_index_blocks":   false,
+			"search_index_vector_index_size_bytes":       false,
+			"search_index_offset_vectors_size_bytes":     false,
+			"search_index_doc_table_size_bytes":          false,
+			"search_index_sortable_values_size_bytes":    false,
+			"search_index_key_table_size_bytes":          false,
+			"search_index_tag_overhead_size_bytes":       false,
+			"search_index_text_overhead_size_bytes":      false,
+			"search_index_total_index_memory_size_bytes": false,
+			"search_index_geoshapes_size_bytes":          false,
+			"search_index_avg_per_doc_records":           false,
+			"search_index_avg_per_record_bytes":          false,
+			"search_index_avg_per_term_offsets":          false,
+			"search_index_avg_per_record_offset_bits":    false,
+			"search_index_indexing":                      false,
+			"search_index_percent_indexed":               false,
+			"search_index_hash_indexing_failures":        false,
+			"search_index_number_of_uses_total":          false,
+			"search_index_cleaning":                      false,
+		}
+
+		for m := range chM {
+			for want := range wantedMetrics {
+				if strings.Contains(m.Desc().String(), want) {
+					wantedMetrics[want] = true
+				}
+			}
+		}
+
+		if tst.wantSearchIndexesMetrics {
+			for want, found := range wantedMetrics {
+				if !found {
+					t.Errorf("%s was *not* found in Redis Search indexes metrics but expected", want)
+				}
+			}
+		} else if !tst.wantSearchIndexesMetrics {
+			for want, found := range wantedMetrics {
+				if found {
+					t.Errorf("%s was *found* in Redis Search indexes metrics but *not* expected", want)
+				}
+			}
+		}
+	}
+}