4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
710 lines
25 KiB
Python
710 lines
25 KiB
Python
"""Shared Redis integration helpers.
|
|
|
|
Provides configuration, connectivity validation, and read-only diagnostic
|
|
queries for Redis instances. All operations are production-safe: read-only,
|
|
timeouts enforced, result sizes capped, and key discovery uses the
|
|
non-blocking ``SCAN`` cursor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic import Field, field_validator
|
|
|
|
from config.strict_config import StrictConfigModel
|
|
from integrations._validation_helpers import report_classify_failure, report_validation_failure
|
|
from integrations.config_models import RedisIntegrationConfig
|
|
from platform.common.coercion import safe_int
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_REDIS_PORT = 6379
|
|
DEFAULT_REDIS_MAX_RESULTS = 50
|
|
DEFAULT_REDIS_TIMEOUT_SECONDS = 5.0
|
|
|
|
# Hard cap on how many keys SCAN will iterate
|
|
DEFAULT_REDIS_SCAN_LIMIT = 10_000
|
|
|
|
# Per-element character cap when sampling list/queue values, so a queue of
|
|
# large JSON payloads can never blow up the tool response.
|
|
DEFAULT_REDIS_LIST_VALUE_PREVIEW = 256
|
|
|
|
|
|
class RedisConfig(StrictConfigModel):
|
|
"""Normalized Redis connection settings."""
|
|
|
|
host: str = ""
|
|
port: int = Field(default=DEFAULT_REDIS_PORT, ge=1, le=65535)
|
|
username: str = ""
|
|
password: str = ""
|
|
db: int = Field(default=0, ge=0)
|
|
ssl: bool = False
|
|
timeout_seconds: float = Field(default=DEFAULT_REDIS_TIMEOUT_SECONDS, gt=0)
|
|
max_results: int = Field(default=DEFAULT_REDIS_MAX_RESULTS, gt=0, le=200)
|
|
integration_id: str = ""
|
|
|
|
@field_validator("host", mode="before")
|
|
@classmethod
|
|
def _normalize_host(cls, value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
@field_validator("username", mode="before")
|
|
@classmethod
|
|
def _normalize_username(cls, value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
@field_validator("password", mode="before")
|
|
@classmethod
|
|
def _normalize_password(cls, value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.host)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RedisValidationResult:
|
|
"""Result of validating a Redis integration."""
|
|
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def build_redis_config(raw: dict[str, Any] | None) -> RedisConfig:
|
|
"""Build a normalized Redis config object from env/store data."""
|
|
return RedisConfig.model_validate(raw or {})
|
|
|
|
|
|
def redis_config_from_env() -> RedisConfig | None:
|
|
"""Load a Redis config from env vars."""
|
|
host = os.getenv("REDIS_HOST", "").strip()
|
|
if not host:
|
|
return None
|
|
|
|
return build_redis_config(
|
|
{
|
|
"host": host,
|
|
"port": safe_int(os.getenv("REDIS_PORT", str(DEFAULT_REDIS_PORT)), DEFAULT_REDIS_PORT),
|
|
"username": os.getenv("REDIS_USERNAME", "").strip(),
|
|
"password": os.getenv("REDIS_PASSWORD", "").strip(),
|
|
"db": safe_int(os.getenv("REDIS_DATABASE", "0"), 0),
|
|
"ssl": os.getenv("REDIS_SSL", "false").strip().lower() in ("true", "1", "yes"),
|
|
}
|
|
)
|
|
|
|
|
|
def _get_client(config: RedisConfig) -> Any:
|
|
"""Create a redis client from config. Caller must close.
|
|
|
|
The client decodes responses to ``str`` so callers receive plain Python
|
|
types rather than raw bytes.
|
|
"""
|
|
import redis
|
|
|
|
return redis.Redis(
|
|
host=config.host,
|
|
port=config.port,
|
|
db=config.db,
|
|
username=config.username or None,
|
|
password=config.password or None,
|
|
ssl=config.ssl,
|
|
socket_timeout=config.timeout_seconds,
|
|
socket_connect_timeout=config.timeout_seconds,
|
|
decode_responses=True,
|
|
client_name="opensre",
|
|
)
|
|
|
|
|
|
def validate_redis_config(config: RedisConfig) -> RedisValidationResult:
|
|
"""Validate Redis connectivity with a lightweight ``PING`` command."""
|
|
if not config.host:
|
|
return RedisValidationResult(ok=False, detail="Redis host is required.")
|
|
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
if client.ping() is not True:
|
|
return RedisValidationResult(
|
|
ok=False, detail="Redis PING returned an unexpected result."
|
|
)
|
|
info = client.info("server")
|
|
version = info.get("redis_version", "unknown")
|
|
return RedisValidationResult(
|
|
ok=True,
|
|
detail=(
|
|
f"Connected to Redis {version} at {config.host}:{config.port}; "
|
|
f"database {config.db}."
|
|
),
|
|
)
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="redis",
|
|
method="validate_redis_config",
|
|
)
|
|
return RedisValidationResult(ok=False, detail=f"Redis connection failed: {err}")
|
|
|
|
|
|
def redis_is_available(sources: dict[str, dict]) -> bool:
|
|
"""Check if Redis integration params are present in available sources."""
|
|
return bool(sources.get("redis", {}).get("host"))
|
|
|
|
|
|
def redis_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
|
|
"""Extract Redis connection params from resolved integrations.
|
|
|
|
Credentials are resolved from the integration store or environment, so the
|
|
LLM never needs to supply the host or password directly.
|
|
"""
|
|
rd = sources.get("redis", {})
|
|
return {
|
|
"host": str(rd.get("host", "")).strip(),
|
|
"port": int(rd.get("port", DEFAULT_REDIS_PORT) or DEFAULT_REDIS_PORT),
|
|
"username": str(rd.get("username", "")).strip(),
|
|
"password": str(rd.get("password", "")).strip(),
|
|
"db": int(rd.get("db", 0) or 0),
|
|
"ssl": bool(rd.get("ssl", False)),
|
|
}
|
|
|
|
|
|
def get_server_info(config: RedisConfig) -> dict[str, Any]:
|
|
"""Retrieve server info: memory, connected clients, keyspace, and stats.
|
|
|
|
Read-only: uses the ``INFO`` command.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
info = client.info()
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_server_info")
|
|
|
|
keyspace = {
|
|
db_name: {
|
|
"keys": db_stats.get("keys", 0),
|
|
"expires": db_stats.get("expires", 0),
|
|
"avg_ttl_ms": db_stats.get("avg_ttl", 0),
|
|
}
|
|
for db_name, db_stats in info.items()
|
|
if db_name.startswith("db") and isinstance(db_stats, dict)
|
|
}
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"version": info.get("redis_version", ""),
|
|
"mode": info.get("redis_mode", ""),
|
|
"uptime_seconds": info.get("uptime_in_seconds", 0),
|
|
"memory": {
|
|
"used_memory_bytes": info.get("used_memory", 0),
|
|
"used_memory_human": info.get("used_memory_human", ""),
|
|
"used_memory_rss_bytes": info.get("used_memory_rss", 0),
|
|
"used_memory_peak_bytes": info.get("used_memory_peak", 0),
|
|
"maxmemory_bytes": info.get("maxmemory", 0),
|
|
"maxmemory_policy": info.get("maxmemory_policy", ""),
|
|
"mem_fragmentation_ratio": info.get("mem_fragmentation_ratio", 0),
|
|
},
|
|
"clients": {
|
|
"connected_clients": info.get("connected_clients", 0),
|
|
"blocked_clients": info.get("blocked_clients", 0),
|
|
"tracking_clients": info.get("tracking_clients", 0),
|
|
},
|
|
"stats": {
|
|
"total_connections_received": info.get("total_connections_received", 0),
|
|
"total_commands_processed": info.get("total_commands_processed", 0),
|
|
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
|
|
"keyspace_hits": info.get("keyspace_hits", 0),
|
|
"keyspace_misses": info.get("keyspace_misses", 0),
|
|
"expired_keys": info.get("expired_keys", 0),
|
|
"evicted_keys": info.get("evicted_keys", 0),
|
|
"rejected_connections": info.get("rejected_connections", 0),
|
|
},
|
|
"keyspace": keyspace,
|
|
}
|
|
|
|
|
|
def get_slowlog(config: RedisConfig, limit: int | None = None) -> dict[str, Any]:
|
|
"""Retrieve recent slow log entries.
|
|
|
|
Read-only: uses ``SLOWLOG GET``. Durations are reported in microseconds
|
|
(as Redis stores them). Results are capped at ``config.max_results``.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
effective_limit = min(limit or config.max_results, config.max_results)
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
raw_entries = client.slowlog_get(effective_limit)
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_slowlog")
|
|
|
|
entries = []
|
|
for entry in raw_entries:
|
|
command = entry.get("command", "")
|
|
if isinstance(command, (bytes, bytearray)):
|
|
command = command.decode("utf-8", "replace")
|
|
entries.append(
|
|
{
|
|
"id": entry.get("id"),
|
|
"start_time": entry.get("start_time"),
|
|
"duration_microseconds": entry.get("duration", 0),
|
|
"command": str(command),
|
|
"client_address": entry.get("client_address", ""),
|
|
"client_name": entry.get("client_name", ""),
|
|
}
|
|
)
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"returned_entries": len(entries),
|
|
"entries": entries,
|
|
}
|
|
|
|
|
|
def get_replication(config: RedisConfig) -> dict[str, Any]:
|
|
"""Retrieve replication status and replica lag.
|
|
|
|
Read-only: uses ``INFO replication``. Reports the node role, master link
|
|
health (for replicas), and per-replica offset lag (for masters).
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
info = client.info("replication")
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_replication")
|
|
|
|
role = info.get("role", "")
|
|
master_repl_offset = info.get("master_repl_offset", 0)
|
|
result: dict[str, Any] = {
|
|
"source": "redis",
|
|
"available": True,
|
|
"role": role,
|
|
"connected_slaves": info.get("connected_slaves", 0),
|
|
"master_repl_offset": master_repl_offset,
|
|
}
|
|
|
|
if role == "slave":
|
|
slave_offset = info.get("slave_repl_offset", 0)
|
|
result["master"] = {
|
|
"host": info.get("master_host", ""),
|
|
"port": info.get("master_port", 0),
|
|
"link_status": info.get("master_link_status", ""),
|
|
"last_io_seconds_ago": info.get("master_last_io_seconds_ago", -1),
|
|
"sync_in_progress": bool(info.get("master_sync_in_progress", 0)),
|
|
"slave_repl_offset": slave_offset,
|
|
}
|
|
|
|
replicas = []
|
|
for key, value in info.items():
|
|
if not key.startswith("slave") or not isinstance(value, dict):
|
|
continue
|
|
replica_offset = value.get("offset", 0)
|
|
replicas.append(
|
|
{
|
|
"id": key,
|
|
"ip": value.get("ip", ""),
|
|
"port": value.get("port", 0),
|
|
"state": value.get("state", ""),
|
|
"offset": replica_offset,
|
|
"lag_bytes": max(0, master_repl_offset - replica_offset),
|
|
}
|
|
)
|
|
result["replicas"] = replicas
|
|
return result
|
|
|
|
|
|
def scan_keys(
|
|
config: RedisConfig,
|
|
pattern: str = "*",
|
|
sample_limit: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Count keys matching a pattern and sample their TTL and type.
|
|
|
|
Read-only: uses the non-blocking ``SCAN`` cursor (never ``KEYS``) so the
|
|
server is not blocked on large keyspaces. Total iteration is capped at
|
|
``DEFAULT_REDIS_SCAN_LIMIT``; TTL/type sampling is capped at
|
|
``config.max_results``.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
match = pattern or "*"
|
|
sample_cap = min(sample_limit or config.max_results, config.max_results)
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
cursor, matched = 0, 0
|
|
samples: list[dict[str, Any]] = []
|
|
while True:
|
|
sampled_key_names: list[str] = []
|
|
cursor, batch = client.scan(cursor=cursor, match=match, count=100)
|
|
for key in batch:
|
|
matched += 1
|
|
if len(samples) + len(sampled_key_names) < sample_cap:
|
|
sampled_key_names.append(key)
|
|
if matched >= DEFAULT_REDIS_SCAN_LIMIT:
|
|
break
|
|
|
|
if sampled_key_names:
|
|
pipe = client.pipeline(transaction=False)
|
|
for key in sampled_key_names:
|
|
pipe.ttl(key)
|
|
pipe.type(key)
|
|
pipe_results = pipe.execute()
|
|
for index, key in enumerate(sampled_key_names):
|
|
samples.append(
|
|
{
|
|
"key": key,
|
|
"ttl_seconds": pipe_results[2 * index],
|
|
"type": pipe_results[2 * index + 1],
|
|
}
|
|
)
|
|
|
|
if cursor == 0 or matched >= DEFAULT_REDIS_SCAN_LIMIT:
|
|
break
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "scan_keys")
|
|
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"pattern": match,
|
|
"matched_keys": matched,
|
|
"scan_truncated": matched >= DEFAULT_REDIS_SCAN_LIMIT,
|
|
"scan_limit": DEFAULT_REDIS_SCAN_LIMIT,
|
|
"sampled_keys": len(samples),
|
|
"samples": samples,
|
|
}
|
|
|
|
|
|
def _truncate_value(value: Any, limit: int = DEFAULT_REDIS_LIST_VALUE_PREVIEW) -> str:
|
|
"""Stringify a sampled value and cap its length so payloads stay bounded."""
|
|
text = str(value)
|
|
return text if len(text) <= limit else f"{text[:limit]}…"
|
|
|
|
|
|
def get_client_list(config: RedisConfig) -> dict[str, Any]:
|
|
"""Summarize connected clients via ``CLIENT LIST`` (read-only).
|
|
|
|
Surfaces connection-pool pressure during an incident: total client count,
|
|
how many are blocked (waiting on ``BLPOP``/``BRPOP``/``XREAD`` etc.), how
|
|
many are in pub/sub mode, and breakdowns by source address and last
|
|
command. The full client list is parsed for the aggregate counts, but only
|
|
``config.max_results`` clients are returned in the per-client sample so the
|
|
response stays bounded even on a saturated server with thousands of
|
|
connections.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
raw_clients = client.client_list()
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_client_list")
|
|
|
|
blocked = 0
|
|
pubsub = 0
|
|
max_idle_seconds = 0
|
|
by_address: dict[str, int] = {}
|
|
by_command: dict[str, int] = {}
|
|
samples: list[dict[str, Any]] = []
|
|
for entry in raw_clients:
|
|
flags = str(entry.get("flags", ""))
|
|
subscriptions = safe_int(entry.get("sub", 0), 0) + safe_int(entry.get("psub", 0), 0)
|
|
idle = safe_int(entry.get("idle", 0), 0)
|
|
addr = str(entry.get("addr", ""))
|
|
# addr is "ip:port" (or "[v6]:port"); group by the host portion only.
|
|
source = addr.rsplit(":", 1)[0] if ":" in addr else (addr or "unknown")
|
|
command = str(entry.get("cmd", "") or "unknown")
|
|
is_blocked = "b" in flags
|
|
is_pubsub = "P" in flags or subscriptions > 0
|
|
|
|
if is_blocked:
|
|
blocked += 1
|
|
if is_pubsub:
|
|
pubsub += 1
|
|
max_idle_seconds = max(max_idle_seconds, idle)
|
|
by_address[source] = by_address.get(source, 0) + 1
|
|
by_command[command] = by_command.get(command, 0) + 1
|
|
|
|
if len(samples) < config.max_results:
|
|
samples.append(
|
|
{
|
|
"id": safe_int(entry.get("id", 0), 0),
|
|
"addr": addr,
|
|
"name": str(entry.get("name", "")),
|
|
"age_seconds": safe_int(entry.get("age", 0), 0),
|
|
"idle_seconds": idle,
|
|
"flags": flags,
|
|
"db": safe_int(entry.get("db", 0), 0),
|
|
"command": command,
|
|
"user": str(entry.get("user", "")),
|
|
"blocked": is_blocked,
|
|
"pubsub": is_pubsub,
|
|
}
|
|
)
|
|
|
|
def _top(counts: dict[str, int]) -> dict[str, int]:
|
|
return dict(
|
|
sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[: config.max_results]
|
|
)
|
|
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"total_clients": len(raw_clients),
|
|
"blocked_clients": blocked,
|
|
"pubsub_clients": pubsub,
|
|
"max_idle_seconds": max_idle_seconds,
|
|
"address_breakdown": _top(by_address),
|
|
"command_breakdown": _top(by_command),
|
|
"returned_clients": len(samples),
|
|
"clients": samples,
|
|
}
|
|
|
|
|
|
def get_list_depth(
|
|
config: RedisConfig,
|
|
key: str,
|
|
head: int = 0,
|
|
tail: int = 0,
|
|
) -> dict[str, Any]:
|
|
"""Report a list/queue key's depth (``LLEN``) with an optional bounded sample.
|
|
|
|
Read-only. ``TYPE`` is checked first so a non-list key returns a clear
|
|
message instead of a ``WRONGTYPE`` error, and a missing key reports
|
|
``exists=False`` rather than being indistinguishable from an empty list.
|
|
Head/tail sampling uses bounded ``LRANGE`` (each side capped at
|
|
``config.max_results``); every sampled element is truncated so a queue of
|
|
large job payloads cannot blow up the response.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
key = str(key or "").strip()
|
|
if not key:
|
|
return {"source": "redis", "available": False, "error": "A list key is required."}
|
|
|
|
head_n = max(0, min(head or 0, config.max_results))
|
|
tail_n = max(0, min(tail or 0, config.max_results))
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
key_type = client.type(key)
|
|
if key_type == "none":
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"key": key,
|
|
"exists": False,
|
|
"type": "none",
|
|
"depth": 0,
|
|
"head": [],
|
|
"tail": [],
|
|
}
|
|
if key_type != "list":
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"key": key,
|
|
"exists": True,
|
|
"type": key_type,
|
|
"depth": None,
|
|
"head": [],
|
|
"tail": [],
|
|
"error": f"Key '{key}' is a {key_type}, not a list.",
|
|
}
|
|
pipe = client.pipeline(transaction=False)
|
|
pipe.llen(key)
|
|
if head_n:
|
|
pipe.lrange(key, 0, head_n - 1)
|
|
if tail_n:
|
|
pipe.lrange(key, -tail_n, -1)
|
|
results = pipe.execute()
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_list_depth")
|
|
|
|
depth = results[0]
|
|
cursor = 1
|
|
head_values: list[Any] = []
|
|
if head_n:
|
|
head_values = results[cursor]
|
|
cursor += 1
|
|
tail_values: list[Any] = results[cursor] if tail_n else []
|
|
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"key": key,
|
|
"exists": True,
|
|
"type": "list",
|
|
"depth": depth,
|
|
"head": [_truncate_value(value) for value in head_values],
|
|
"tail": [_truncate_value(value) for value in tail_values],
|
|
}
|
|
|
|
|
|
def get_latency_doctor(
|
|
config: RedisConfig,
|
|
event: str = "",
|
|
history_limit: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return ``LATENCY DOCTOR`` analysis plus the latest monitored events.
|
|
|
|
Read-only. ``LATENCY DOCTOR`` produces a human-readable diagnosis of recent
|
|
latency spikes (fork/RDB save, AOF rewrite, blocking commands, slow disk).
|
|
``LATENCY LATEST`` lists each monitored event's latest/max spike; when
|
|
``event`` is given, ``LATENCY HISTORY`` for that event is included (capped at
|
|
``history_limit`` or ``config.max_results``). ``monitoring_active`` reflects
|
|
whether latency monitoring is *enabled* (``latency-monitor-threshold`` > 0),
|
|
read via ``CONFIG GET`` — so a healthy, enabled-but-quiet server is reported
|
|
as active even when no spikes have been recorded yet. The threshold read is
|
|
best-effort: if the ACL forbids ``CONFIG GET`` it falls back to whether any
|
|
events exist, and ``monitoring_threshold_ms`` is ``None``.
|
|
"""
|
|
if not config.is_configured:
|
|
return {"source": "redis", "available": False, "error": "Not configured."}
|
|
|
|
event = str(event or "").strip()
|
|
# Floor at 0 (mirrors get_list_depth's head/tail clamp): a negative
|
|
# history_limit is truthy, so without max(0, ...) it would survive the min()
|
|
# and turn ``history_raw[:history_cap]`` into a back-truncating slice that
|
|
# silently drops the most recent events instead of capping the count.
|
|
history_cap = max(0, min(history_limit or config.max_results, config.max_results))
|
|
try:
|
|
client = _get_client(config)
|
|
try:
|
|
# redis-py intentionally does not implement latency_doctor() (its
|
|
# output is human-readable, not parseable), so issue the raw command.
|
|
report = client.execute_command("LATENCY", "DOCTOR")
|
|
latest_raw = client.latency_latest()
|
|
history_raw = client.latency_history(event) if event else []
|
|
# Best-effort read of whether monitoring is enabled. CONFIG GET is
|
|
# read-only (only CONFIG SET is out of scope); guard it separately so
|
|
# an ACL that forbids CONFIG doesn't fail the whole tool.
|
|
threshold_ms: int | None = None
|
|
try:
|
|
cfg = client.config_get("latency-monitor-threshold")
|
|
threshold_ms = safe_int(cfg.get("latency-monitor-threshold", 0), 0)
|
|
except Exception:
|
|
threshold_ms = None
|
|
finally:
|
|
client.close()
|
|
except Exception as err:
|
|
return _redis_error(err, "get_latency_doctor")
|
|
|
|
latest = [
|
|
{
|
|
"event": str(row[0]),
|
|
"last_occurrence": row[1],
|
|
"latest_ms": row[2],
|
|
"max_ms": row[3],
|
|
}
|
|
for row in latest_raw
|
|
if isinstance(row, (list, tuple)) and len(row) >= 4
|
|
]
|
|
history = [
|
|
{"timestamp": row[0], "latency_ms": row[1]}
|
|
for row in history_raw[:history_cap]
|
|
if isinstance(row, (list, tuple)) and len(row) >= 2
|
|
]
|
|
|
|
monitoring_active = threshold_ms > 0 if threshold_ms is not None else bool(latest)
|
|
return {
|
|
"source": "redis",
|
|
"available": True,
|
|
"report": str(report),
|
|
"monitoring_active": monitoring_active,
|
|
"monitoring_threshold_ms": threshold_ms,
|
|
"monitored_events": len(latest),
|
|
"latest": latest,
|
|
"event": event,
|
|
"history": history,
|
|
}
|
|
|
|
|
|
def _redis_error(err: Exception, method: str) -> dict[str, Any]:
|
|
"""Normalize a Redis exception into a graceful, available=False payload.
|
|
|
|
Authentication and permission failures return a friendly hint without a
|
|
Sentry report; all other errors are reported for diagnosis. Errors are
|
|
classified by redis-py's typed exceptions rather than message substrings.
|
|
"""
|
|
import redis.exceptions as redis_exc
|
|
|
|
if isinstance(err, redis_exc.AuthenticationError):
|
|
return {
|
|
"source": "redis",
|
|
"available": False,
|
|
"error": "Redis authentication failed. Check the credentials in the connection settings.",
|
|
}
|
|
if isinstance(err, redis_exc.NoPermissionError):
|
|
return {
|
|
"source": "redis",
|
|
"available": False,
|
|
"error": (
|
|
"Redis user lacks permission for this command. Grant the user read "
|
|
"access to the diagnostic commands it needs (e.g. INFO, CLIENT, "
|
|
"SLOWLOG, LATENCY, LLEN/LRANGE, TYPE, SCAN)."
|
|
),
|
|
}
|
|
report_validation_failure(
|
|
err,
|
|
logger=logger,
|
|
integration="redis",
|
|
method=method,
|
|
)
|
|
return {"source": "redis", "available": False, "error": str(err)}
|
|
|
|
|
|
def classify(
|
|
credentials: dict[str, Any], record_id: str
|
|
) -> tuple[RedisIntegrationConfig | None, str | None]:
|
|
try:
|
|
cfg = RedisIntegrationConfig.model_validate(
|
|
{
|
|
"host": credentials.get("host", ""),
|
|
"port": credentials.get("port", 6379),
|
|
"username": credentials.get("username", ""),
|
|
"password": credentials.get("password", ""),
|
|
"db": credentials.get("db", 0),
|
|
"ssl": credentials.get("ssl", False),
|
|
"integration_id": record_id,
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
report_classify_failure(exc, logger=logger, integration="redis", record_id=record_id)
|
|
return None, None
|
|
if cfg.host:
|
|
return cfg, "redis"
|
|
return None, None
|