How Micrometer Observation, Micrometer Tracing and OpenTelemetry fit together

The stack has four layers, and each has one job.

The Observation API is the instrumentation layer. Spring MVC, WebFlux, RestTemplate, WebClient and your own code create an Observation around a unit of work. An observation has a name, a start and a stop, low- and high-cardinality key-values, and an error if one occurred. On its own it records nothing.

ObservationHandlers decide what an observation becomes. They are registered on the ObservationRegistry. A meter handler turns each observation into timers in a MeterRegistry. A tracing handler turns the same observation into a span through a Tracer. One instrumentation point therefore produces both a latency metric and a span, and their names and tags stay consistent.

Micrometer Tracing is a facade over tracers. It defines Tracer, Span and propagation abstractions. A bridge implements them. The OpenTelemetry bridge maps them onto the OpenTelemetry SDK. A Brave bridge also exists.

The OTLP exporter belongs to the OpenTelemetry SDK. It batches finished spans and sends them over OTLP to a collector or to any backend that accepts OTLP.

Why this replaced Spring Cloud Sleuth

Sleuth instrumented Spring for tracing only and lived outside the core framework. In Spring Boot 3, Sleuth is not supported. Its tracing core moved into Micrometer Tracing, and the framework itself emits observations. Metrics and traces now come from the same source rather than from two parallel instrumentation paths.

Who benefits

Backend teams instrument once. Platform teams choose the bridge, the exporter and the collector routing, and they can change the tracing backend without touching application code.

+----------------------------------------------------------------+
| Spring MVC, WebFlux, RestTemplate, WebClient, your own code    |
+----------------------------------------------------------------+
                                | Observation: name, start/stop,
                                v key-values, error
+----------------------------------------------------------------+
| ObservationRegistry -> registered ObservationHandlers          |
+----------------------------------------------------------------+
      | meter handler                          | tracing handler
      v                                        v
+-------------------------+   +----------------------------------+
| MeterRegistry           |   | Micrometer Tracing (facade)      |
| timers, latency metrics |   | Tracer, Span, propagation        |
+-------------------------+   +----------------------------------+
                                               | implemented by
                                               v
                              +----------------------------------+
                              | OpenTelemetry bridge -> OTel SDK |
                              | OTLP exporter: batches spans     |
                              +----------------------------------+
                                               | OTLP
                                               v
                              +----------------------------------+
                              | Collector or any OTLP backend    |
                              +----------------------------------+

Instrumented code creates one observation, and registered handlers turn it into a timer in the MeterRegistry and into a span that goes through the Micrometer Tracing facade, the OpenTelemetry bridge and the OTLP exporter to a collector or backend.

Minimal setup: dependencies, OTLP exporter and sampling configuration

Start with three dependencies: spring-boot-starter-actuator, io.micrometer:micrometer-tracing-bridge-otel and io.opentelemetry:opentelemetry-exporter-otlp. Leave the version off all three. Spring Boot's dependency management already aligns Micrometer, Micrometer Tracing and the OpenTelemetry SDK. If you pin one of them by hand, the bridge and the SDK can end up on incompatible versions, and that usually shows up at runtime as a linkage error rather than at build time.

Use only one bridge. If micrometer-tracing-bridge-brave is also on the classpath, two tracer implementations compete for auto-configuration. The usual result is missing or duplicated spans and trace IDs that don't match between logs and the backend. A transitive dependency can pull Brave in without you noticing, so check the resolved dependency tree, not just your build file.

Configure the exporter with Spring Boot properties rather than raw OpenTelemetry SDK environment variables. That way configuration lives in one place and Boot's conditions apply consistently:

  • Endpoint: set management.otlp.tracing.endpoint explicitly. For OTLP over HTTP, this is typically the Collector's port 4318 with the /v1/traces path.
  • Protocol: recent Spring Boot versions expose a transport property to choose between HTTP and gRPC. Property names in this namespace have changed across releases, so check the reference documentation for your Boot version.
  • Sampling: set management.tracing.sampling.probability. The default is 0.1, so nine out of ten requests in dev produce no trace. That looks like broken instrumentation. Use 1.0 locally and choose a deliberate production value.
  • Service name: set spring.application.name. It becomes the service.name resource attribute. Without it, spans from different services are hard to tell apart in the backend.
# application.yml
# Assumes spring-boot-starter-actuator, micrometer-tracing-bridge-otel and
# opentelemetry-exporter-otlp are declared WITHOUT versions, so Boot's
# dependency management keeps them aligned.
spring:
  application:
    # Exported as the service.name resource attribute on every span
    name: order-service

management:
  tracing:
    sampling:
      # Default is 0.1, so most local requests would produce no trace.
      # Sample everything while developing.
      probability: 1.0
  otlp:
    tracing:
      # OTLP/HTTP endpoint of a local OpenTelemetry Collector
      endpoint:
      # Transport selection (http or grpc) exists in recent Boot versions;
      # verify the property name against the reference docs for your version.
      transport: http

---
# Production overrides, activated with the 'prod' profile
spring:
  config:
    activate:
      on-profile: prod

management:
  tracing:
    sampling:
      # Deliberate production value; tune it to your traffic and backend cost
      probability: 0.2
  otlp:
    tracing:
      endpoint:

A Spring Boot application.yml that sets the service name, uses full sampling in development, points the OTLP exporter at a Collector over HTTP, and overrides the sampling rate and endpoint for a production profile.

Exporting to a local OpenTelemetry Collector

For local development, run the Collector as a container next to your services with Docker Compose. Expose the standard OTLP ports, 4317 for gRPC and 4318 for HTTP, and point management.otlp.tracing.endpoint at the HTTP receiver's /v1/traces path. In the Collector pipeline, wire the OTLP receiver to a batch processor and then to the debug exporter. The debug exporter prints received spans to the Collector's stdout, so docker compose logs shows whether spans arrive and which attributes they carry, without any backend running.

Send to a Collector in production as well, not straight to a vendor or storage backend. The Collector handles batching, retry queues, attribute scrubbing, tail sampling and the choice of backend. All of that becomes infrastructure configuration you can change without rebuilding or redeploying applications. Tail sampling in particular depends on a component that sees whole traces, and an individual service never does. Note that the tail sampling processor ships in the contrib distribution, not the core one.

With direct export, the application's exporter queue lives in heap memory, and export runs on application threads. When the backend is slow or down, that queue fills, memory pressure rises, and export threads stay busy with retries. The availability of your observability vendor then affects the behavior of your service.

A Collector reduces this risk but does not remove it. Spans are still dropped when the SDK's queue overflows, when the Collector is unreachable, or when the Collector's own retry queue fills during a long backend outage. Batching smooths short failures. It does not make delivery guaranteed.

#!/usr/bin/env bash
set -euo pipefail

# Collector pipeline: OTLP receiver -> batch processor -> debug exporter.
# The debug exporter prints spans to stdout, so no backend is required locally.
cat > otel-collector.yaml <<'EOF'
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317   # OTLP/gRPC
      http:
        endpoint: 0.0.0.0:4318   # OTLP/HTTP, spans arrive at /v1/traces
processors:
  batch: {}                      # buffers and sends spans in batches
exporters:
  debug:
    verbosity: detailed          # print span names and attributes
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
EOF

# Run the Collector next to your services.
# For tail sampling, switch to the contrib image (otel/opentelemetry-collector-contrib).
cat > compose.yaml <<'EOF'
services:
  otel-collector:
    image: otel/opentelemetry-collector:latest
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
    ports:
      - "4317:4317"
      - "4318:4318"
EOF

docker compose up -d

# In the Spring Boot app (application.properties):
#   management.otlp.tracing.endpoint=
# Then exercise the app and watch the spans arrive:
docker compose logs -f otel-collector

A local Collector run with Docker Compose that receives OTLP on 4317/4318, batches spans and prints them with the debug exporter so you can check what your Spring Boot app sends.

What is instrumented automatically and how trace context propagates

Without writing instrumentation code, you get spans for:

  • Inbound HTTP requests handled by Spring MVC or WebFlux.
  • Outbound HTTP calls made through RestTemplate, RestClient or WebClient, but only when the client is built from the RestTemplateBuilder, RestClient.Builder or WebClient.Builder bean that Spring Boot provides. These builders have the observation interceptor already attached, and that interceptor writes the trace context into outgoing headers.
  • Other Spring integrations that emit observations, such as Kafka and RabbitMQ templates and listeners. Some of these must be switched on with an observation property before they produce spans.

Verifying propagation across two services

Set up service A to call service B through an injected builder. In service B, log the incoming traceparent header. It has the form version-traceid-spanid-flags. The trace ID in that header should match the trace ID in A's log line. In the Collector's debug output, B's server span should list A's client span as its parent. If B starts a new trace ID, propagation failed.

Where it silently breaks

A client created with new RestTemplate() or WebClient.create() never passes through the auto-configured builder. It produces no client span and sends no header, so the downstream service starts a new trace. Libraries without observation support behave the same way. Neither case raises an error, which is why gaps tend to show up only when someone looks at a trace.

Mixed propagation formats cause a similar split. During a migration from Sleuth or a vendor agent, some services may still send B3 headers while others expect W3C. Each hop reads only the format it recognizes, so the trace splits at that hop. Configure services to emit and accept both formats until every service in the call path uses the same one.

// Service A: calls B through the Spring Boot-provided builder
@RestController
class OrderController {
    private static final Logger log = LoggerFactory.getLogger(OrderController.class);
    private final RestClient instrumented;
    // Anti-pattern for contrast: RestClient.create() bypasses the auto-configured
    // builder, so calls through it produce no client span and send no traceparent
    private final RestClient bare = RestClient.create();

    OrderController(RestClient.Builder builder) {
        // The injected builder already carries the observation setup, so each call
        // gets a client span and the trace context is written into outgoing headers
        this.instrumented = builder.baseUrl("").build();
    }

    @GetMapping("/orders/{id}")
    String order(@PathVariable String id) {
        // With log correlation enabled, this line carries A's traceId and spanId
        log.info("calling inventory for order {}", id);
        return instrumented.get().uri("/stock/{id}", id).retrieve().body(String.class);
    }
}

// Service B: log the incoming header so you can compare trace IDs with A
@RestController
class StockController {
    private static final Logger log = LoggerFactory.getLogger(StockController.class);

    @GetMapping("/stock/{id}")
    String stock(@PathVariable String id,
                 @RequestHeader(value = "traceparent", required = false) String traceparent) {
        // Expected form: 00-<32 hex trace id>-<16 hex span id of A's client span>-<flags>
        // A null value or a trace ID different from A's means propagation broke
        log.info("incoming traceparent={}", traceparent);
        return "in-stock";
    }
}

Service A calls service B through the injected RestClient.Builder, and service B logs the incoming traceparent header so you can check that its trace ID matches the one in A's log line. The bare RestClient.create() client is included for contrast: calls through it would send no traceparent header.

Custom observations, cardinality, filtering and log correlation

When auto-instrumentation stops at a framework boundary, wrap the business operation yourself. Observation.createNotStarted("order.checkout", registry) creates an observation, lowCardinalityKeyValue and highCardinalityKeyValue attach attributes, and .observe(...) runs the code with that observation active. One observation produces both a timer and a span.

@Observed gives the same result declaratively. It only works when an ObservedAspect bean is registered and Spring AOP is on the classpath. Without them the annotation is silently ignored.

Cardinality decides where data lands

Low-cardinality key-values become metric tags and span attributes. High-cardinality ones, such as order IDs or user IDs, go only on spans. The split exists because every distinct tag combination creates a new time series. If you put a user ID on a timer, metric storage grows with your user count.

Shaping what gets recorded

  • ObservationPredicate: returns false to skip an observation entirely. Use it to drop /actuator/health probes, which otherwise flood traces with noise.
  • ObservationFilter: mutates the context before it is reported. Use it to add common key-values like region or deployment ring.
  • ObservationConvention: replaces how a component names and tags its observations. Use it to rename or remove default tags without forking the instrumentation.

Log correlation and context propagation

Micrometer Tracing writes traceId and spanId into the MDC. Reference them in your log pattern with %X{traceId} and %X{spanId}.

The MDC is thread-local, so values are lost when work changes threads. Use the context-propagation library to carry them across:

  • Executors: wrap them with ContextExecutorService.
  • @Async: set a ContextPropagatingTaskDecorator on the task executor.
  • Reactor: enable automatic context propagation.
  • Kotlin coroutines: add the observation registry to the coroutine context as a context element.

When to use it, when to avoid it, and the alternatives

Micrometer Tracing with the OTel bridge is not the only way to get traces out of a Spring Boot service. The real choice is between three approaches, and you should run only one of them as your primary source of spans.

Approach Coverage Startup and runtime cost Control Pick it when
Micrometer Tracing + OTel bridge Spring-instrumented components plus your own observations Low: plain library code Full: it is your dependency graph You want one API for metrics and traces and OTLP export
OpenTelemetry Java agent Broad: many third-party clients, drivers and frameworks Higher startup: classes are transformed at load time Limited: bytecode changes you do not own You need coverage you cannot add by code, or many non-Spring JVM services
Micrometer Tracing + Brave Same as the bridge Low Full You already run a Zipkin-based pipeline that works

Do not stack the agent on top of Micrometer instrumentation. Both will instrument the same HTTP server and client calls, so you get duplicate spans unless you disable one side per library.

Attribute names also differ. The bridge emits Micrometer's observation key names, while the agent follows OpenTelemetry semantic conventions. Dashboards and alerts built on one set of names break silently when you switch, so treat a migration as a dashboard migration too.

Sampling decides where cost is controlled. Head-based sampling in the application is cheap but blind: it drops traces before it knows they contain errors. Tail sampling in the Collector keeps slow or failed traces. The trade-off is that the Collector must buffer complete traces, which costs memory and needs trace-aware load balancing.

Verification checklist and impact on cost and engineering time

Verification checklist

Run these checks in a staging environment before trusting the dashboards:

  • Spans reach the Collector. Add the Collector's debug exporter to the traces pipeline and send a request. If nothing appears, the problem is in the application: the exporter endpoint, protocol (gRPC or HTTP) or sampling configuration. If spans appear but never reach the backend, the problem is in the Collector's exporter configuration.
  • Parent-child links survive service hops. Call an endpoint that calls a downstream service. Both spans should share a trace ID, and the downstream span's parent ID should match the caller's client span. A broken link usually means the request went through a client that Spring did not create, so no traceparent header was sent.
  • Metrics and spans share names. An observation such as http.server.requests should produce both a timer and a span. If the names differ, you cannot pivot from a latency spike on a dashboard to the traces behind it.
  • Logs carry trace IDs. Pick one trace ID from the backend and search your logs for it.

Cost and engineering time

The savings come from structure. You instrument once against the Observation API, so a timer and a span come from the same code. Changing tracing vendors means editing Collector configuration, not redeploying every service. No vendor SDK calls end up in business code.

The ongoing costs are real and recurring. Context propagation breaks quietly across thread pools and reactive boundaries, and finding the cause takes time. Every new tag needs a cardinality review. The Collector is production infrastructure that you must size, monitor and upgrade like any other service.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot micrometer tracing opentelemetry, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.