/** * Redis INFO collector (service plugin). * * Enable: PEARDATA_REDIS=1 * Addr: PEARDATA_REDIS_URL=redis://127.0.0.1:6379 (or host:port) * * Charts: redis.memory, redis.clients, redis.stats */ import net from 'net' import { CollectorPlugin } from './plugin.js' import { registerChart } from '../../../shared/metrics.js' import logger from '../../utils/logger.js' const log = logger.child('redis') const CHART_MEMORY = { id: 'redis.memory', name: 'redis.memory', context: 'redis.memory', title: 'Redis memory', units: 'MiB', family: 'redis', chartType: 'area', priority: 8100, plugin: 'redis', dimensions: [ { id: 'used', name: 'used', algorithm: 'absolute' }, { id: 'peak', name: 'peak', algorithm: 'absolute' }, { id: 'rss', name: 'rss', algorithm: 'absolute' }, ], } const CHART_CLIENTS = { id: 'redis.clients', name: 'redis.clients', context: 'redis.clients', title: 'Redis clients', units: 'clients', family: 'redis', chartType: 'line', priority: 8110, plugin: 'redis', dimensions: [ { id: 'connected', name: 'connected', algorithm: 'absolute' }, { id: 'blocked', name: 'blocked', algorithm: 'absolute' }, ], } const CHART_STATS = { id: 'redis.stats', name: 'redis.stats', context: 'redis.stats', title: 'Redis ops', units: 'ops/s', family: 'redis', chartType: 'line', priority: 8120, plugin: 'redis', dimensions: [ { id: 'ops', name: 'ops', algorithm: 'absolute' }, { id: 'hit_rate', name: 'hit_rate', algorithm: 'absolute' }, ], } export function isRedisEnabled() { const v = process.env.PEARDATA_REDIS return v === '1' || v === 'on' || v === 'true' } /** * @param {string} urlOrHost * @returns {{ host: string, port: number }} */ export function parseRedisAddr(urlOrHost) { const raw = String(urlOrHost || '127.0.0.1:6379').trim() if (raw.includes('://')) { try { const u = new URL(raw) return { host: u.hostname || '127.0.0.1', port: Number(u.port) || 6379, } } catch { // fall through } } const [host, port] = raw.split(':') return { host: host || '127.0.0.1', port: Number(port) || 6379 } } /** * @param {string} body Redis INFO text * @returns {Record} */ export function parseRedisInfo(body) { /** @type {Record} */ const out = {} for (const line of String(body || '').split(/\r?\n/)) { if (!line || line.startsWith('#')) continue const i = line.indexOf(':') if (i < 0) continue out[line.slice(0, i)] = line.slice(i + 1).trim() } return out } function bytesToMiB(n) { return n / (1024 * 1024) } /** * @param {{ host: string, port: number }} addr * @param {number} [timeoutMs] * @returns {Promise} */ export function fetchRedisInfo(addr, timeoutMs = 3000) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host: addr.host, port: addr.port }) let buf = '' let settled = false const done = (err, data) => { if (settled) return settled = true clearTimeout(timer) try { socket.destroy() } catch { // ignore } if (err) reject(err) else resolve(data) } const timer = setTimeout(() => done(new Error('redis timeout')), timeoutMs) socket.on('connect', () => { socket.write('INFO\r\n') }) socket.on('data', (chunk) => { buf += chunk.toString('utf8') // RESP bulk: $\r\n\r\n or plain INFO dump if (buf.includes('redis_version:') || buf.includes('used_memory:')) { const idx = buf.indexOf('$') if (idx === 0) { const nl = buf.indexOf('\r\n') if (nl > 0) { const body = buf.slice(nl + 2) if (body.includes('redis_version:') || body.length > 200) done(null, body) } } else { done(null, buf) } } }) socket.on('error', (err) => done(err)) socket.on('end', () => { if (buf) done(null, buf) else done(new Error('redis closed with no data')) }) }) } export class RedisCollector extends CollectorPlugin { constructor(opts = {}) { super({ name: 'redis', intervalMs: opts.intervalMs }) this.addr = parseRedisAddr(opts.url || process.env.PEARDATA_REDIS_URL || '127.0.0.1:6379') } isEnabled() { return isRedisEnabled() } start() { if (!this.isEnabled()) return registerChart(CHART_MEMORY) registerChart(CHART_CLIENTS) registerChart(CHART_STATS) log.info('Redis collector started', this.addr) super.start() } async collect() { const raw = await fetchRedisInfo(this.addr) const info = parseRedisInfo(raw) const ts = Date.now() const hits = Number(info.keyspace_hits) || 0 const misses = Number(info.keyspace_misses) || 0 const denom = hits + misses const hitRate = denom > 0 ? (hits / denom) * 100 : 0 return [ { chart: 'redis.memory', context: 'redis.memory', ts, values: { used: bytesToMiB(Number(info.used_memory) || 0), peak: bytesToMiB(Number(info.used_memory_peak) || 0), rss: bytesToMiB(Number(info.used_memory_rss) || 0), }, }, { chart: 'redis.clients', context: 'redis.clients', ts, values: { connected: Number(info.connected_clients) || 0, blocked: Number(info.blocked_clients) || 0, }, }, { chart: 'redis.stats', context: 'redis.stats', ts, values: { ops: Number(info.instantaneous_ops_per_sec) || 0, hit_rate: hitRate, }, }, ] } } /** @type {RedisCollector|null} */ let singleton = null export function getRedisCollector() { if (!singleton) singleton = new RedisCollector() return singleton }