{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices","title":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","description":"Trampas de auto-instrumentación, custom spans que sirven de verdad, propagación de baggage, Jaeger vs Tempo cara a cara, trace-based testing, y cómo no fundirte con los costos de observabilidad.","language":"es","datePublished":"2025-12-11","dateModified":null,"contentFormat":"text/markdown","content":"Observability\n\n# OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos\n\n2025-12-118 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/opentelemetry-distributed-tracing-microservices>)\n\n## La trampa de la auto-instrumentación\n\nEl paso inicial típico es aplicar el agente de auto-instrumentación sin configuración adicional.\n\nPara Node.js con [OpenTelemetry](<https://opentelemetry.io/>), la configuración inicial parece simple:\n\n```javascript\n// tracing.js, cargado antes que todo via --require\nconst { NodeSDK } = require('@opentelemetry/sdk-node');\nconst { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');\nconst { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');\n\nconst sdk = new NodeSDK({\n traceExporter: new OTLPTraceExporter({\n url: 'http://otel-collector:4317',\n }),\n instrumentations: [getNodeAutoInstrumentations({\n '@opentelemetry/instrumentation-fs': { enabled: false }, // Creeme\n '@opentelemetry/instrumentation-http': {\n ignoreIncomingRequestHook: (req) => {\n return req.url === '/healthz' || req.url === '/readyz';\n },\n },\n })],\n});\n\nsdk.start();\n```\n\n¿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.\n\nPara servicios en Go, el approach es diferente porque Go no tiene un modelo de agente en runtime:\n\n```go\npackage main\n\nimport (\n \"context\"\n \"go.opentelemetry.io/otel\"\n \"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc\"\n \"go.opentelemetry.io/otel/sdk/resource\"\n sdktrace \"go.opentelemetry.io/otel/sdk/trace\"\n semconv \"go.opentelemetry.io/otel/semconv/v1.24.0\"\n)\n\nfunc initTracer() (*sdktrace.TracerProvider, error) {\n exporter, err := otlptracegrpc.New(context.Background(),\n otlptracegrpc.WithEndpoint(\"otel-collector:4317\"),\n otlptracegrpc.WithInsecure(),\n )\n if err != nil {\n return nil, err\n }\n\n tp := sdktrace.NewTracerProvider(\n sdktrace.WithBatcher(exporter,\n sdktrace.WithMaxQueueSize(2048),\n sdktrace.WithMaxExportBatchSize(512),\n sdktrace.WithBatchTimeout(5*time.Second),\n ),\n sdktrace.WithResource(resource.NewWithAttributes(\n semconv.SchemaURL,\n semconv.ServiceNameKey.String(\"checkout-service\"),\n semconv.ServiceVersionKey.String(\"1.4.2\"),\n semconv.DeploymentEnvironmentKey.String(\"production\"),\n )),\n sdktrace.WithSampler(sdktrace.ParentBased(\n sdktrace.TraceIDRatioBased(0.1),\n )),\n )\n\n otel.SetTracerProvider(tp)\n return tp, nil\n}\n```\n\nLa 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.\n\n## Custom spans que de verdad sirven\n\nLa 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.\n\nUna buena regla: creá custom spans para **operaciones de negocio** y **dependencias externas** que la auto-instrumentación no cubre.\n\n```go\nfunc (s *CheckoutService) ProcessOrder(ctx context.Context, order *Order) error {\n ctx, span := s.tracer.Start(ctx, \"checkout.process_order\",\n trace.WithAttributes(\n attribute.String(\"order.id\", order.ID),\n attribute.Int(\"order.items_count\", len(order.Items)),\n attribute.String(\"order.payment_method\", order.PaymentMethod),\n attribute.Float64(\"order.total\", order.Total),\n ),\n )\n defer span.End()\n\n // Validar inventario, esto habla con otro servicio\n if err := s.validateInventory(ctx, order); err != nil {\n span.SetStatus(codes.Error, \"inventory validation failed\")\n span.RecordError(err, trace.WithAttributes(\n attribute.StringSlice(\"failed_skus\", getFailedSKUs(err)),\n ))\n return fmt.Errorf(\"inventory check: %w\", err)\n }\n\n // Procesar pago, gateway externo\n paymentSpan := trace.SpanFromContext(ctx)\n paymentSpan.AddEvent(\"payment.initiated\", trace.WithAttributes(\n attribute.String(\"gateway\", s.paymentGateway.Name()),\n ))\n\n charge, err := s.processPayment(ctx, order)\n if err != nil {\n span.SetStatus(codes.Error, \"payment processing failed\")\n span.RecordError(err)\n return fmt.Errorf(\"payment: %w\", err)\n }\n\n span.SetAttributes(attribute.String(\"payment.charge_id\", charge.ID))\n return nil\n}\n```\n\nNoten 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).\n\n## Propagación de baggage: contexto cross-service bien hecho\n\nBaggage 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.\n\n```go\n// En el API gateway, inyectar contexto de tenant\nimport \"go.opentelemetry.io/otel/baggage\"\n\nfunc gatewayMiddleware(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n tenantID := extractTenantID(r)\n region := resolveRegion(r)\n\n member1, _ := baggage.NewMember(\"tenant.id\", tenantID)\n member2, _ := baggage.NewMember(\"region\", region)\n bag, _ := baggage.New(member1, member2)\n\n ctx := baggage.ContextWithBaggage(r.Context(), bag)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n}\n\n// En CUALQUIER servicio downstream, leerlo sin saber cómo llegó\nfunc (s *InventoryService) CheckStock(ctx context.Context, sku string) (int, error) {\n bag := baggage.FromContext(ctx)\n tenantID := bag.Member(\"tenant.id\").Value()\n region := bag.Member(\"region\").Value()\n\n // Rutear al inventario específico del tenant\n return s.repo.GetStock(ctx, tenantID, region, sku)\n}\n```\n\nEl collector también puede leer baggage y agregarlo como atributos de spans automáticamente:\n\n```yaml\n# Config del otel-collector\nprocessors:\n baggage:\n rules:\n - from: baggage\n key: tenant.id\n action: insert\n - from: baggage\n key: region\n action: insert\n```\n\nEsto 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.\n\n## Jaeger vs Tempo: la comparación verdad\n\nAcá va un breakdown honesto basado en experiencia de producción.\n\n**Jaeger con Elasticsearch**:\n\n- Setup: Jaeger collector + cluster Elasticsearch (3 nodos, r6g.xlarge)\n- Storage: ~2TB de traces, retención 7 días\n- Costo mensual: ~$2,400 (mayormente Elasticsearch)\n- Performance de queries: Rápido para lookup por trace ID, lento para búsqueda por tags a escala\n- Carga operativa: Manejo de índices de Elasticsearch, tuning de JVM, rebalanceo de shards\n\n**Grafana Tempo con S3**:\n\n- Setup: Tempo (3 réplicas en modo microservicios) + bucket S3\n- Storage: ~2TB de traces, retención 30 días (sí, 4x más)\n- Costo mensual: ~$180 (almacenamiento S3 + un poco de compute)\n- Performance de queries: Lookup por trace ID es rápido, búsqueda por tags requiere TraceQL\n- Carga operativa: Mínima, ingesters stateless, object storage hace el trabajo pesado\n\nLa 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.\n\n```promql\n# TraceQL, encontrando traces lentas de checkout\n{ span.checkout.process_order && duration > 5s }\n\n# Con filtro de atributos\n{ span.checkout.process_order\n && span.order.payment_method = \"credit_card\"\n && status = error }\n```\n\nLa migración de Jaeger a Tempo suele ser una victoria clara. La factura de observabilidad pasa de generar terror a casi no notarse.\n\n## Trace-based testing: validación mediante observabilidad\n\nEste 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.\n\nTracetest, que se engancha al collector de OTEL, permite esto:\n\n```yaml\n# Definición de tracetest\ntype: Test\nspec:\n name: \"Checkout crea pago y manda confirmación\"\n trigger:\n type: http\n httpRequest:\n url: http://api-gateway/api/v1/checkout\n method: POST\n body: '{\"items\": [{\"sku\": \"WIDGET-1\", \"qty\": 2}], \"payment\": \"credit_card\"}'\n specs:\n - selector: span[name=\"checkout.process_order\"]\n assertions:\n - attr:order.items_count = 2\n - selector: span[name=\"payment.charge\"]\n assertions:\n - attr:tracetest.span.duration < 2000ms\n - attr:payment.status = \"captured\"\n - selector: span[name=\"notification.send_email\"]\n assertions:\n - attr:email.template = \"order_confirmation\"\n - attr:tracetest.span.duration < 500ms\n```\n\nEste 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.\n\nCorrer 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.\n\n## Control de costos: la guía de supervivencia\n\nLos costos de observabilidad se pueden ir al más rápido de lo que considerars. Este es un approach probado de tres capas:\n\n### Capa 1: Head-based sampling en el SDK\n\n```yaml\n# Variable de entorno, simple y efectivo\nOTEL_TRACES_SAMPLER=parentbased_traceidratio\nOTEL_TRACES_SAMPLER_ARG=0.1\n```\n\nEsto 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.\n\n### Capa 2: Tail-based sampling en el collector\n\n```yaml\n# Config del otel-collector\nprocessors:\n tail_sampling:\n decision_wait: 30s\n policies:\n - name: errors-always\n type: status_code\n status_code:\n status_codes: [ERROR]\n - name: slow-traces\n type: latency\n latency:\n threshold_ms: 5000\n - name: percentage-sample\n type: probabilistic\n probabilistic:\n sampling_percentage: 5\n```\n\nEsto 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.\n\n### Capa 3: Límites de atributos\n\n```yaml\nprocessors:\n attributes:\n actions:\n - key: http.request.body\n action: delete # Nunca guardar bodies de requests\n - key: db.statement\n action: hash # Hashear queries SQL en vez de guardarlas\n span:\n max_num_attributes: 64\n max_attribute_value_length: 256\n```\n\nEl 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.\n\nCon 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).\n\n## La topología de collectors que escala\n\nCorrer un solo collector es desaconsejable. Esta es una topología probada:\n\n```\nServicios → Agent Collectors (DaemonSet) → Gateway Collectors (Deployment) → Tempo\n (buffering local) (sampling, procesamiento) (storage)\n```\n\nLos 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.\n\nCorrer 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.\n\n## Preguntas frecuentes\n\n### ¿Debería usar auto-instrumentación o instrumentación manual?\n\nIniciar con auto-instrumentación para tener visibilidad base, después sumá spans manuales para los paths críticos del negocio. La auto-instrumentación cubre bien HTTP, gRPC y llamadas a base de datos pero se pierde la lógica específica del dominio.\n\n### ¿Jaeger o Grafana Tempo?\n\nTempo si ya se está en el ecosistema Grafana y se desea minimizar costos, usa object storage (S3/GCS) en vez de Elasticsearch. Jaeger si se necesita una solución standalone con tooling maduro y no te jode correr Elasticsearch o Cassandra.\n\n### ¿Cómo controlo los costos de OpenTelemetry en producción?\n\nTres palancas: head-based sampling para reducir volumen en el origen, tail-based sampling en el collector para quedarte con las traces interesantes, y límites de atributos para topar el tamaño de los spans. Bajamos nuestros costos un 90% con 10% de sample rate head-based más tail-based sampling de errores.\n\n## Tips\n\n1. **Tip:** Configurar ` OTEL_TRACES_SAMPLER=parentbased_traceidratio ` con ratio 0.1 en producción, después utilizar tail-based sampling en el collector para capturar el 100% de los errores igual.\n2. **Tip:** Nunca metas PII en atributos de spans. Utilizar ` baggage ` para contexto cross-service y redactá en el collector con el procesador ` attributes `.\n3. **Tip:** Grafana Tempo con backend S3 nos cuesta ~$180/mes por 2TB de traces. Jaeger con Elasticsearch salía $2,400/mes por el mismo volumen. Hacé las cuentas.\n4. **Tip:** Utilizar ` span.SetStatus(codes.Error, err.Error()) ` religiosamente, sin eso, tus traces con error son invisibles en la mayoría de las UIs.\n\n- opentelemetry\n- distributed-tracing\n- observability\n- jaeger\n- tempo\n- microservices\n- otel\n- spans\n- instrumentation\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#title"},{"level":2,"text":"La trampa de la auto-instrumentación","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#la-trampa-de-la-auto-instrumentacin"},{"level":2,"text":"Custom spans que de verdad sirven","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#custom-spans-que-de-verdad-sirven"},{"level":2,"text":"Propagación de baggage: contexto cross-service bien hecho","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#propagacin-de-baggage-contexto-cross-service-bien-hecho"},{"level":2,"text":"Jaeger vs Tempo: la comparación verdad","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#jaeger-vs-tempo-la-comparacin-verdad"},{"level":2,"text":"Trace-based testing: validación mediante observabilidad","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#trace-based-testing-validacin-mediante-observabilidad"},{"level":2,"text":"Control de costos: la guía de supervivencia","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#control-de-costos-la-gua-de-supervivencia"},{"level":3,"text":"Capa 1: Head-based sampling en el SDK","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-1-head-based-sampling-en-el-sdk"},{"level":3,"text":"Capa 2: Tail-based sampling en el collector","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-2-tail-based-sampling-en-el-collector"},{"level":3,"text":"Capa 3: Límites de atributos","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-3-lmites-de-atributos"},{"level":2,"text":"La topología de collectors que escala","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#la-topologa-de-collectors-que-escala"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#questions"},{"level":3,"text":"¿Debería usar auto-instrumentación o instrumentación manual?","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-1"},{"level":3,"text":"¿Jaeger o Grafana Tempo?","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-2"},{"level":3,"text":"¿Cómo controlo los costos de OpenTelemetry en producción?","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#article-tips"}],"sections":[{"heading":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","level":1,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices","content":"Observability"},{"heading":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","level":1,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#title","content":"2025-12-118 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/opentelemetry-distributed-tracing-microservices>)"},{"heading":"La trampa de la auto-instrumentación","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#la-trampa-de-la-auto-instrumentacin","content":"El paso inicial típico es aplicar el agente de auto-instrumentación sin configuración adicional.\n\nPara Node.js con [OpenTelemetry](<https://opentelemetry.io/>), la configuración inicial parece simple:\n\n```javascript\n// tracing.js, cargado antes que todo via --require\nconst { NodeSDK } = require('@opentelemetry/sdk-node');\nconst { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');\nconst { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');\n\nconst sdk = new NodeSDK({\n traceExporter: new OTLPTraceExporter({\n url: 'http://otel-collector:4317',\n }),\n instrumentations: [getNodeAutoInstrumentations({\n '@opentelemetry/instrumentation-fs': { enabled: false }, // Creeme\n '@opentelemetry/instrumentation-http': {\n ignoreIncomingRequestHook: (req) => {\n return req.url === '/healthz' || req.url === '/readyz';\n },\n },\n })],\n});\n\nsdk.start();\n```\n\n¿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.\n\nPara servicios en Go, el approach es diferente porque Go no tiene un modelo de agente en runtime:\n\n```go\npackage main\n\nimport (\n \"context\"\n \"go.opentelemetry.io/otel\"\n \"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc\"\n \"go.opentelemetry.io/otel/sdk/resource\"\n sdktrace \"go.opentelemetry.io/otel/sdk/trace\"\n semconv \"go.opentelemetry.io/otel/semconv/v1.24.0\"\n)\n\nfunc initTracer() (*sdktrace.TracerProvider, error) {\n exporter, err := otlptracegrpc.New(context.Background(),\n otlptracegrpc.WithEndpoint(\"otel-collector:4317\"),\n otlptracegrpc.WithInsecure(),\n )\n if err != nil {\n return nil, err\n }\n\n tp := sdktrace.NewTracerProvider(\n sdktrace.WithBatcher(exporter,\n sdktrace.WithMaxQueueSize(2048),\n sdktrace.WithMaxExportBatchSize(512),\n sdktrace.WithBatchTimeout(5*time.Second),\n ),\n sdktrace.WithResource(resource.NewWithAttributes(\n semconv.SchemaURL,\n semconv.ServiceNameKey.String(\"checkout-service\"),\n semconv.ServiceVersionKey.String(\"1.4.2\"),\n semconv.DeploymentEnvironmentKey.String(\"production\"),\n )),\n sdktrace.WithSampler(sdktrace.ParentBased(\n sdktrace.TraceIDRatioBased(0.1),\n )),\n )\n\n otel.SetTracerProvider(tp)\n return tp, nil\n}\n```\n\nLa 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."},{"heading":"Custom spans que de verdad sirven","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#custom-spans-que-de-verdad-sirven","content":"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.\n\nUna buena regla: creá custom spans para **operaciones de negocio** y **dependencias externas** que la auto-instrumentación no cubre.\n\n```go\nfunc (s *CheckoutService) ProcessOrder(ctx context.Context, order *Order) error {\n ctx, span := s.tracer.Start(ctx, \"checkout.process_order\",\n trace.WithAttributes(\n attribute.String(\"order.id\", order.ID),\n attribute.Int(\"order.items_count\", len(order.Items)),\n attribute.String(\"order.payment_method\", order.PaymentMethod),\n attribute.Float64(\"order.total\", order.Total),\n ),\n )\n defer span.End()\n\n // Validar inventario, esto habla con otro servicio\n if err := s.validateInventory(ctx, order); err != nil {\n span.SetStatus(codes.Error, \"inventory validation failed\")\n span.RecordError(err, trace.WithAttributes(\n attribute.StringSlice(\"failed_skus\", getFailedSKUs(err)),\n ))\n return fmt.Errorf(\"inventory check: %w\", err)\n }\n\n // Procesar pago, gateway externo\n paymentSpan := trace.SpanFromContext(ctx)\n paymentSpan.AddEvent(\"payment.initiated\", trace.WithAttributes(\n attribute.String(\"gateway\", s.paymentGateway.Name()),\n ))\n\n charge, err := s.processPayment(ctx, order)\n if err != nil {\n span.SetStatus(codes.Error, \"payment processing failed\")\n span.RecordError(err)\n return fmt.Errorf(\"payment: %w\", err)\n }\n\n span.SetAttributes(attribute.String(\"payment.charge_id\", charge.ID))\n return nil\n}\n```\n\nNoten 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)."},{"heading":"Propagación de baggage: contexto cross-service bien hecho","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#propagacin-de-baggage-contexto-cross-service-bien-hecho","content":"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.\n\n```go\n// En el API gateway, inyectar contexto de tenant\nimport \"go.opentelemetry.io/otel/baggage\"\n\nfunc gatewayMiddleware(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n tenantID := extractTenantID(r)\n region := resolveRegion(r)\n\n member1, _ := baggage.NewMember(\"tenant.id\", tenantID)\n member2, _ := baggage.NewMember(\"region\", region)\n bag, _ := baggage.New(member1, member2)\n\n ctx := baggage.ContextWithBaggage(r.Context(), bag)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n}\n\n// En CUALQUIER servicio downstream, leerlo sin saber cómo llegó\nfunc (s *InventoryService) CheckStock(ctx context.Context, sku string) (int, error) {\n bag := baggage.FromContext(ctx)\n tenantID := bag.Member(\"tenant.id\").Value()\n region := bag.Member(\"region\").Value()\n\n // Rutear al inventario específico del tenant\n return s.repo.GetStock(ctx, tenantID, region, sku)\n}\n```\n\nEl collector también puede leer baggage y agregarlo como atributos de spans automáticamente:\n\n```yaml\n# Config del otel-collector\nprocessors:\n baggage:\n rules:\n - from: baggage\n key: tenant.id\n action: insert\n - from: baggage\n key: region\n action: insert\n```\n\nEsto 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."},{"heading":"Jaeger vs Tempo: la comparación verdad","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#jaeger-vs-tempo-la-comparacin-verdad","content":"Acá va un breakdown honesto basado en experiencia de producción.\n\n**Jaeger con Elasticsearch**:\n\n- Setup: Jaeger collector + cluster Elasticsearch (3 nodos, r6g.xlarge)\n- Storage: ~2TB de traces, retención 7 días\n- Costo mensual: ~$2,400 (mayormente Elasticsearch)\n- Performance de queries: Rápido para lookup por trace ID, lento para búsqueda por tags a escala\n- Carga operativa: Manejo de índices de Elasticsearch, tuning de JVM, rebalanceo de shards\n\n**Grafana Tempo con S3**:\n\n- Setup: Tempo (3 réplicas en modo microservicios) + bucket S3\n- Storage: ~2TB de traces, retención 30 días (sí, 4x más)\n- Costo mensual: ~$180 (almacenamiento S3 + un poco de compute)\n- Performance de queries: Lookup por trace ID es rápido, búsqueda por tags requiere TraceQL\n- Carga operativa: Mínima, ingesters stateless, object storage hace el trabajo pesado\n\nLa 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.\n\n```promql\n# TraceQL, encontrando traces lentas de checkout\n{ span.checkout.process_order && duration > 5s }\n\n# Con filtro de atributos\n{ span.checkout.process_order\n && span.order.payment_method = \"credit_card\"\n && status = error }\n```\n\nLa migración de Jaeger a Tempo suele ser una victoria clara. La factura de observabilidad pasa de generar terror a casi no notarse."},{"heading":"Trace-based testing: validación mediante observabilidad","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#trace-based-testing-validacin-mediante-observabilidad","content":"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.\n\nTracetest, que se engancha al collector de OTEL, permite esto:\n\n```yaml\n# Definición de tracetest\ntype: Test\nspec:\n name: \"Checkout crea pago y manda confirmación\"\n trigger:\n type: http\n httpRequest:\n url: http://api-gateway/api/v1/checkout\n method: POST\n body: '{\"items\": [{\"sku\": \"WIDGET-1\", \"qty\": 2}], \"payment\": \"credit_card\"}'\n specs:\n - selector: span[name=\"checkout.process_order\"]\n assertions:\n - attr:order.items_count = 2\n - selector: span[name=\"payment.charge\"]\n assertions:\n - attr:tracetest.span.duration < 2000ms\n - attr:payment.status = \"captured\"\n - selector: span[name=\"notification.send_email\"]\n assertions:\n - attr:email.template = \"order_confirmation\"\n - attr:tracetest.span.duration < 500ms\n```\n\nEste 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.\n\nCorrer 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."},{"heading":"Control de costos: la guía de supervivencia","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#control-de-costos-la-gua-de-supervivencia","content":"Los costos de observabilidad se pueden ir al más rápido de lo que considerars. Este es un approach probado de tres capas:"},{"heading":"Capa 1: Head-based sampling en el SDK","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-1-head-based-sampling-en-el-sdk","content":"```yaml\n# Variable de entorno, simple y efectivo\nOTEL_TRACES_SAMPLER=parentbased_traceidratio\nOTEL_TRACES_SAMPLER_ARG=0.1\n```\n\nEsto 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."},{"heading":"Capa 2: Tail-based sampling en el collector","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-2-tail-based-sampling-en-el-collector","content":"```yaml\n# Config del otel-collector\nprocessors:\n tail_sampling:\n decision_wait: 30s\n policies:\n - name: errors-always\n type: status_code\n status_code:\n status_codes: [ERROR]\n - name: slow-traces\n type: latency\n latency:\n threshold_ms: 5000\n - name: percentage-sample\n type: probabilistic\n probabilistic:\n sampling_percentage: 5\n```\n\nEsto 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."},{"heading":"Capa 3: Límites de atributos","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#capa-3-lmites-de-atributos","content":"```yaml\nprocessors:\n attributes:\n actions:\n - key: http.request.body\n action: delete # Nunca guardar bodies de requests\n - key: db.statement\n action: hash # Hashear queries SQL en vez de guardarlas\n span:\n max_num_attributes: 64\n max_attribute_value_length: 256\n```\n\nEl 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.\n\nCon 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)."},{"heading":"La topología de collectors que escala","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#la-topologa-de-collectors-que-escala","content":"Correr un solo collector es desaconsejable. Esta es una topología probada:\n\n```\nServicios → Agent Collectors (DaemonSet) → Gateway Collectors (Deployment) → Tempo\n (buffering local) (sampling, procesamiento) (storage)\n```\n\nLos 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.\n\nCorrer 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#questions","content":""},{"heading":"¿Debería usar auto-instrumentación o instrumentación manual?","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-1","content":"Iniciar con auto-instrumentación para tener visibilidad base, después sumá spans manuales para los paths críticos del negocio. La auto-instrumentación cubre bien HTTP, gRPC y llamadas a base de datos pero se pierde la lógica específica del dominio."},{"heading":"¿Jaeger o Grafana Tempo?","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-2","content":"Tempo si ya se está en el ecosistema Grafana y se desea minimizar costos, usa object storage (S3/GCS) en vez de Elasticsearch. Jaeger si se necesita una solución standalone con tooling maduro y no te jode correr Elasticsearch o Cassandra."},{"heading":"¿Cómo controlo los costos de OpenTelemetry en producción?","level":3,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#question-3","content":"Tres palancas: head-based sampling para reducir volumen en el origen, tail-based sampling en el collector para quedarte con las traces interesantes, y límites de atributos para topar el tamaño de los spans. Bajamos nuestros costos un 90% con 10% de sample rate head-based más tail-based sampling de errores."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#article-tips","content":"1. **Tip:** Configurar ` OTEL_TRACES_SAMPLER=parentbased_traceidratio ` con ratio 0.1 en producción, después utilizar tail-based sampling en el collector para capturar el 100% de los errores igual.\n2. **Tip:** Nunca metas PII en atributos de spans. Utilizar ` baggage ` para contexto cross-service y redactá en el collector con el procesador ` attributes `.\n3. **Tip:** Grafana Tempo con backend S3 nos cuesta ~$180/mes por 2TB de traces. Jaeger con Elasticsearch salía $2,400/mes por el mismo volumen. Hacé las cuentas.\n4. **Tip:** Utilizar ` span.SetStatus(codes.Error, err.Error()) ` religiosamente, sin eso, tus traces con error son invisibles en la mayoría de las UIs.\n\n- opentelemetry\n- distributed-tracing\n- observability\n- jaeger\n- tempo\n- microservices\n- otel\n- spans\n- instrumentation\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/opentelemetry-distributed-tracing-microservices"},{"text":"OpenTelemetry","url":"https://opentelemetry.io/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices"},"headline":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","description":"Trampas de auto-instrumentación, custom spans que sirven de verdad, propagación de baggage, Jaeger vs Tempo cara a cara, trace-based testing, y cómo no fundirte con los costos de observabilidad.","datePublished":"2025-12-11","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Observability","keywords":"opentelemetry, distributed-tracing, observability, jaeger, tempo, microservices, otel, spans, instrumentation","wordCount":1521,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/opentelemetry-distributed-tracing-microservices"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"OpenTelemetry en Producción: Implementación de Distributed Tracing y Control de Costos","item":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Debería usar auto-instrumentación o instrumentación manual?","acceptedAnswer":{"@type":"Answer","text":"Iniciar con auto-instrumentación para tener visibilidad base, después sumá spans manuales para los paths críticos del negocio. La auto-instrumentación cubre bien HTTP, gRPC y llamadas a base de datos pero se pierde la lógica específica del dominio."}},{"@type":"Question","name":"¿Jaeger o Grafana Tempo?","acceptedAnswer":{"@type":"Answer","text":"Tempo si ya se está en el ecosistema Grafana y se desea minimizar costos, usa object storage (S3/GCS) en vez de Elasticsearch. Jaeger si se necesita una solución standalone con tooling maduro y no te jode correr Elasticsearch o Cassandra."}},{"@type":"Question","name":"¿Cómo controlo los costos de OpenTelemetry en producción?","acceptedAnswer":{"@type":"Answer","text":"Tres palancas: head-based sampling para reducir volumen en el origen, tail-based sampling en el collector para quedarte con las traces interesantes, y límites de atributos para topar el tamaño de los spans. Bajamos nuestros costos un 90% con 10% de sample rate head-based más tail-based sampling de errores."}}]}]}],"contentHash":"6887540cede885c2bb6ff1727303df5dd6f3ee730afd3bccf4b086633fd21e3b","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":68301,"contentBytes":14654}}
