<!-- Canonical: https://lucioduran.com/blog/opentelemetry-distributed-tracing-microservices
Language: en
Content-SHA256: 2a96eadcff7b71b97bcf03151f583e882a01fb56c2c4d25f4f351bea6539e231
-->

Observability

# OpenTelemetry in Production: Distributed Tracing Implementation and Cost Control

2025-12-118 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/opentelemetry-distributed-tracing-microservices>)

## The auto-instrumentation trap

The typical first step is applying the auto-instrumentation agent without further configuration.

For Node.js with [OpenTelemetry](<https://opentelemetry.io/>), the initial setup appears simple:

```javascript
// tracing.js, loaded before anything else 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 }, // Trust me
 '@opentelemetry/instrumentation-http': {
 ignoreIncomingRequestHook: (req) => {
 return req.url === '/healthz' || req.url === '/readyz';
 },
 },
 })],
});

sdk.start();
```

See that ` instrumentation-fs ` disabled? A common discovery: the collector falls over because the fs instrumentation creates a span for every single file read, including config files, node\_modules, temp files, everything. Trace volume jumps from 50MB/min to 12GB/min.

For Go services, the approach is different because Go doesn't have a runtime agent model:

```go
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
}
```

The ` WithBatcher ` config is crucial. The defaults are fine for development, but in production with high throughput, you'll hit queue overflows. Services can drop 40% of spans silently because the batch queue was full and the default behavior is to drop, not block.

## Custom spans that actually help

Auto-instrumentation gives you the skeleton. Custom spans give you the meat. But teams sometimes go overboard and create spans for every function call, which just turns your traces into unreadable noise.

A good rule: create custom spans for **business operations** and **external dependencies** that auto-instrumentation doesn't cover.

```go
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()

 // Validate inventory, this talks to another service
 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)
 }

 // Process payment, external gateway
 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
}
```

Notice a few things. The ` order.id ` is recorded as an attribute for trace searching by order ID. ` RecordError ` with additional attributes captures which specific SKUs failed. And span events mark significant moments within a single span (payment initiated vs. payment completed).

## Baggage propagation: cross-service context done right

Baggage is the most underused feature of OpenTelemetry. It lets you attach key-value pairs to the context that propagate across service boundaries, without cluttering every span with the same attributes.

```go
// At the API gateway, inject tenant context
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))
 })
}

// In ANY downstream service, read it without knowing how it got there
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()

 // Route to tenant-specific inventory
 return s.repo.GetStock(ctx, tenantID, region, sku)
}
```

The collector can also read baggage and add it as span attributes automatically:

```yaml
# otel-collector config
processors:
 baggage:
 rules:
 - from: baggage
 key: tenant.id
 action: insert
 - from: baggage
 key: region
 action: insert
```

This is incredibly powerful for multi-tenant systems. Instead of passing ` tenantID ` through every function signature, it rides the context through the entire request chain.

## Jaeger vs Tempo: the real comparison

Here is an honest breakdown based on production experience.

**Jaeger with Elasticsearch**:

- Setup: Jaeger collector + Elasticsearch cluster (3 nodes, r6g.xlarge)
- Storage: ~2TB of traces, 7-day retention
- Monthly cost: ~$2,400 (mostly Elasticsearch)
- Query performance: Fast for trace ID lookup, slow for tag-based search at scale
- Operations burden: Elasticsearch index management, JVM tuning, shard rebalancing

**Grafana Tempo with S3**:

- Setup: Tempo (3 replicas in microservices mode) + S3 bucket
- Storage: ~2TB of traces, 30-day retention (yes, 4x longer)
- Monthly cost: ~$180 (S3 storage + a bit of compute)
- Query performance: Trace ID lookup is fast, tag-based search requires TraceQL
- Operations burden: Minimal, stateless ingesters, object storage does the heavy lifting

The cost difference is not a typo. Tempo uses object storage (S3, GCS, Azure Blob) instead of a full-text search engine. The trade-off is that you can't do arbitrary field searches, you need to use TraceQL or integrate with Grafana Loki for log-to-trace correlation.

```promql
# TraceQL, finding slow checkout traces
{ span.checkout.process_order && duration > 5s }

# With attribute filter
{ span.checkout.process_order
 && span.order.payment_method = "credit_card"
 && status = error }
```

The switch from Jaeger to Tempo is often a clear win. The observability bill goes from dreaded to barely noticed.

## Trace-based testing: validation through observability

This is one of the most promising developments. Trace-based testing uses actual distributed traces to validate system behavior. Instead of mocking everything, you run real requests and assert on the trace structure.

Tracetest, which hooks into the OTEL collector, enables this:

```yaml
# tracetest definition
type: Test
spec:
 name: "Checkout creates payment and sends confirmation"
 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
```

This test fires a real HTTP request, waits for the trace to be collected, and then asserts on span attributes across multiple services. It's integration testing on steroids, you're validating not just the response, but the entire internal behavior of the system.

Running these in CI after deploying to a staging environment catches issues that traditional integration tests miss: a service that returns the right response but takes an extra database round-trip, a message queue consumer that processes events but doesn't propagate the trace context, an HTTP client that silently falls back to a default timeout.

## Cost control: the survival guide

Observability costs can spiral out of control faster than you'd expect. Here is a proven three-layer approach:

### Layer 1: Head-based sampling at the SDK

```yaml
# Environment variable, simple and effective
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
```

This samples 10% of traces at the source. The ` parentbased ` prefix is critical, it means child spans respect the parent's sampling decision. Without it, you get orphaned spans everywhere.

### Layer 2: Tail-based sampling at the collector

```yaml
# otel-collector config
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
```

This catches 100% of error traces and slow traces, regardless of the head-based sampling decision. The collector needs to buffer spans for the ` decision_wait ` period (30s in this case), which requires memory, budget about 500MB per 10K spans/second.

### Layer 3: Attribute limits

```yaml
processors:
 attributes:
 actions:
 - key: http.request.body
 action: delete # Never store request bodies
 - key: db.statement
 action: hash # Hash SQL queries instead of storing them
 span:
 max_num_attributes: 64
 max_attribute_value_length: 256
```

The ` db.statement ` hashing prevents a scenario where full SQL queries including WHERE clause values, which may contain user emails, get logged. Security and cost in one config change.

With all three layers, tracing costs drop from ~$8,000/month to ~$800/month while actually improving the debugging experience (because 100% error capture means the traces that matter are never missed).

## The collector topology that scales

Running a single collector is inadvisable. Here is a proven topology:

```
Services → Agent Collectors (DaemonSet) → Gateway Collectors (Deployment) → Tempo
 (local buffering) (sampling, processing) (storage)
```

The agent collectors run as a DaemonSet in Kubernetes. They receive spans over localhost (no network hop), do basic batching, and forward to the gateway. The gateway collectors do the heavy processing: tail-based sampling, attribute manipulation, and export to the backend.

```yaml
# Agent collector config (lightweight)
receivers:
 otlp:
 protocols:
 grpc:
 endpoint: 0.0.0.0:4317
exporters:
 otlp:
 endpoint: otel-gateway:4317
 compression: zstd
processors:
 batch:
 timeout: 1s
 send_batch_size: 1024

# Gateway collector config (heavy processing)
receivers:
 otlp:
 protocols:
 grpc:
 endpoint: 0.0.0.0:4317
processors:
 tail_sampling:
 # ... sampling policies
 attributes:
 # ... attribute processing
exporters:
 otlp:
 endpoint: tempo:4317
```

This separation lets you scale the gateway independently from the agents, and if the gateway goes down, the agents buffer locally without dropping spans from your services.

Running OpenTelemetry in production isn't hard. Running it *well*, with proper sampling, cost control, meaningful custom spans, and a topology that scales, that's where the craft is. Start with auto-instrumentation, add custom spans for business operations, set up tail-based sampling immediately, and pick a storage backend that won't bankrupt you. Everything else is optimization.

## Frequently asked questions

### Should I use auto-instrumentation or manual instrumentation?

Start with auto-instrumentation to get baseline visibility, then layer manual spans for business-critical paths. Auto-instrumentation covers HTTP, gRPC, and database calls well but misses domain-specific logic.

### Jaeger or Grafana Tempo?

Tempo if you're already in the Grafana ecosystem and want to minimize costs, it uses object storage (S3/GCS) instead of Elasticsearch. Jaeger if you need a standalone solution with mature tooling and don't mind running Elasticsearch or Cassandra.

### How do I control OpenTelemetry costs in production?

Three levers: head-based sampling to reduce volume at the source, tail-based sampling at the collector to keep interesting traces, and attribute limits to cap span size. We cut our costs 90% with a 10% head sample rate plus tail-based error sampling.

## Tips

1. **Tip:** Set ` OTEL_TRACES_SAMPLER=parentbased_traceidratio ` with a ratio of 0.1 in production, then use tail-based sampling at the collector to catch 100% of errors regardless.
2. **Tip:** Never put PII in span attributes. Use ` baggage ` for cross-service context and redact at the collector level with the ` attributes ` processor.
3. **Tip:** Grafana Tempo with S3 backend costs us ~$180/mo for 2TB of traces. Jaeger with Elasticsearch was $2,400/mo for the same volume. Do the math.
4. **Tip:** Use ` span.SetStatus(codes.Error, err.Error()) ` religiously, without it, your error traces are invisible in most UIs.

- opentelemetry
- distributed-tracing
- observability
- jaeger
- tempo
- microservices
- otel
- spans
- instrumentation

[Back to Blog](<https://lucioduran.com/blog>)
