Diseñando Sistemas Observables desde el Inicio

Por: Artiko
disenoarquitecturainstrumentacionopentelemetryproduccionbest-practiceschecklist

Diseñando Sistemas Observables desde el Inicio

El costo de la observabilidad retroactiva

Hay dos formas de tener un sistema observable: diseñarlo para serlo desde el principio, o intentar añadir observabilidad a un sistema existente.

La segunda opción es dramáticamente más costosa. Cuando un sistema no fue diseñado con observabilidad en mente:

El costo de instrumentar retroactivamente un sistema maduro puede ser meses de trabajo de ingeniería. El costo de diseñar con observabilidad desde el inicio es marginal — quizás un 10-15% de overhead en el tiempo de desarrollo.

Este capítulo te da el framework para hacer lo segundo.


La observabilidad como requisito no funcional

Los requisitos no funcionales (NFRs) son las propiedades del sistema que definen cómo funciona, en contraste con los funcionales que definen qué hace.

Seguridad, performance, escalabilidad, mantenibilidad — estos son NFRs universalmente aceptados. La observabilidad debe estar en esa lista.

Definiendo el contrato de observabilidad de un servicio

Antes de construir un servicio, define explícitamente:

# observability-contract.yaml
service: payment-service
version: 1.0

metrics:
  - name: payments_processed_total
    type: counter
    labels: [status, gateway, currency]
    description: "Total number of payment processing attempts"
  
  - name: payment_duration_seconds
    type: histogram
    buckets: [0.1, 0.5, 1, 2, 5, 10]
    labels: [gateway, status]
    description: "Payment processing duration"

logs:
  required_fields: [timestamp, level, service, trace_id, span_id]
  business_events:
    - payment_initiated
    - payment_succeeded
    - payment_failed
    - payment_retried

traces:
  auto_instrumented:
    - HTTP incoming/outgoing
    - Database queries
    - Queue operations
  manual_spans:
    - processPayment (business operation)
    - validatePaymentData
    - chargeGateway

slo:
  availability:
    target: 99.9%
    sli: "rate(payments_processed_total{status='success'}[5m]) / rate(payments_processed_total[5m])"
  latency:
    target_p99_ms: 3000
    sli: "histogram_quantile(0.99, payment_duration_seconds)"

Este contrato se incluye en el diseño técnico del servicio, se revisa en code review, y se valida en CI.


Patrones de instrumentación

Patrón 1: Middleware de observabilidad

Para APIs, el middleware es el lugar correcto para instrumentar la mayoría de las señales:

// observability.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { trace, metrics, context } from '@opentelemetry/api';

const meter = metrics.getMeter('api-gateway');
const requestCounter = meter.createCounter('http_requests_total');
const requestDuration = meter.createHistogram('http_request_duration_seconds');

export function observabilityMiddleware(req: Request, res: Response, next: NextFunction) {
  const startTime = Date.now();
  const span = trace.getActiveSpan();
  
  // Enriquecer span con contexto de la request
  if (span) {
    span.setAttribute('http.method', req.method);
    span.setAttribute('http.url', req.url);
    span.setAttribute('http.user_agent', req.headers['user-agent'] ?? '');
    
    // Contexto de negocio si está disponible
    if (req.user) {
      span.setAttribute('user.id', req.user.id);
      span.setAttribute('user.tier', req.user.tier);
    }
  }
  
  res.on('finish', () => {
    const duration = (Date.now() - startTime) / 1000;
    const attributes = {
      'http.method': req.method,
      'http.status_code': String(res.statusCode),
      'http.route': req.route?.path ?? 'unknown',
    };
    
    requestCounter.add(1, attributes);
    requestDuration.record(duration, attributes);
  });
  
  next();
}

Patrón 2: Wrapper de operaciones de negocio

Para lógica de negocio, un wrapper que instrumenta automáticamente:

// tracing.utils.ts
import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';

const tracer = trace.getTracer('business-operations');

export async function withSpan<T>(
  name: string,
  fn: () => Promise<T>,
  attributes: Record<string, string | number | boolean> = {}
): Promise<T> {
  return tracer.startActiveSpan(name, { kind: SpanKind.INTERNAL }, async (span) => {
    Object.entries(attributes).forEach(([key, value]) => {
      span.setAttribute(key, value);
    });
    
    try {
      const result = await fn();
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message });
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end();
    }
  });
}

// Uso en código de negocio:
async function processOrder(orderId: string) {
  return withSpan('processOrder', async () => {
    const order = await getOrder(orderId);
    
    await withSpan('validateInventory', () => checkInventory(order), { 
      'order.item_count': order.items.length 
    });
    
    await withSpan('chargePayment', () => chargePayment(order), {
      'payment.amount_cents': order.totalCents,
      'payment.gateway': order.paymentGateway
    });
    
    return order;
  }, { 'order.id': orderId });
}

Patrón 3: Logger contextual

Un logger que automáticamente incluye el contexto de traza activo:

// logger.ts
import { trace } from '@opentelemetry/api';
import pino from 'pino';

const baseLogger = pino({ level: process.env.LOG_LEVEL ?? 'info' });

type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';

function createLogMethod(level: LogLevel) {
  return (message: string, data?: Record<string, unknown>) => {
    const span = trace.getActiveSpan();
    const traceContext = span?.spanContext() ? {
      trace_id: span.spanContext().traceId,
      span_id: span.spanContext().spanId,
    } : {};
    
    baseLogger[level]({ ...traceContext, ...data }, message);
  };
}

export const logger = {
  trace: createLogMethod('trace'),
  debug: createLogMethod('debug'),
  info: createLogMethod('info'),
  warn: createLogMethod('warn'),
  error: createLogMethod('error'),
  fatal: createLogMethod('fatal'),
};

// Uso: el trace_id se inyecta automáticamente en cada log
logger.info('Payment processed', { orderId, amountCents, gateway });
logger.error('Payment failed', { orderId, error: err.message, attempt });

Observabilidad en diferentes capas del sistema

APIs y servicios HTTP

flowchart TD
    REQ[Request HTTP] --> MW[Middleware de Observabilidad]
    MW --> |Crea span, extrae trace context| HANDLER[Request Handler]
    HANDLER --> |Enriquece span| DB[Database Query]
    HANDLER --> |Propaga trace context| DOWNSTREAM[Downstream Service]
    DB --> |Span de DB automático| RESP[Response]
    RESP --> MW
    MW --> |Emite: counter, histogram| METRICS[Metrics Backend]
    MW --> |Emite: logs con trace_id| LOGS[Log Backend]
    MW --> |Cierra span| TRACES[Trace Backend]

Sistemas de colas y workers

Los sistemas asíncronos tienen el desafío de que la traza se “rompe” cuando el mensaje pasa por una cola. La solución es incluir el contexto de traza en el mensaje:

// Producer: inyecta contexto de traza en el mensaje
async function publishPaymentJob(orderId: string) {
  const span = trace.getActiveSpan();
  const traceContext = {
    traceId: span?.spanContext().traceId,
    traceFlags: span?.spanContext().traceFlags,
  };
  
  await queue.publish('payment-jobs', {
    orderId,
    _telemetry: traceContext  // Incluir en el payload
  });
  
  logger.info('Payment job published', { orderId });
}

// Consumer: restaura el contexto de traza desde el mensaje
async function processPaymentJob(message: QueueMessage) {
  const parentContext = message._telemetry;
  
  // Crear un nuevo span con link a la traza original
  const span = tracer.startSpan('processPaymentJob', {
    links: parentContext ? [{ context: parentContext }] : []
  });
  
  await context.with(trace.setSpan(context.active(), span), async () => {
    try {
      await doProcessPayment(message.orderId);
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.recordException(err);
    } finally {
      span.end();
    }
  });
}

Bases de datos y queries

La instrumentación automática de bases de datos con OpenTelemetry captura:

Pero falta el contexto de negocio. Agrega esto manualmente cuando sea relevante:

async function getUserOrders(userId: string): Promise<Order[]> {
  const span = trace.getActiveSpan();
  span?.setAttribute('query.purpose', 'fetch_user_orders');
  span?.setAttribute('query.user_id', userId);
  
  const startTime = Date.now();
  const orders = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
  
  span?.setAttribute('query.result_count', orders.length);
  span?.setAttribute('query.duration_ms', Date.now() - startTime);
  
  return orders;
}

Checklist de observabilidad para producción

Este checklist cubre lo mínimo que un servicio debe tener antes de ir a producción:

Logs

Métricas

Trazas

SLOs

Alertas

Documentación operativa


Anti-patrones de diseño que impiden la observabilidad

Anti-patrón 1: Logging de todo o de nada

Algunos equipos logean absolutamente todo (incluyendo cada línea de código de debug) o prácticamente nada (solo errores críticos). Ambos extremos son ineficientes.

La regla: logea eventos, no estados. “User authentication succeeded” (evento) es útil. “User object: {id: 1, name: ‘Juan’, role: ‘admin’, preferences: {…}}” (estado completo) es ruido.

Anti-patrón 2: Asumir que el sistema funciona si no hay errores

Un sistema puede estar procesando transacciones a 10% de su velocidad normal, sin errores, y ser un incidente serio. Sin métricas de throughput, no sabrías hasta que los usuarios se quejaran.

Siempre mide el volumen de operaciones completadas, no solo la tasa de error.

Anti-patrón 3: Health checks que no son representativos

// ❌ Health check inútil - solo verifica que el proceso está corriendo
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// ✅ Health check real - verifica las dependencias críticas
app.get('/health', async (req, res) => {
  const checks = await Promise.allSettled([
    db.query('SELECT 1'),
    redis.ping(),
    stripe.ping(),
  ]);
  
  const healthy = checks.every(c => c.status === 'fulfilled');
  const details = {
    database: checks[0].status,
    cache: checks[1].status,
    payment_gateway: checks[2].status,
  };
  
  res.status(healthy ? 200 : 503).json({ 
    status: healthy ? 'healthy' : 'degraded',
    checks: details
  });
});

Anti-patrón 4: Trazas solo en el happy path

Es tentador solo añadir spans a los flujos que “importan”. Pero los flujos de error son exactamente los que más necesitan trazas detalladas.

// ❌ Solo el happy path está instrumentado
async function processPayment(order) {
  return withSpan('processPayment', async () => {
    const result = await stripe.charge(order);
    return result;
    // Si esto falla, no hay información adicional del error
  });
}

// ✅ Paths de error también instrumentados
async function processPayment(order) {
  return withSpan('processPayment', async (span) => {
    span.setAttribute('payment.gateway', order.gateway);
    span.setAttribute('payment.amount_cents', order.amountCents);
    
    try {
      const result = await stripe.charge(order);
      span.setAttribute('payment.transaction_id', result.id);
      return result;
    } catch (error) {
      // Información detallada del error en el span
      span.setAttribute('error.type', error.constructor.name);
      span.setAttribute('error.stripe_code', error.code);
      span.setAttribute('error.decline_code', error.decline_code);
      span.recordException(error);
      throw error;
    }
  });
}

El futuro: observabilidad continua

La frontera actual de la observabilidad va más allá del debugging reactivo:

Anomaly Detection automática: Sistemas de ML que aprenden el comportamiento normal y alertan en anomalías sin necesitar umbrales manuales.

Observabilidad en CI/CD: Ejecutar tests de performance y observabilidad en cada PR. Si el p99 de una función crítica aumenta un 20%, el PR es rechazado automáticamente.

Correlación automática de cambios: Relacionar automáticamente un deploy con un cambio en las métricas. “El error rate aumentó justo después de este PR” sin que nadie tenga que buscarlo.

Análisis de causa raíz asistido por IA: Herramientas que, dado un incidente, sugieren automáticamente las causas más probables basadas en los datos de telemetría.


Cierre: los principios que perviven

Más allá de las herramientas específicas — que cambiarán en los próximos años — estos principios son permanentes:

  1. La observabilidad es una propiedad del sistema, no una herramienta: Instrumenta bien y puedes cambiar de backend cuando quieras.

  2. Los tres pilares son complementarios: Logs para el detalle, métricas para las tendencias, trazas para el flujo. Necesitas los tres.

  3. Alerta en síntomas, investiga causas: Las alertas deben corresponder a experiencia del usuario, no a estados de la infraestructura.

  4. Los SLOs son el lenguaje común entre ingeniería y negocio: Permiten tomar decisiones objetivas sobre el balance entre innovación y confiabilidad.

  5. La cultura blameless es prerequisito: Sin ella, los errores se ocultan y no se aprende de ellos.

  6. La observabilidad es una inversión que se paga sola: Equipos que invierten en ella reducen su MTTD/MTTR drásticamente y tienen ingenieros más felices.


Referencias finales del tutorial