La trampa de la auto-instrumentación

El paso inicial típico es aplicar el agente de auto-instrumentación sin configuración adicional.

Para Node.js con OpenTelemetry, la configuración inicial parece simple:

// tracing.js, cargado antes que todo via --require
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');

const sdk = new NodeSDK({
 traceExporter: new OTLPTraceExporter({
 url: 'http://otel-collector:4317',
 }),
 instrumentations: [getNodeAutoInstrumentations({
 '@opentelemetry/instrumentation-fs': { enabled: false }, // Creeme
 '@opentelemetry/instrumentation-http': {
 ignoreIncomingRequestHook: (req) => {
 return req.url === '/healthz' || req.url === '/readyz';
 },
 },
 })],
});

sdk.start();

¿Ven ese instrumentation-fs deshabilitado? Un descubrimiento común: el collector se cae porque la instrumentación de fs crea un span por cada lectura de archivo, incluyendo archivos de config, node_modules, archivos temporales, todo. El volumen de traces salta de 50MB/min a 12GB/min.

Para servicios en Go, el approach es diferente porque Go no tiene un modelo de agente en runtime:

package main

import (
 "context"
 "go.opentelemetry.io/otel"
 "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
 "go.opentelemetry.io/otel/sdk/resource"
 sdktrace "go.opentelemetry.io/otel/sdk/trace"
 semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initTracer() (*sdktrace.TracerProvider, error) {
 exporter, err := otlptracegrpc.New(context.Background(),
 otlptracegrpc.WithEndpoint("otel-collector:4317"),
 otlptracegrpc.WithInsecure(),
 )
 if err != nil {
 return nil, err
 }

 tp := sdktrace.NewTracerProvider(
 sdktrace.WithBatcher(exporter,
 sdktrace.WithMaxQueueSize(2048),
 sdktrace.WithMaxExportBatchSize(512),
 sdktrace.WithBatchTimeout(5*time.Second),
 ),
 sdktrace.WithResource(resource.NewWithAttributes(
 semconv.SchemaURL,
 semconv.ServiceNameKey.String("checkout-service"),
 semconv.ServiceVersionKey.String("1.4.2"),
 semconv.DeploymentEnvironmentKey.String("production"),
 )),
 sdktrace.WithSampler(sdktrace.ParentBased(
 sdktrace.TraceIDRatioBased(0.1),
 )),
 )

 otel.SetTracerProvider(tp)
 return tp, nil
}

La config de WithBatcher es crucial. Los defaults están bien para desarrollo, pero en producción con alto throughput, se va a tener overflows de cola. Los servicios pueden dropear el 40% de los spans en silencio porque la cola del batch estaba llena y el comportamiento por defecto es dropear, no bloquear.

Custom spans que de verdad sirven

La auto-instrumentación te da el esqueleto. Los custom spans te dan la carne. Pero los equipos a veces se pasan de rosca y crear spans para cada llamada a función, lo que transforma tus traces en ruido ilegible.

Una buena regla: creá custom spans para operaciones de negocio y dependencias externas que la auto-instrumentación no cubre.

func (s *CheckoutService) ProcessOrder(ctx context.Context, order *Order) error {
 ctx, span := s.tracer.Start(ctx, "checkout.process_order",
 trace.WithAttributes(
 attribute.String("order.id", order.ID),
 attribute.Int("order.items_count", len(order.Items)),
 attribute.String("order.payment_method", order.PaymentMethod),
 attribute.Float64("order.total", order.Total),
 ),
 )
 defer span.End()

 // Validar inventario, esto habla con otro servicio
 if err := s.validateInventory(ctx, order); err != nil {
 span.SetStatus(codes.Error, "inventory validation failed")
 span.RecordError(err, trace.WithAttributes(
 attribute.StringSlice("failed_skus", getFailedSKUs(err)),
 ))
 return fmt.Errorf("inventory check: %w", err)
 }

 // Procesar pago, gateway externo
 paymentSpan := trace.SpanFromContext(ctx)
 paymentSpan.AddEvent("payment.initiated", trace.WithAttributes(
 attribute.String("gateway", s.paymentGateway.Name()),
 ))

 charge, err := s.processPayment(ctx, order)
 if err != nil {
 span.SetStatus(codes.Error, "payment processing failed")
 span.RecordError(err)
 return fmt.Errorf("payment: %w", err)
 }

 span.SetAttributes(attribute.String("payment.charge_id", charge.ID))
 return nil
}

Noten un par de cosas. Estoy registrando el order.id como atributo para poder buscar traces por ID de orden. Estoy usando RecordError con atributos adicionales para capturar qué SKUs específicos fallaron. Y estoy usando span events para momentos significativos dentro de un mismo span (pago iniciado vs. pago completado).

Propagación de baggage: contexto cross-service bien hecho

Baggage es la feature más subutilizada de OpenTelemetry. Te deja adjuntar pares clave-valor al contexto que se propagan a través de las fronteras de servicios, sin ensuciar cada span con los mismos atributos.

// En el API gateway, inyectar contexto de tenant
import "go.opentelemetry.io/otel/baggage"

func gatewayMiddleware(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 tenantID := extractTenantID(r)
 region := resolveRegion(r)

 member1, _ := baggage.NewMember("tenant.id", tenantID)
 member2, _ := baggage.NewMember("region", region)
 bag, _ := baggage.New(member1, member2)

 ctx := baggage.ContextWithBaggage(r.Context(), bag)
 next.ServeHTTP(w, r.WithContext(ctx))
 })
}

// En CUALQUIER servicio downstream, leerlo sin saber cómo llegó
func (s *InventoryService) CheckStock(ctx context.Context, sku string) (int, error) {
 bag := baggage.FromContext(ctx)
 tenantID := bag.Member("tenant.id").Value()
 region := bag.Member("region").Value()

 // Rutear al inventario específico del tenant
 return s.repo.GetStock(ctx, tenantID, region, sku)
}

El collector también puede leer baggage y agregarlo como atributos de spans automáticamente:

# Config del otel-collector
processors:
 baggage:
 rules:
 - from: baggage
 key: tenant.id
 action: insert
 - from: baggage
 key: region
 action: insert

Esto es increíblemente poderoso para sistemas multi-tenant. En vez de pasar tenantID por cada firma de función, viaja con el contexto a través de toda la cadena de request.

Jaeger vs Tempo: la comparación verdad

Acá va un breakdown honesto basado en experiencia de producción.

Jaeger con Elasticsearch:

  • Setup: Jaeger collector + cluster Elasticsearch (3 nodos, r6g.xlarge)
  • Storage: ~2TB de traces, retención 7 días
  • Costo mensual: ~$2,400 (mayormente Elasticsearch)
  • Performance de queries: Rápido para lookup por trace ID, lento para búsqueda por tags a escala
  • Carga operativa: Manejo de índices de Elasticsearch, tuning de JVM, rebalanceo de shards

Grafana Tempo con S3:

  • Setup: Tempo (3 réplicas en modo microservicios) + bucket S3
  • Storage: ~2TB de traces, retención 30 días (sí, 4x más)
  • Costo mensual: ~$180 (almacenamiento S3 + un poco de compute)
  • Performance de queries: Lookup por trace ID es rápido, búsqueda por tags requiere TraceQL
  • Carga operativa: Mínima, ingesters stateless, object storage hace el trabajo pesado

La diferencia de costo no es un typo. Tempo usa object storage (S3, GCS, Azure Blob) en vez de un motor de búsqueda full-text. El trade-off es que no es posible hacer búsquedas arbitrarias por campo, se necesita usar TraceQL o integrar con Grafana Loki para correlación log-to-trace.

# TraceQL, encontrando traces lentas de checkout
{ span.checkout.process_order && duration > 5s }

# Con filtro de atributos
{ span.checkout.process_order
 && span.order.payment_method = "credit_card"
 && status = error }

La migración de Jaeger a Tempo suele ser una victoria clara. La factura de observabilidad pasa de generar terror a casi no notarse.

Trace-based testing: validación mediante observabilidad

Este es uno de los desarrollos más prometedores. Trace-based testing usa traces distribuidas reales para validar el comportamiento del sistema. En vez de simular todo, se ejecutan requests reales y se realizan assertions sobre la estructura de la trace.

Tracetest, que se engancha al collector de OTEL, permite esto:

# Definición de tracetest
type: Test
spec:
 name: "Checkout crea pago y manda confirmación"
 trigger:
 type: http
 httpRequest:
 url: http://api-gateway/api/v1/checkout
 method: POST
 body: '{"items": [{"sku": "WIDGET-1", "qty": 2}], "payment": "credit_card"}'
 specs:
 - selector: span[name="checkout.process_order"]
 assertions:
 - attr:order.items_count = 2
 - selector: span[name="payment.charge"]
 assertions:
 - attr:tracetest.span.duration < 2000ms
 - attr:payment.status = "captured"
 - selector: span[name="notification.send_email"]
 assertions:
 - attr:email.template = "order_confirmation"
 - attr:tracetest.span.duration < 500ms

Este test tira un request HTTP real, espera a que se recolecte la trace, y después hace assertions sobre atributos de spans a través de múltiples servicios. Es testing de integración con esteroides, se está validando no solo la respuesta, sino el comportamiento interno completo del sistema.

Correr estos en CI después de deployar a staging atrapa issues que los tests de integración tradicionales se pierden: un servicio que devuelve la respuesta correcta pero hace un round-trip extra a la base de datos, un consumer de cola de mensajes que procesa eventos pero no propaga el trace context, un cliente HTTP que hace fallback silencioso a un timeout default.

Control de costos: la guía de supervivencia

Los costos de observabilidad se pueden ir al más rápido de lo que considerars. Este es un approach probado de tres capas:

Capa 1: Head-based sampling en el SDK

# Variable de entorno, simple y efectivo
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

Esto samplea el 10% de las traces en el origen. El prefijo parentbased es crítico, significa que los spans hijos respetan la decisión de sampling del padre. Sin eso, te quedan spans huérfanos por todos lados.

Capa 2: Tail-based sampling en el collector

# Config del otel-collector
processors:
 tail_sampling:
 decision_wait: 30s
 policies:
 - name: errors-always
 type: status_code
 status_code:
 status_codes: [ERROR]
 - name: slow-traces
 type: latency
 latency:
 threshold_ms: 5000
 - name: percentage-sample
 type: probabilistic
 probabilistic:
 sampling_percentage: 5

Esto captura el 100% de las traces con error y las traces lentas, sin importar la decisión de head-based sampling. Necesitás que el collector bufferee spans durante el periodo de decision_wait (30s en nuestro caso), lo que requiere memoria, calculá unos 500MB por cada 10K spans/segundo.

Capa 3: Límites de atributos

processors:
 attributes:
 actions:
 - key: http.request.body
 action: delete # Nunca guardar bodies de requests
 - key: db.statement
 action: hash # Hashear queries SQL en vez de guardarlas
 span:
 max_num_attributes: 64
 max_attribute_value_length: 256

El hasheo de db.statement previene un escenario donde queries SQL completas incluyendo valores de cláusulas WHERE, que pueden contener emails de usuarios, se logueen. Seguridad y costos en un solo cambio de config.

Con las tres capas, los costos de tracing bajan de ~$8,000/mes a ~$800/mes mientras mejora la experiencia de debugging (porque el 100% de captura de errores significa que nunca te perdés las traces que importan).

La topología de collectors que escala

Correr un solo collector es desaconsejable. Esta es una topología probada:

Servicios → Agent Collectors (DaemonSet) → Gateway Collectors (Deployment) → Tempo
 (buffering local) (sampling, procesamiento) (storage)

Los agent collectors corren como DaemonSet en Kubernetes. Reciben spans por localhost (sin hop de red), hacen batching básico y forwardean al gateway. Los gateway collectors hacen el procesamiento pesado: tail-based sampling, manipulación de atributos y export al backend.

Correr OpenTelemetry en producción no es difícil. Correrlo bien, con sampling apropiado, control de costos, custom spans significativos y una topología que escale, ahí está el oficio. Iniciar con auto-instrumentación, agregar custom spans para operaciones de negocio, configurar tail-based sampling de entrada, y elegir un backend de storage que no te funda. Todo lo demás es optimización.