feat: añade métricas, health detallada, mantenimiento y tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>pull/1/head
parent
cd0f2adf1a
commit
8983cfa453
@ -0,0 +1,47 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { db } from '../db';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export class MaintenanceService {
|
||||
private static _timer: any = null;
|
||||
|
||||
private static get retentionDays(): number {
|
||||
const v = Number(process.env.GROUP_MEMBERS_INACTIVE_RETENTION_DAYS);
|
||||
if (Number.isFinite(v)) return v;
|
||||
return 180; // por defecto 180 días
|
||||
}
|
||||
|
||||
static start(): void {
|
||||
if (process.env.NODE_ENV === 'test' && process.env.FORCE_SCHEDULERS !== 'true') return;
|
||||
if (this.retentionDays <= 0) return;
|
||||
|
||||
const intervalMs = 24 * 60 * 60 * 1000; // diario
|
||||
this._timer = setInterval(() => {
|
||||
this.cleanupInactiveMembersOnce().catch(err => {
|
||||
console.error('❌ Error en cleanup de miembros inactivos:', err);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
static stop(): void {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
static async cleanupInactiveMembersOnce(instance: Database = db, retentionDays: number = this.retentionDays): Promise<number> {
|
||||
if (retentionDays <= 0) return 0;
|
||||
const threshold = toIsoSql(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000));
|
||||
const res = instance.prepare(`
|
||||
DELETE FROM group_members
|
||||
WHERE is_active = 0
|
||||
AND last_seen_at < ?
|
||||
`).run(threshold);
|
||||
const deleted = Number(res?.changes || 0);
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
export class Metrics {
|
||||
private static counters = new Map<string, number>();
|
||||
private static gauges = new Map<string, number>();
|
||||
|
||||
static enabled(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
if (process.env.METRICS_ENABLED != null) {
|
||||
const v = process.env.METRICS_ENABLED.toLowerCase();
|
||||
return v === 'true' || v === '1' || v === 'yes';
|
||||
}
|
||||
return process.env.NODE_ENV !== 'test';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static inc(name: string, value: number = 1): void {
|
||||
if (!this.enabled()) return;
|
||||
const v = this.counters.get(name) || 0;
|
||||
this.counters.set(name, v + value);
|
||||
}
|
||||
|
||||
static set(name: string, value: number): void {
|
||||
if (!this.enabled()) return;
|
||||
this.gauges.set(name, value);
|
||||
}
|
||||
|
||||
static get(name: string): number | undefined {
|
||||
if (this.gauges.has(name)) return this.gauges.get(name);
|
||||
if (this.counters.has(name)) return this.counters.get(name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static render(format: 'prom' | 'json' = 'prom'): string {
|
||||
if (format === 'json') {
|
||||
const json = {
|
||||
counters: Object.fromEntries(this.counters.entries()),
|
||||
gauges: Object.fromEntries(this.gauges.entries()),
|
||||
};
|
||||
return JSON.stringify(json);
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const [k, v] of this.counters.entries()) {
|
||||
lines.push(`# TYPE ${k} counter`);
|
||||
lines.push(`${k} ${v}`);
|
||||
}
|
||||
for (const [k, v] of this.gauges.entries()) {
|
||||
lines.push(`# TYPE ${k} gauge`);
|
||||
lines.push(`${k} ${v}`);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
this.counters.clear();
|
||||
this.gauges.clear();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { MaintenanceService } from '../../../src/services/maintenance';
|
||||
|
||||
function toIso(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
const envBackup = { ...process.env };
|
||||
let memdb: Database;
|
||||
|
||||
describe('MaintenanceService - cleanup de miembros inactivos', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...envBackup, NODE_ENV: 'test' };
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
|
||||
memdb.exec(`INSERT INTO users (id) VALUES ('u1'), ('u2'), ('u3')`);
|
||||
memdb.exec(`INSERT INTO groups (id, community_id, name, active, last_verified) VALUES ('g1@g.us','comm','G1',1, ?)`, toIso(new Date()));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
test('elimina inactivos con last_seen_at más viejo que el umbral', async () => {
|
||||
const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000); // 200 días
|
||||
const recent = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000); // 50 días
|
||||
const now = new Date();
|
||||
|
||||
// Sembrar miembros: dos inactivos (uno viejo, uno reciente) y uno activo
|
||||
memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES
|
||||
('g1@g.us','u1',0,0,?,?),
|
||||
('g1@g.us','u2',0,0,?,?),
|
||||
('g1@g.us','u3',0,1,?,?)`,
|
||||
toIso(old), toIso(old),
|
||||
toIso(recent), toIso(recent),
|
||||
toIso(now), toIso(now)
|
||||
);
|
||||
|
||||
const before = memdb.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 0`).get() as any;
|
||||
expect(Number(before.c)).toBe(2);
|
||||
|
||||
const deleted = await MaintenanceService.cleanupInactiveMembersOnce(memdb, 180);
|
||||
expect(deleted).toBe(1);
|
||||
|
||||
const after = memdb.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 0`).get() as any;
|
||||
expect(Number(after.c)).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { WebhookServer } from '../../../src/server';
|
||||
import { Metrics } from '../../../src/services/metrics';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
function toIso(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
const envBackup = { ...process.env };
|
||||
let memdb: Database;
|
||||
|
||||
describe('/metrics y /health (detallado)', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...envBackup, NODE_ENV: 'test', METRICS_ENABLED: 'true' };
|
||||
Metrics.reset();
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(WebhookServer as any).dbInstance = memdb;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
test('/metrics devuelve métricas en formato Prometheus', async () => {
|
||||
// Sembrar algunas métricas
|
||||
Metrics.set('last_sync_ok', 1);
|
||||
Metrics.set('active_groups', 2);
|
||||
Metrics.inc('webhook_events_total_messages_upsert', 3);
|
||||
|
||||
const res = await WebhookServer.handleRequest(new Request('http://localhost/metrics', { method: 'GET' }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('last_sync_ok');
|
||||
expect(body).toContain('active_groups 2');
|
||||
expect(body).toContain('webhook_events_total_messages_upsert 3');
|
||||
});
|
||||
|
||||
test('/health?full=1 devuelve JSON con contadores y snapshot', async () => {
|
||||
// Insertar datos
|
||||
memdb.exec(`INSERT INTO groups (id, community_id, name, active, last_verified) VALUES ('123@g.us','comm','Grupo 123',1, ?)`, toIso(new Date(Date.now() - 60_000)));
|
||||
memdb.exec(`INSERT INTO users (id) VALUES ('34600123456')`);
|
||||
memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('123@g.us','34600123456',0,1,?,?)`,
|
||||
toIso(new Date()), toIso(new Date()));
|
||||
|
||||
const res = await WebhookServer.handleRequest(new Request('http://localhost/health?full=1', { method: 'GET' }));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.status).toBe('ok');
|
||||
expect(typeof json.active_groups).toBe('number');
|
||||
expect(typeof json.active_members).toBe('number');
|
||||
// snapshot_age_ms debe ser null o un número >= 0
|
||||
if (json.snapshot_age_ms !== null) {
|
||||
expect(json.snapshot_age_ms).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue