/** * Snapshot export + Prometheus text push (Pushgateway-compatible). * * Jobs: * exportSnapshot — build JSON snapshot (optional write to PEARDATA_EXPORT_DIR) * prometheusPush — POST exposition text to PEARDATA_PUSHGATEWAY_URL * * Env: * PEARDATA_EXPORT_DIR — directory for snapshot-*.json * PEARDATA_PUSHGATEWAY_URL — e.g. http://127.0.0.1:9091/metrics/job/peardata */ import fs from 'fs' import path from 'path' import http from 'http' import https from 'https' import os from 'os' import b4a from 'b4a' import { getStore } from './store.js' import { getCollector } from './collector.js' import { getAnomalyEngine } from './anomaly.js' import { listAlerts } from './alerts.js' import { getServerPublicKeyHex } from '../core/auth-keys.js' import { APP_NAME, APP_VERSION } from '../../shared/protocol.js' import { CHART_BY_ID } from '../../shared/metrics.js' import logger from '../utils/logger.js' const log = logger.child('export') /** * @returns {object} */ export function buildSnapshot() { const store = getStore() const collector = getCollector() const pk = (() => { try { return getServerPublicKeyHex() } catch { return null } })() return { success: true, app: APP_NAME, version: APP_VERSION, hostname: os.hostname(), publicKeyHex: pk, node: collector.getNodeInfo(pk, APP_VERSION), latest: store.latestValues(), health: getAnomalyEngine().getHealth(), alerts: listAlerts(), ts: Date.now(), } } /** * Convert latest values to Prometheus exposition format. * @param {Record }>} [latest] */ export function toPrometheusText(latest) { const data = latest || getStore().latestValues() const lines = [ `# HELP peardata_info PearData agent info`, `# TYPE peardata_info gauge`, `peardata_info{version="${APP_VERSION}",hostname="${os.hostname()}"} 1`, ] for (const [chart, point] of Object.entries(data)) { const metric = `peardata_${chart.replace(/[^a-zA-Z0-9_]/g, '_')}` const ctx = CHART_BY_ID.get(chart)?.context || chart for (const [dim, val] of Object.entries(point.values || {})) { if (val == null || Number.isNaN(val)) continue lines.push( `${metric}{dimension="${dim}",context="${ctx}"} ${val}` ) } } return lines.join('\n') + '\n' } /** * @param {object} snapshot * @returns {{ path: string|null, bytes: number }} */ export function writeSnapshotFile(snapshot) { const dir = process.env.PEARDATA_EXPORT_DIR if (!dir) return { path: null, bytes: 0 } fs.mkdirSync(dir, { recursive: true }) const file = path.join(dir, `snapshot-${snapshot.ts || Date.now()}.json`) const body = JSON.stringify(snapshot, null, 2) fs.writeFileSync(file, body) return { path: file, bytes: body.length } } /** * POST Prometheus text to Pushgateway (or any text receiver). * @param {string} url * @param {string} body * @param {number} [timeoutMs] */ export function pushPrometheusText(url, body, timeoutMs = 10_000) { return new Promise((resolve, reject) => { const u = new URL(url) const mod = u.protocol === 'https:' ? https : http const req = mod.request( { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'text/plain; version=0.0.4', 'Content-Length': b4a.byteLength(body), }, timeout: timeoutMs, }, (res) => { let data = '' res.on('data', (c) => { data += c }) res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`push failed HTTP ${res.statusCode}: ${data.slice(0, 200)}`)) return } resolve({ status: res.statusCode || 200, bytes: body.length }) }) } ) req.on('timeout', () => { req.destroy() reject(new Error('push timeout')) }) req.on('error', reject) req.write(body) req.end() }) } /** * Job handler: build + optional file write. */ export async function jobExportSnapshot() { const snapshot = buildSnapshot() const written = writeSnapshotFile(snapshot) if (written.path) log.info('Wrote snapshot', written) return { ok: true, ts: snapshot.ts, charts: Object.keys(snapshot.latest || {}).length, file: written.path, bytes: written.bytes, snapshot: written.path ? undefined : snapshot, } } /** * Job handler: push Prometheus text to gateway. * @param {{ url?: string }} [args] */ export async function jobPrometheusPush(args = {}) { const url = args.url || process.env.PEARDATA_PUSHGATEWAY_URL if (!url) { return { ok: false, error: 'PEARDATA_PUSHGATEWAY_URL (or args.url) required', } } const body = toPrometheusText() const res = await pushPrometheusText(url, body) log.info('Prometheus push ok', { url, ...res }) return { ok: true, ...res, url, lines: body.split('\n').length } }