diff --git a/src/server.ts b/src/server.ts index 8ff835b..7bf3231 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { - // 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); diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 4367c10..534ae45 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -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(); // 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(); diff --git a/src/services/maintenance.ts b/src/services/maintenance.ts new file mode 100644 index 0000000..b68e01e --- /dev/null +++ b/src/services/maintenance.ts @@ -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 { + 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; + } +} diff --git a/src/services/metrics.ts b/src/services/metrics.ts new file mode 100644 index 0000000..7ebd228 --- /dev/null +++ b/src/services/metrics.ts @@ -0,0 +1,57 @@ +export class Metrics { + private static counters = new Map(); + private static gauges = new Map(); + + 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(); + } +} diff --git a/tests/unit/services/cleanup-inactive.test.ts b/tests/unit/services/cleanup-inactive.test.ts new file mode 100644 index 0000000..1ab8aca --- /dev/null +++ b/tests/unit/services/cleanup-inactive.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/metrics-health.test.ts b/tests/unit/services/metrics-health.test.ts new file mode 100644 index 0000000..a65810e --- /dev/null +++ b/tests/unit/services/metrics-health.test.ts @@ -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); + } + }); +});