Contexto del Problema
El módulo de Plan de Pagos de NutriFlow recibe quejas de "el pago no funcionó" sin ninguna telemetría que permita diagnosticar la causa. El equipo opera ciego: solo hay console.log no estructurados, sin correlation IDs ni dashboards. Este plan establece la instrumentación mínima viable para producción desde el primer deploy.
¿Qué porcentaje de cobros iniciales fallan y cuál es la razón (timeout, fondos insuficientes, tarjeta inválida)?
¿Cuánto tarda MercadoPago en responder en p95/p99? ¿Hay degradación durante horas pico?
¿El worker nocturno procesó todos los registros o hubo errores silenciosos que dejaron suscripciones en estado incorrecto?
¿Una solicitud de pago fallida puede rastrearse end-to-end (log → traza → span de proveedor) sin leer el código fuente?
| Pregunta | Señal | Qué responde | Ejemplo concreto |
|---|---|---|---|
| ¿Por qué falló este cobro específico? | log | "¿Qué pasó en este caso?" | payment_failed con errorCode: MP_INSUFFICIENT_FUNDS |
| ¿Cuántos cobros fallan por hora? | métrica | "¿Con qué frecuencia?" | subscriptions_payment_total{status="failed"} |
| ¿MercadoPago está más lento de lo normal? | métrica | "¿Cuánto tarda, en agregado?" | Histograma mercadopago_request_duration_seconds p99 |
| ¿Dónde se fue el tiempo en un pago lento? | traza | "¿Dónde exactamente?" | Span applyDiscount → chargeProvider → updateSubscription |
| ¿El worker acabó con errores? | log + alerta | "¿Qué pasó?" + "¿Me avisa?" | worker_run_completed con failedCount > 0 → alerta ticket |
// Correlation ID por request — obligatorio en toda la stack import pino from 'pino'; import crypto from 'crypto'; export const logger = pino({ level: 'info', formatters: { level: (label) => ({ level: label }) } }); export function requestContextMiddleware(req, res, next) { const requestId = req.headers['x-request-id'] ?? crypto.randomUUID(); req.id = requestId; req.log = logger.child({ requestId, service: 'checkout-service' }); res.setHeader('x-request-id', requestId); next(); }
async function createSubscription(req: Request, dto: CreateSubscriptionDto) { const { log } = req; log.info({ event: 'subscription_create_started', clinicId: dto.clinicId, planId: dto.planId, amount: dto.amountCents }, 'subscription create started'); try { const charge = await mercadopago.charge({ amount: dto.amountCents, token: dto.cardToken }); log.info({ event: 'payment_succeeded', clinicId: dto.clinicId, chargeId: charge.id, provider: 'mercadopago', attempt: 1 }, 'initial payment succeeded'); return await db.createSubscription({ ...dto, chargeId: charge.id, status: 'active' }); } catch (err) { log.warn({ event: 'payment_failed', clinicId: dto.clinicId, provider: 'mercadopago', errorCode: err.code, // MP_INSUFFICIENT_FUNDS, MP_INVALID_CARD... errorType: err.type, attempt: 1 }, 'initial payment failed'); // warn: degraded pero manejado throw err; } } // Worker nocturno — siempre loguear resumen al final async function processExpiredSubscriptions() { const start = Date.now(); let processed = 0, failed = 0; for (const sub of await db.findExpiring()) { try { await renewSubscription(sub.id); processed++; } catch (e) { failed++; logger.error({ event: 'subscription_renewal_error', subscriptionId: sub.id, errorCode: e.code }, 'renewal failed'); } } logger.info({ event: 'worker_run_completed', processedCount: processed, failedCount: failed, durationMs: Date.now() - start }, 'worker finished'); }
import { Histogram, Counter, register } from 'prom-client'; // RED: Rate + Errors + Duration por endpoint export const httpDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'Latencia de requests HTTP', labelNames: ['method', 'route', 'status_class'], // '2xx' no '200' — cardinalidad fija buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], }); // RED específico de MercadoPago — dependencia externa crítica export const mpDuration = new Histogram({ name: 'mercadopago_request_duration_seconds', help: 'Latencia de llamadas al proveedor MercadoPago', labelNames: ['operation', 'status'], // 'charge'|'refund' · 'success'|'failed' buckets: [0.1, 0.25, 0.5, 1, 1.5, 3, 5], }); export const paymentTotal = new Counter({ name: 'subscriptions_payment_total', help: 'Total de intentos de cobro por resultado', labelNames: ['status', 'error_code'], // error_code: MP_INSUFFICIENT_FUNDS, etc. }); export const workerErrors = new Counter({ name: 'subscription_worker_errors_total', help: 'Errores del worker de renovación nocturna', labelNames: ['error_code'], }); // NUNCA como label: userId, email, requestId, URL completa, mensaje de error libre
import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; const sdk = new NodeSDK({ serviceName: 'checkout-service', traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }), instrumentations: [getNodeAutoInstrumentations()], // cubre HTTP, PG, Redis automáticamente }); sdk.start();
import { trace, context } from '@opentelemetry/api'; const tracer = trace.getTracer('checkout-service'); async function createSubscription(req, dto) { return tracer.startActiveSpan('subscription.create', async (span) => { span.setAttributes({ 'clinic.id': dto.clinicId, 'subscription.plan_id': dto.planId, 'payment.amount_cents': dto.amountCents, }); const charge = await tracer.startActiveSpan('mercadopago.charge', async (mpSpan) => { try { const result = await mercadopago.charge(dto); mpSpan.setAttributes({ 'mp.charge_id': result.id, 'mp.status': 'success' }); return result; } catch (e) { mpSpan.setAttributes({ 'mp.error_code': e.code, 'mp.status': 'failed' }); mpSpan.recordException(e); throw e; } finally { mpSpan.end(); } }); span.end(); return charge; }); }
Tasa de error de pagos elevada
event=payment_failed agrupados por errorCode. 2) Comprobar dashboard MercadoPago. 3) Si error_code = MP_PROVIDER_DOWN → escalar a payments@nutriflow.comLatencia p99 de MercadoPago sobre SLO
service=checkout-service, span=mercadopago.charge. 2) Comparar latencia de las últimas 24h en el dashboard. 3) Si es generalizado → activar fallback o comunicar degradación.Worker de renovación con errores
event=subscription_renewal_error del último run. 2) Listar subscriptionId afectados. 3) Revisar BD — ¿estado inconsistente? 4) Reprocesar manualmente o reintentar en próximo run.Worker nocturno no completó en ventana esperada
- Las 4 preguntas de on-call están escritas y cada señal mapea a una.
- Todo log output es JSON estructurado con
eventestable yrequestIden cada línea. - No se loguea
cardToken, email, ni ningún dato PII — campos allowlisteados explícitamente. - Métricas RED existen para
POST /api/subscriptions, el worker y MercadoPago. - Latencia es histograma; p95/p99 son consultables en Prometheus.
- Una request puede seguirse end-to-end en la UI de trazado sin spans rotos.
- Cada alerta es síntoma-based, tiene runbook y fue test-fired una vez.
- Inducir un error en staging y localizarlo por
requestIdsolo con telemetría (sin leer código).
`Payment ${id} failed for user ${userId}` — no queryable.