Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions redisdb/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# redisdb test fixtures

This folder holds the pytest suite plus the Compose environments used by the
tests and by the evalya fixtures published from `evalya.yaml`.

## Compose environments

| File | What it is |
|---|---|
| `compose/standalone.compose` | Single authenticated Redis instance. |
| `compose/1m-2s.compose` | Master with two slaves (one deliberately unhealthy). |
| `compose/1m-2s-cloud.compose` | Same topology, master loads a mounted `config/redis.conf`. |
| `compose/full-coverage.compose` | Full metric-coverage environment (see below). |

`evalya.yaml` publishes two of these as reusable fixtures: `redis-standalone`
(the standalone Compose file) and `redis-full` (the full-coverage environment).

## The `redis-full` fixture and the INFO-rewrite proxy

`redis-full` exists to make a single fixture emit the **entire** `INFO` surface
that the redisdb check and the OpenTelemetry redisreceiver can parse, including
fields a plain OSS Redis instance can never produce.

It runs four services:

- **redis-master**: a real Redis server (AOF on, small `maxmemory` under LRU,
full slowlog). No config file is mounted, so `CONFIG`/`CLIENT`/`CLUSTER` stay
un-renamed and the check's side-channel collections work.
- **redis-replica**: `replicaof` the master, so `INFO replication` reports a
connected slave and the check's with-replica path is exercised. It is not
proxied; the check sees its state through the master's INFO.
- **seed**: a one-shot job that populates typed keys, TTLs, keyspace
hits/misses, an eviction-inducing overfill, benchmark traffic, and forced key
expiry, so the naturally derived counters are non-zero before the first scrape.
- **info-proxy**: the fixture entrypoint, a transparent RESP proxy fronting the
master on 6379.

### How it sits in front of Redis

The proxy is the entrypoint; the real Redis runs behind it and is the source of
truth for almost everything. A scraper connects to the proxy on 6379 exactly as
it would to a standalone, and each request flows through:

```
scraper (redisdb check / OTel receiver)
│ RESP request
info-proxy ──(forwards byte-for-byte)──► redis-master
│ ◄──────────── real reply ────────────┘
├─ INFO all → real reply + appended inject.conf lines (length re-framed)
├─ CLUSTER INFO → canned reply from cluster_info.txt (master not contacted)
└─ everything else → returned unchanged
```

This is a transparent forward, not a fallback: the master is always the primary
path, and the proxy only augments one reply and short-circuits one command.

### What the proxy does

The proxy speaks Redis (RESP) only. It has no knowledge of Datadog metrics or
metric formats; it fakes raw Redis server output, and the downstream check maps
that to `redis.*` metrics. Because both scrapers read the same `INFO`, one
injection point feeds both.

Its default path is forward-everything to the master, with two exceptions:

- `INFO all` (and bare `INFO` / `INFO everything`): the real reply is forwarded,
then the `key:value` lines from `proxy/inject.conf` are appended and the RESP
length is re-framed. These are the fields OSS Redis cannot emit on its own:
managed-service (Azure Cache), cluster, sentinel, and RediSearch fields, each
matching a key the check parses in `constants.py`.
- `CLUSTER INFO`: answered from `proxy/cluster_info.txt` without contacting the
master, since a standalone master would reply that cluster support is disabled.

Everything else, including section-scoped requests such as `INFO commandstats`,
is forwarded and returned byte-for-byte. The proxy dials one backend connection
per client to preserve per-connection AUTH and the negotiated RESP protocol
version, and it handles both RESP2 (the redisdb check) and RESP3 (the OTel
receiver).

If you drop the proxy and point the check straight at redis-master, everything
still works; you only lose the faked managed-service/cluster/sentinel/RediSearch
fields.

### Port and access

The proxy listens on 6379, the standard Redis port, and presents exactly like a
standalone. The Compose file publishes no host port on purpose (to avoid
clashing with a local Redis or a concurrent run); consumers reach it over the
Compose network. To inspect the fixture by hand, add `--publish 6379:6379` or a
Compose override.

The proxy source lives in `proxy/`; see `proxy/main.go` for the request loop and
`proxy/resp.go` for the RESP framing.
138 changes: 138 additions & 0 deletions redisdb/tests/compose/full-coverage.compose
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Full metric-coverage environment for the redisdb check and the OpenTelemetry
# redisreceiver.
#
# A real master/replica pair produces every INFO field that OSS Redis can emit
# (memory, clients, cpu, stats, persistence, keyspace, replication, AOF,
# eviction, expiry, commandstats, slowlog). The `info-proxy` sits in front of
# the master and augments the `INFO all` reply with the fields OSS Redis never
# emits on its own (managed-service, cluster, sentinel, and RediSearch fields),
# and serves a canned `CLUSTER INFO`. Both scrapers read those fields the same
# way, so a single injection point covers both. See ../proxy for details.
services:
redis-master:
image: "redis:${REDIS_VERSION:-7.4}"
networks:
- network1
# No config file here on purpose: the managed-service simulation config
# renames CONFIG/CLIENT/CLUSTER, which the check needs for its side-channel
# collections. Enable AOF, a small memory ceiling with LRU eviction (so
# evicted_keys is non-zero after the seed overfills), and full slowlog
# capture so slowlog metrics are populated.
command: >
redis-server
--requirepass devops-best-friend
--appendonly yes
--maxmemory 16mb
--maxmemory-policy allkeys-lru
--slowlog-log-slower-than 0
--latency-monitor-threshold 1
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a devops-best-friend ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s

redis-replica:
image: "redis:${REDIS_VERSION:-7.4}"
depends_on:
redis-master:
condition: service_healthy
networks:
- network1
command: >
redis-server
--requirepass devops-best-friend
--masterauth devops-best-friend
--replicaof redis-master 6379
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a devops-best-friend ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s

# Populates the master with typed keys, TTL keys, keyspace hits/misses, an
# eviction-inducing overfill, benchmark traffic (commandstats + net.commands),
# and a slow command, so the naturally derived INFO fields are all non-trivial.
seed:
image: "redis:${REDIS_VERSION:-7.4}"
depends_on:
redis-master:
condition: service_healthy
networks:
- network1
restart: "no"
entrypoint:
- sh
- -c
- |
set -e
R="redis-cli -h redis-master -a devops-best-friend --no-auth-warning"
$$R SET string:1 hello >/dev/null
$$R RPUSH list:1 a b c d e >/dev/null
$$R SADD set:1 x y z >/dev/null
$$R HSET hash:1 f1 v1 f2 v2 f3 v3 >/dev/null
$$R ZADD zset:1 1 a 2 b 3 c >/dev/null
$$R XADD stream:1 '*' field val >/dev/null
i=1; while [ $$i -le 50 ]; do $$R SETEX ttl:$$i 300 v >/dev/null; i=$$((i+1)); done
$$R GET string:1 >/dev/null
$$R GET does:not:exist >/dev/null
echo "seeding traffic (benchmark + overfill for eviction)..."
redis-benchmark -h redis-master -a devops-best-friend -q -n 100000 -r 100000 -d 512 -t set,get,incr,lpush,rpush,sadd,hset,zadd,spop,lpop >/dev/null 2>&1 || true
$$R DEBUG SLEEP 0.05 >/dev/null 2>&1 || true
# Drive expired_keys: the 300s ttl:* keys above are evicted by the LRU
# overfill long before they expire, so create short-TTL keys after the
# overfill, wait past the TTL, then read them to force lazy expiry (the
# active-expiry cycle also counts them). Without this expired_keys stays 0.
i=1; while [ $$i -le 20 ]; do $$R SETEX exp:$$i 1 v >/dev/null; i=$$((i+1)); done
sleep 2
i=1; while [ $$i -le 20 ]; do $$R GET exp:$$i >/dev/null; i=$$((i+1)); done
echo "evicted_keys=$$($$R INFO stats | grep evicted_keys || true)"
echo "expired_keys=$$($$R INFO stats | grep expired_keys || true)"
echo "seed-done"

info-proxy:
build:
context: ../proxy
dockerfile: Dockerfile
# info-proxy is the fixture entrypoint (published as redis-full). Gate it on
# the whole environment being ready so a single dependency on the proxy pulls
# up everything a scraper needs:
# - redis-replica healthy, so INFO replication reports a connected slave
# (connected_slaves/slave0) and the with-replica code path is exercised;
# - seed completed, so the naturally derived counters (keyspace hits/misses,
# evicted/expired keys, commands, net bytes, commandstats) are non-zero
# before the first scrape rather than starting at 0.
depends_on:
redis-master:
condition: service_healthy
redis-replica:
condition: service_healthy
seed:
condition: service_completed_successfully
environment:
LISTEN_ADDR: ":6379"
BACKEND_ADDR: "redis-master:6379"
INJECT_FILE: "/etc/info-proxy/inject.conf"
CLUSTER_INFO_FILE: "/etc/info-proxy/cluster_info.txt"
volumes:
- ../proxy/inject.conf:/etc/info-proxy/inject.conf:ro
- ../proxy/cluster_info.txt:/etc/info-proxy/cluster_info.txt:ro
# No host-port publish: the sole consumer is the evalya `redis-full` task,
# whose other services reach the proxy over the managed network via the
# DB_HOST label. Publishing 6379 to the host only invites conflicts with a
# local Redis or a concurrent scenario. A developer inspecting the fixture
# by hand can add `--publish 6379:6379` or a compose override as needed.
networks:
- network1
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a devops-best-friend ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s

networks:
network1:
name: full-coverage_default
22 changes: 22 additions & 0 deletions redisdb/tests/evalya.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,25 @@ tasks:
timeout: 5s
retries: 5
start_period: 10s

# Full metric-coverage fixture: a real master/replica pair fronted by the
# info-proxy, which augments `INFO all` with the fields OSS Redis cannot emit
# (managed-service, cluster, sentinel, RediSearch) and serves a canned
# `CLUSTER INFO`. Consumers connect to the proxy, which presents on 6379 just
# like a standalone. See ./compose/full-coverage.compose and ./proxy.
- id: redis-full
task: ./compose/full-coverage.compose@info-proxy
labels:
evalya.io/publish: "true"
evalya.io/provides.DB_HOST: "{{ .hostname }}"
evalya.io/provides.DB_PORT: "6379"
evalya.io/provides.REDIS_PASSWORD: devops-best-friend
env:
- name: REDIS_PASSWORD
value: devops-best-friend
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
12 changes: 12 additions & 0 deletions redisdb/tests/proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Build the info-proxy binary, then ship it on a redis image so the compose
# healthcheck can use redis-cli against the proxy port.
FROM golang:1.26-alpine AS build
WORKDIR /src
# Stdlib-only module: copying sources is enough, no module download step.
COPY . .
RUN CGO_ENABLED=0 go build -o /usr/local/bin/info-proxy .

FROM redis:7-alpine
COPY --from=build /usr/local/bin/info-proxy /usr/local/bin/info-proxy
EXPOSE 6379
ENTRYPOINT ["/usr/local/bin/info-proxy"]
11 changes: 11 additions & 0 deletions redisdb/tests/proxy/cluster_info.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:2
cluster_stats_messages_sent:1000
cluster_stats_messages_received:1000
3 changes: 3 additions & 0 deletions redisdb/tests/proxy/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module info-proxy

go 1.26
89 changes: 89 additions & 0 deletions redisdb/tests/proxy/inject.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# INFO fields injected by info-proxy into the `INFO all` reply.
#
# These are the redisdb metrics that a plain OSS Redis standalone/replica cannot
# emit no matter how it is loaded or configured, so the environment fakes them
# here to exercise the check's parsing and metric submission. Every key matches
# an entry in redisdb/datadog_checks/redisdb/constants.py (GAUGE_KEYS/RATE_KEYS);
# values are arbitrary small non-zero numbers (the FTF parity check asserts
# co-presence, not value equality).
#
# Naturally generated fields (memory, clients, cpu, stats, persistence,
# keyspace, replication, AOF, eviction, expiry, commandstats, slowlog) are NOT
# listed here; the compose environment produces them for real.

# --- Managed-service-only (Azure Cache for Redis) ------------------------------
# Emitted only by Azure's managed Redis, never by OSS redis-server.
bytes_received_per_sec:1024
bytes_sent_per_sec:2048

# --- Cluster -------------------------------------------------------------------
# Override the standalone's cluster_enabled:0 so the check follows its cluster
# path and calls CLUSTER INFO (served canned by the proxy, see cluster_info.txt).
cluster_enabled:1
cluster_connections:4

# --- Sentinel ------------------------------------------------------------------
# Present only in the INFO of a redis-server running in --sentinel mode.
sentinel_masters:1
sentinel_running_scripts:2
sentinel_scripts_queue_length:3
sentinel_simulate_failure_flags:4
sentinel_tilt:5
sentinel_tilt_since_seconds:1
sentinel_total_tilt:2

# --- RediSearch module (search_*) ----------------------------------------------
# Present only when the RediSearch/Query-Engine module is loaded (redis-stack).
# Faked here to keep the base image small; these are redisdb-only (the OTel
# receiver has no search metrics).
search_bytes_collected:1
search_cursors_internal_active:2
search_cursors_internal_idle:3
search_cursors_user_active:4
search_cursors_user_idle:5
search_errors_for_index_with_max_failures:6
search_errors_indexing_failures:7
search_gc_bytes_collected:8
search_gc_marked_deleted_vectors:9
search_gc_total_cycles:1
search_gc_total_docs_not_collected_by_gc:2
search_gc_total_ms_run:3
search_global_idle:4
search_global_total:5
search_largest_memory_index:6
search_marked_deleted_vectors:7
search_number_of_active_indexes:8
search_number_of_active_indexes_indexing:9
search_number_of_active_indexes_running_queries:1
search_number_of_indexes:2
search_smallest_memory_index:3
search_total_active_queries:4
search_total_active_write_threads:5
search_total_cycles:6
search_total_docs_not_collected_by_gc:7
search_total_indexing_time:8
search_total_ms_run:9
search_total_queries_processed:1
search_total_query_commands:2
search_total_query_execution_time_ms:3
search_used_memory_indexes:4
search_used_memory_vector_index:5
search_fields_geo_Geo:6
search_fields_geo_NoIndex:7
search_fields_geo_Sortable:8
search_fields_geoshape_Geoshape:9
search_fields_geoshape_NoIndex:1
search_fields_geoshape_Sortable:2
search_fields_numeric_NoIndex:3
search_fields_numeric_Numeric:4
search_fields_numeric_Sortable:5
search_fields_tag_CaseSensitive:6
search_fields_tag_NoIndex:7
search_fields_tag_Sortable:8
search_fields_tag_Tag:9
search_fields_text_NoIndex:1
search_fields_text_Sortable:2
search_fields_text_Text:3
search_fields_vector_Flat:4
search_fields_vector_HNSW:5
search_fields_vector_Vector:6
Loading
Loading