From d25efb097c8ba6fa6214e72a8fa80d8b1dcb4765 Mon Sep 17 00:00:00 2001 From: brobert Date: Fri, 17 Oct 2025 09:01:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20m=C3=A9tricas=20con=20lab?= =?UTF-8?q?els=20y=20calcular=20alias=5Fcoverage=5Fratio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 15 ++++++++++ src/services/command.ts | 10 +++++++ src/services/contacts.ts | 3 ++ src/services/group-sync.ts | 36 +++++++++++++++++++++++ src/services/metrics.ts | 60 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 20d1587..4760d0e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -282,6 +282,15 @@ export class WebhookServer { try { const nAlt = normalizeWhatsAppId(pAlt); const n = normalizeWhatsAppId(p); + if (process.env.NODE_ENV !== 'test') { + console.log('[A0] message.key participants', { + participant: p, + participantAlt: pAlt, + normalized_participant: n, + normalized_participantAlt: nAlt, + alias_upsert: !!(nAlt && n && nAlt !== n) + }); + } if (nAlt && n && nAlt !== n) { IdentityService.upsertAlias(p, pAlt, 'message.key'); } @@ -504,6 +513,12 @@ export class WebhookServer { const PORT = process.env.PORT || '3007'; console.log('✅ Environment variables validated'); + // A0: pre-crear contadores para que aparezcan en /metrics + try { + Metrics.inc('onboarding_prompts_sent_total', 0); + Metrics.inc('onboarding_prompts_skipped_total', 0); + Metrics.inc('onboarding_assign_failures_total', 0); + } catch {} if (process.env.NODE_ENV !== 'test') { try { diff --git a/src/services/command.ts b/src/services/command.ts index d00fe4a..0f859d3 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1041,6 +1041,16 @@ export class CommandService { ...normalizedFromAtTokens ])); + if (process.env.NODE_ENV !== 'test') { + console.log('[A0] /t nueva menciones', { + context_mentions: context.mentions || [], + mentions_normalized: mentionsNormalizedFromContext, + at_tokens: atTokenCandidates, + at_normalized: normalizedFromAtTokens, + combined: combinedAssigneeCandidates + }); + } + const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext); // Asegurar creador diff --git a/src/services/contacts.ts b/src/services/contacts.ts index 5225d7d..d13bacd 100644 --- a/src/services/contacts.ts +++ b/src/services/contacts.ts @@ -67,6 +67,9 @@ export class ContactsService { const rawJid = typeof rec?.jid === 'string' ? rec.jid : null; if (rawId && rawJid && rawId.includes('@lid') && rawJid.endsWith('@s.whatsapp.net')) { IdentityService.upsertAlias(rawId, rawJid, 'contacts.update'); + if (process.env.NODE_ENV !== 'test') { + console.log('[A0] contacts.update learned alias', { alias: rawId, jid: rawJid }); + } } } catch {} diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 4342349..0c7ba57 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -629,9 +629,45 @@ export class GroupSyncService { } })(); + try { this.computeAndPublishAliasCoverage(groupId); } catch {} return { added, updated, deactivated }; } + private static computeAndPublishAliasCoverage(groupId: string): void { + try { + const rows = this.dbInstance.prepare(` + SELECT user_id + FROM group_members + WHERE group_id = ? AND is_active = 1 + `).all(groupId) as Array<{ user_id: string }>; + + const total = rows.length; + if (total === 0) { + try { Metrics.set('alias_coverage_ratio', 1, { group_id: groupId }); } catch {} + return; + } + + let resolvable = 0; + for (const r of rows) { + const uid = String(r.user_id || ''); + if (/^\d+$/.test(uid)) { + resolvable++; + continue; + } + try { + const resolved = IdentityService.resolveAliasOrNull(uid); + if (resolved && /^\d+$/.test(resolved)) { + resolvable++; + } + } catch {} + } + const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1)); + try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {} + } catch (e) { + console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e); + } + } + /** * Sync members for all active groups by calling Evolution API and reconciling. * Devuelve contadores agregados. diff --git a/src/services/metrics.ts b/src/services/metrics.ts index 7ebd228..ce02942 100644 --- a/src/services/metrics.ts +++ b/src/services/metrics.ts @@ -1,6 +1,8 @@ export class Metrics { private static counters = new Map(); private static gauges = new Map(); + private static labeledCounters = new Map>(); + private static labeledGauges = new Map>(); static enabled(): boolean { if (typeof process !== 'undefined' && process.env) { @@ -13,14 +15,48 @@ export class Metrics { return true; } - static inc(name: string, value: number = 1): void { + private static serializeLabels(labels: Record | undefined | null): string | null { + if (!labels) return null; + const keys = Object.keys(labels).sort(); + const parts = keys.map(k => { + const val = String(labels[k] ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `${k}="${val}"`; + }); + return parts.join(','); + } + + private static ensureLabeledMap(map: Map>, name: string): Map { + let inner = map.get(name); + if (!inner) { + inner = new Map(); + map.set(name, inner); + } + return inner; + } + + static inc(name: string, value: number = 1, labels?: Record): void { if (!this.enabled()) return; + if (labels && Object.keys(labels).length > 0) { + const key = this.serializeLabels(labels); + if (!key) return; + const inner = this.ensureLabeledMap(this.labeledCounters, name); + const v = inner.get(key) || 0; + inner.set(key, v + value); + return; + } const v = this.counters.get(name) || 0; this.counters.set(name, v + value); } - static set(name: string, value: number): void { + static set(name: string, value: number, labels?: Record): void { if (!this.enabled()) return; + if (labels && Object.keys(labels).length > 0) { + const key = this.serializeLabels(labels); + if (!key) return; + const inner = this.ensureLabeledMap(this.labeledGauges, name); + inner.set(key, value); + return; + } this.gauges.set(name, value); } @@ -35,6 +71,12 @@ export class Metrics { const json = { counters: Object.fromEntries(this.counters.entries()), gauges: Object.fromEntries(this.gauges.entries()), + labeledCounters: Object.fromEntries( + Array.from(this.labeledCounters.entries()).map(([name, inner]) => [name, Object.fromEntries(inner.entries())]) + ), + labeledGauges: Object.fromEntries( + Array.from(this.labeledGauges.entries()).map(([name, inner]) => [name, Object.fromEntries(inner.entries())]) + ) }; return JSON.stringify(json); } @@ -43,15 +85,29 @@ export class Metrics { lines.push(`# TYPE ${k} counter`); lines.push(`${k} ${v}`); } + for (const [name, inner] of this.labeledCounters.entries()) { + lines.push(`# TYPE ${name} counter`); + for (const [labelKey, v] of inner.entries()) { + lines.push(`${name}{${labelKey}} ${v}`); + } + } for (const [k, v] of this.gauges.entries()) { lines.push(`# TYPE ${k} gauge`); lines.push(`${k} ${v}`); } + for (const [name, inner] of this.labeledGauges.entries()) { + lines.push(`# TYPE ${name} gauge`); + for (const [labelKey, v] of inner.entries()) { + lines.push(`${name}{${labelKey}} ${v}`); + } + } return lines.join('\n') + '\n'; } static reset(): void { this.counters.clear(); this.gauges.clear(); + this.labeledCounters.clear(); + this.labeledGauges.clear(); } }