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