Files
peardata/server/services/collectors/redis.js
T
Raven Scott 1d46b7b3ad
CI / test (push) Failing after 6s
Release rolling / release (push) Has been cancelled
updates
2026-07-18 19:11:32 -04:00

228 lines
5.7 KiB
JavaScript

/**
* 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<string, string>}
*/
export function parseRedisInfo(body) {
/** @type {Record<string, string>} */
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<string>}
*/
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: $<len>\r\n<body>\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
}