Files
peardata/server/services/collectors/memcached.js
T
Raven Scott 2e1a3e9b06
CI / test (push) Successful in 1m24s
Release rolling / release (push) Has been cancelled
Updates
2026-07-18 19:44:32 -04:00

203 lines
5.2 KiB
JavaScript

/**
* Memcached stats collector (service plugin).
*
* Enable: PEARDATA_MEMCACHED=1
* Addr: PEARDATA_MEMCACHED_URL=127.0.0.1:11211
*
* Charts: memcached.ops, memcached.memory
*/
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('memcached')
const CHART_OPS = {
id: 'memcached.ops',
name: 'memcached.ops',
context: 'memcached.ops',
title: 'Memcached operations',
units: 'ops/s',
family: 'memcached',
chartType: 'line',
priority: 8500,
plugin: 'memcached',
dimensions: [
{ id: 'get_hits', name: 'get_hits', algorithm: 'absolute' },
{ id: 'get_misses', name: 'get_misses', algorithm: 'absolute' },
{ id: 'cmd_get', name: 'cmd_get', algorithm: 'absolute' },
{ id: 'cmd_set', name: 'cmd_set', algorithm: 'absolute' },
],
}
const CHART_MEMORY = {
id: 'memcached.memory',
name: 'memcached.memory',
context: 'memcached.memory',
title: 'Memcached memory',
units: 'bytes',
family: 'memcached',
chartType: 'area',
priority: 8510,
plugin: 'memcached',
dimensions: [
{ id: 'bytes', name: 'bytes', algorithm: 'absolute' },
{ id: 'limit_maxbytes', name: 'limit_maxbytes', algorithm: 'absolute' },
],
}
export function isMemcachedEnabled() {
const v = process.env.PEARDATA_MEMCACHED
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} urlOrHost
* @returns {{ host: string, port: number }}
*/
export function parseMemcachedAddr(urlOrHost) {
const raw = String(urlOrHost || '127.0.0.1:11211').trim()
if (raw.includes('://')) {
try {
const u = new URL(raw)
return {
host: u.hostname || '127.0.0.1',
port: Number(u.port) || 11211,
}
} catch {
// fall through
}
}
const [host, port] = raw.split(':')
return { host: host || '127.0.0.1', port: Number(port) || 11211 }
}
/**
* @param {string} body
* @returns {Record<string, number>}
*/
export function parseMemcachedStats(body) {
/** @type {Record<string, number>} */
const out = {}
for (const line of String(body || '').split(/\r?\n/)) {
const m = line.match(/^STAT\s+(\S+)\s+(\S+)/)
if (!m) continue
const n = Number(m[2])
if (Number.isFinite(n)) out[m[1]] = n
}
return out
}
/**
* @param {{ host: string, port: number }} addr
* @param {number} [timeoutMs]
* @returns {Promise<string>}
*/
export function fetchMemcachedStats(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('memcached timeout')), timeoutMs)
socket.on('connect', () => {
socket.write('stats\r\n')
})
socket.on('data', (chunk) => {
buf += chunk.toString('utf8')
if (buf.includes('END')) done(null, buf)
})
socket.on('error', (err) => done(err))
socket.on('end', () => {
if (buf) done(null, buf)
else done(new Error('memcached closed with no data'))
})
})
}
export class MemcachedCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'memcached', intervalMs: opts.intervalMs })
this.addr = parseMemcachedAddr(
opts.url || process.env.PEARDATA_MEMCACHED_URL || '127.0.0.1:11211'
)
/** @type {{ get_hits: number, get_misses: number, cmd_get: number, cmd_set: number, wallMs: number }|null} */
this._prev = null
}
isEnabled() {
return isMemcachedEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_OPS)
registerChart(CHART_MEMORY)
log.info('Memcached collector started', this.addr)
super.start()
}
async collect() {
const raw = await fetchMemcachedStats(this.addr)
const stats = parseMemcachedStats(raw)
const ts = Date.now()
const cur = {
get_hits: Number(stats.get_hits) || 0,
get_misses: Number(stats.get_misses) || 0,
cmd_get: Number(stats.cmd_get) || 0,
cmd_set: Number(stats.cmd_set) || 0,
}
/** @type {Record<string, number>} */
const rates = { get_hits: 0, get_misses: 0, cmd_get: 0, cmd_set: 0 }
if (this._prev && ts > this._prev.wallMs) {
const dt = (ts - this._prev.wallMs) / 1000
if (dt > 0) {
for (const k of Object.keys(rates)) {
rates[k] = Math.max(0, (cur[k] - this._prev[k]) / dt)
}
}
}
this._prev = { ...cur, wallMs: ts }
return [
{
chart: 'memcached.ops',
context: 'memcached.ops',
ts,
values: rates,
},
{
chart: 'memcached.memory',
context: 'memcached.memory',
ts,
values: {
bytes: Number(stats.bytes) || 0,
limit_maxbytes: Number(stats.limit_maxbytes) || 0,
},
},
]
}
}
/** @type {MemcachedCollector|null} */
let singleton = null
export function getMemcachedCollector() {
if (!singleton) singleton = new MemcachedCollector()
return singleton
}