228 lines
5.7 KiB
JavaScript
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
|
|
}
|