OpenTelemetry in 2026: The Standard for Modern Observability
OpenTelemetry observability has become the default way teams make distributed systems visible, because you cannot fix what you cannot see. In distributed systems with dozens of microservices, a single user request might touch 10 services, 3 databases, and 2 message queues. When something goes wrong, finding the root cause without proper instrumentation is like debugging in the dark. By 2026, the framework is mature enough for every team to adopt, and the surrounding tooling has stabilized into well-trodden patterns rather than bleeding-edge experiments.
What Is OpenTelemetry
OpenTelemetry (OTel) is a vendor-neutral, open-source framework governed by the CNCF. It provides APIs, SDKs, and tools to generate, collect, and export three types of telemetry data:
- Traces — the journey of a request across services (distributed tracing)
- Metrics — numerical measurements over time (counters, histograms, gauges)
- Logs — structured event records with context
The key word is vendor-neutral. You instrument your code once, and you can export to any backend — Jaeger, Grafana Tempo, Datadog, New Relic, AWS X-Ray, or any combination. Consequently, swapping observability vendors no longer means re-instrumenting hundreds of services; you change a collector exporter and redeploy.
The Three Pillars in Practice
Traces answer: “What happened to this specific request?” A trace follows a single request from the frontend through every service it touches. Each step is a span — a named, timed operation with metadata.
User Request → API Gateway (12ms)
└→ Auth Service (8ms)
└→ Order Service (45ms)
└→ PostgreSQL Query (15ms)
└→ Payment Service (120ms)
└→ Stripe API (95ms)
└→ Notification Service (5ms)
└→ Redis Pub/Sub (2ms)
Metrics answer: “How is the system performing overall?” Representative dashboard numbers might include a request rate of 1,250 req/s, an error rate of 0.3%, a P99 latency of 450ms, and 42 of 100 database connections in use. Metrics are cheap to store and aggregate, which makes them ideal for alerting; traces are richer but expensive, which makes them ideal for diagnosis.
Logs answer: “What exactly happened at this moment?”
{
"timestamp": "2026-02-23T10:15:32Z",
"level": "ERROR",
"service": "payment-service",
"trace_id": "abc123def456",
"span_id": "789ghi",
"message": "Payment processing failed",
"error": "Stripe API timeout after 30s",
"customer_id": "cust_42",
"amount": 99.99
}
The power comes from correlation. The trace_id in the log connects to the same trace in your tracing backend, which connects to the same request in your metrics. One ID links all three pillars, so an on-call engineer can pivot from an alert to the exact failing span in seconds rather than grepping logs by hand.
Context Propagation: The Mechanism Behind Distributed Traces
Distributed tracing only works because trace context travels with the request. OpenTelemetry uses the W3C Trace Context standard, which carries the trace identity in a traceparent HTTP header that every service reads and forwards. As a result, the order service and the payment service stamp their spans with the same trace ID even though they share no code.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^ ^ ^ ^
version trace-id (16 bytes) parent span-id flags
This matters for an important edge case: context does not cross asynchronous boundaries automatically. When you publish to Kafka or hand work to a background thread pool, you must inject the context into the message and re-extract it on the consumer, otherwise the trace silently breaks into disconnected fragments. The OTel messaging instrumentation handles common brokers, but custom queues need explicit propagation.
Instrumenting a Spring Boot Application
Spring Boot has excellent support through Micrometer and the OTel Java Agent:
<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
# application.yml
management:
tracing:
sampling:
probability: 1.0 # Sample 100% in dev, lower in production
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces
logging:
pattern:
console: "%d{HH:mm:ss} [%X{traceId}] %-5level %logger{36} - %msg%n"
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final ObservationRegistry registry;
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
// Automatic span creation via Spring Observation
return Observation.createNotStarted("order.fetch", registry)
.lowCardinalityKeyValue("order.type", "standard")
.observe(() -> orderService.findById(id));
}
}
@Service
public class OrderService {
private final JdbcTemplate jdbc;
private final PaymentClient paymentClient;
// Custom span for business logic
@Observed(name = "order.process")
public OrderResponse findById(Long id) {
// JDBC calls are auto-instrumented — each query becomes a span
Order order = jdbc.queryForObject(
"SELECT * FROM orders WHERE id = ?", orderRowMapper, id);
// HTTP calls to other services are auto-traced
Payment payment = paymentClient.getPayment(order.getPaymentId());
return new OrderResponse(order, payment);
}
}
With the OTel Java Agent, most instrumentation is automatic — JDBC queries, HTTP client calls, Kafka producers/consumers, and Redis commands all generate spans without code changes. A crucial discipline, however, is cardinality hygiene: tag spans and metrics with low-cardinality values like order.type, never with raw user IDs or full URLs, because high-cardinality labels can explode metric storage and bankrupt a Prometheus instance.
The OpenTelemetry Collector
The OTel Collector is a vendor-agnostic proxy that receives, processes, and exports telemetry data. It decouples your application from the backend:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
# Add resource attributes
resource:
attributes:
- key: environment
value: production
action: upsert
# Filter out health check spans
filter:
spans:
exclude:
match_type: strict
attributes:
- key: http.route
value: /health
# Tail-based sampling — keep errors, sample normal traffic
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-requests
type: latency
latency: { threshold_ms: 1000 }
- name: default
type: probabilistic
probabilistic: { sampling_percentage: 10 }
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, resource, filter, tail_sampling]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [batch, resource]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch, resource]
exporters: [loki]
This configuration receives telemetry via OTLP, processes it (batching, filtering, sampling), and exports traces to Grafana Tempo, metrics to Prometheus, and logs to Loki. Architecturally, teams typically run a lightweight agent collector on each node for fast local export, then forward to a gateway collector pool where expensive operations like tail-based sampling happen centrally.
Custom Metrics That Matter
Beyond auto-instrumented metrics, define custom ones for your business:
@Component
public class BusinessMetrics {
private final MeterRegistry registry;
private final Counter ordersPlaced;
private final Timer orderProcessingTime;
private final AtomicInteger activeCheckouts;
public BusinessMetrics(MeterRegistry registry) {
this.registry = registry;
this.ordersPlaced = Counter.builder("business.orders.placed")
.description("Total orders placed")
.tag("channel", "web")
.register(registry);
this.orderProcessingTime = Timer.builder("business.orders.processing_time")
.description("Time to process an order")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
this.activeCheckouts = registry.gauge(
"business.checkouts.active",
new AtomicInteger(0)
);
}
public void recordOrder(String type, double amount) {
ordersPlaced.increment();
registry.counter("business.revenue",
"type", type,
"currency", "USD"
).increment(amount);
}
}
Structured Logging with Trace Context
Logs become powerful when they carry trace context:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public PaymentResult processPayment(PaymentRequest request) {
// trace_id and span_id are automatically injected into MDC
log.info("Processing payment for customer={} amount={}",
request.getCustomerId(), request.getAmount());
try {
PaymentResult result = stripeClient.charge(request);
log.info("Payment successful transaction_id={}", result.getTransactionId());
return result;
} catch (PaymentException e) {
log.error("Payment failed for customer={} error={}",
request.getCustomerId(), e.getMessage(), e);
throw e;
}
}
}
In Grafana, you can jump from a log line directly to its trace, see every service that request touched, and identify exactly where the failure occurred.
Sampling Strategies for Production
At scale, collecting 100% of telemetry is prohibitively expensive. Smart sampling strategies are essential:
| Strategy | Description | Use When |
|---|---|---|
| Head-based | Decide at request start (random %) | Simple, predictable cost |
| Tail-based | Decide after request completes | Need to keep all errors/slow requests |
| Priority | Always sample certain request types | Critical paths need 100% visibility |
| Adaptive | Adjust rate based on traffic volume | Variable traffic patterns |
The collector configuration above demonstrates tail-based sampling: 100% of errors and slow requests are kept, while normal traffic is sampled at 10%. Tail-based sampling has a cost, though — the gateway must buffer every span of a trace until the request finishes, which consumes memory and requires that all spans for one trace reach the same collector instance. Teams solve the latter with a load-balancing exporter that routes by trace ID.
From RED Metrics to Actionable SLOs in OpenTelemetry Observability
Dashboards are only useful if they drive decisions. The RED method — Rate, Errors, Duration — gives every service a consistent baseline that any engineer can read at a glance. Build on it by defining Service Level Objectives and alerting on error budget burn rate rather than on raw thresholds.
# Prometheus alert: page only on fast SLO burn, not on every blip
groups:
- name: slo
rules:
- alert: HighErrorBudgetBurn
expr: |
(
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
) > (14.4 * 0.001) # 14.4x burn of a 99.9% SLO
for: 5m
labels: { severity: page }
This approach reduces alert fatigue dramatically. A single failed request no longer wakes anyone; a sustained burn that threatens the monthly budget does. In contrast, static thresholds like “alert if errors > 5/min” either fire constantly during a deploy or stay silent during a slow-bleeding incident.
The Grafana Stack: Putting It All Together
The most popular open-source stack in 2026 pairs Grafana Tempo for traces, Prometheus for metrics, and Grafana Loki for logs, all explored through a single Grafana UI. The backends are connected through exemplars and trace-to-logs correlations. Click a spike in a latency graph, see the traces that caused it, click a trace span, see the logs from that exact moment. This workflow transforms debugging from hours to minutes.
When NOT to Reach for Full Distributed Tracing
OpenTelemetry is not free, and full tracing is overkill for some systems. A single monolith with no downstream services gains little from distributed traces — good structured logs and a few metrics often suffice, and the agent’s overhead and storage bill are pure cost. Similarly, extremely high-throughput, latency-sensitive paths may not tolerate even small per-span overhead, so teams there sample aggressively or trace only critical endpoints.
There is also operational weight: running a collector fleet, a trace store, and retention policies is a real commitment. For a small team shipping a simple CRUD app, starting with metrics plus structured logs and adding tracing later is a perfectly honest path. The goal is visibility proportional to system complexity, not telemetry for its own sake.
Getting Started Checklist
- Add the OTel Java Agent (or SDK for your language) — auto-instrumentation covers most needs
- Deploy an OTel Collector as a sidecar or daemonset
- Export to your backend of choice (the Grafana stack is free and excellent)
- Add trace IDs to your structured logs
- Define 3–5 custom business metrics that matter to your team
- Set up tail-based sampling to control costs while keeping error traces
- Build dashboards with RED metrics (Rate, Errors, Duration) for each service
- Create alerts on SLO violations, not raw thresholds
For further reading, refer to the AWS documentation and the Google Cloud documentation for comprehensive reference material. Our companion guides on distributed transactions with the saga pattern and API security in zero-trust systems pair well with an observability rollout.
Observability is not optional for distributed systems. The framework makes it achievable without vendor lock-in, and in 2026 the tooling has matured to the point where there is no excuse not to implement it.
In conclusion, OpenTelemetry observability is an essential discipline for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.