feat: añade métricas, health detallada, mantenimiento y tests

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent cd0f2adf1a
commit 8983cfa453

@ -11,6 +11,8 @@ import { ContactsService } from './services/contacts';
import { Migrator } from './db/migrator';
import { RateLimiter } from './services/rate-limit';
import { RemindersService } from './services/reminders';
import { Metrics } from './services/metrics';
import { MaintenanceService } from './services/maintenance';
// Bun is available globally when running under Bun runtime
declare global {
@ -53,9 +55,53 @@ export class WebhookServer {
}
static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint
// Health check endpoint y métricas
const url = new URL(request.url);
if (url.pathname.endsWith('/metrics')) {
if (request.method !== 'GET') {
return new Response('🚫 Method not allowed', { status: 405 });
}
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
const body = Metrics.render(format as any);
return new Response(body, {
status: 200,
headers: { 'Content-Type': format === 'json' ? 'application/json' : 'text/plain; version=0.0.4' }
});
}
if (url.pathname.endsWith('/health')) {
// /health?full=1 devuelve JSON con detalles
if (url.searchParams.get('full') === '1') {
try {
const rowG = WebhookServer.dbInstance.prepare(`SELECT COUNT(*) AS c, MAX(last_verified) AS lv FROM groups WHERE active = 1`).get() as any;
const rowM = WebhookServer.dbInstance.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as any;
const active_groups = Number(rowG?.c || 0);
const active_members = Number(rowM?.c || 0);
const lv = rowG?.lv ? String(rowG.lv) : null;
let last_sync_at: string | null = lv;
let snapshot_age_ms: number | null = null;
if (lv) {
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
snapshot_age_ms = Date.now() - ms;
}
}
const last_sync_ok = Metrics.get('last_sync_ok') ?? null;
const payload = { status: 'ok', active_groups, active_members, last_sync_at, snapshot_age_ms, last_sync_ok };
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
return new Response(JSON.stringify({ status: 'error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
return new Response('OK', { status: 200 });
}
@ -99,6 +145,11 @@ export class WebhookServer {
const evt = String(payload.event);
const evtNorm = evt.toLowerCase().replace(/_/g, '.');
// Contabilizar evento de webhook por tipo
try {
Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`);
} catch {}
switch (evtNorm) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
@ -139,6 +190,7 @@ export class WebhookServer {
stack: error instanceof Error ? error.stack : undefined,
time: new Date().toISOString()
});
try { Metrics.inc('webhook_errors_total'); } catch {}
return new Response('Invalid request', { status: 400 });
}
}
@ -340,6 +392,14 @@ export class WebhookServer {
} catch (e) {
console.error('❌ Failed to start ResponseQueue worker or cleanup scheduler:', e);
}
// Mantenimiento (cleanup de miembros inactivos)
try {
MaintenanceService.start();
console.log('✅ MaintenanceService started');
} catch (e) {
console.error('⚠️ Failed to start MaintenanceService:', e);
}
} catch (error) {
console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error);
process.exit(1);

@ -1,6 +1,7 @@
import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
// In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -71,6 +72,8 @@ export class GroupSyncService {
if (!this.shouldSync()) {
return { added: 0, updated: 0 };
}
const startedAt = Date.now();
Metrics.inc('sync_runs_total');
try {
const communityId = process.env.WHATSAPP_COMMUNITY_ID;
@ -108,9 +111,21 @@ export class GroupSyncService {
const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active FROM groups').all();
console.log(' Grupos en DB después de upsert:', dbGroupsAfter);
// Actualizar métricas
this.cacheActiveGroups();
Metrics.set('active_groups', this.activeGroupsCache.size);
const rowM = this.dbInstance.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as any;
Metrics.set('active_members', Number(rowM?.c || 0));
Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000));
Metrics.set('last_sync_ok', 1);
// Duración opcional
Metrics.set('last_sync_duration_ms', Date.now() - (typeof startedAt !== 'undefined' ? startedAt : Date.now()));
return result;
} catch (error) {
console.error('Group sync failed:', error);
Metrics.inc('sync_errors_total');
Metrics.set('last_sync_ok', 0);
throw error;
} finally {
this.lastSyncAttempt = Date.now();

@ -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…
Cancel
Save