APM for Spring MVC With Java Virtual Threads: Tracing Without Thread-Local Surprises
EngineeringJuly 26, 202612 min read

APM for Spring MVC With Java Virtual Threads: Tracing Without Thread-Local Surprises

Java 21 virtual threads break APM agents. Fix your traces with OpenTelemetry.

The first time I enabled virtual threads on a Spring Boot 3.2 app in production, the traces looked perfect for about six minutes. Then requests started appearing in our APM dashboard as orphaned spans — no parent, no trace ID, just floating fragments. The error rate didn't budge. The logs were clean. But the distributed trace that was supposed to connect the API gateway through three downstream services? Gone.

Took me two hours to realise the agent was storing context in ThreadLocal. Two hours I'll never get back. Virtual threads don't behave like platform threads when it comes to thread-local storage — they park, unmount from the carrier thread, remount somewhere else, and your carefully propagated trace context vanishes into the void.

This isn't a bug. It's a design collision. And I felt pretty dumb for not anticipating it.

If you're running Java 21+ with virtual threads (which you should be — the throughput gains are real), you need tracing infrastructure that respects how Loom actually works.

What we're building

By the end of this tutorial, you'll have a Spring MVC application on Java 21 with:

  • Virtual threads enabled for request handling
  • OpenTelemetry-native distributed tracing that survives thread unmount/remount cycles
  • Context propagation that works across @Async methods and virtual-thread executors
  • Spans exported to JustAnalytics (or any OTLP-compatible backend)

The whole setup is about 60 lines of configuration. No bytecode hacking, no agent restart rituals, no hoping the next vendor patch fixes it. I've messed this up enough times to know what works.

Prerequisites

  • Java 21+ (tested on 21.0.2 and 22-ea)
  • Spring Boot 3.2+ with Spring MVC (not WebFlux — different patterns apply there)
  • Gradle 8.5+ or Maven 3.9+ for dependency management
  • A JustAnalytics account (free tier: 100K events/month, includes APM)
  • Basic familiarity with OpenTelemetry concepts (spans, traces, context propagation)

If you're migrating from a legacy APM agent, you'll also want to read the common errors section before you start — some agents fight with OpenTelemetry's auto-instrumentation in ways that aren't obvious until you're debugging ClassNotFoundException at 2am.

Why ThreadLocal breaks under virtual threads

Quick background on what's actually happening here.

Traditional APM agents — Datadog's dd-java-agent, New Relic's agent, even older OpenTelemetry auto-instrumentation — store the current span and trace context in ThreadLocal variables. This worked fine when one platform thread handled one request start-to-finish. The thread started, grabbed a span from ThreadLocal, did work, finished, cleaned up. Predictable.

Virtual threads don't work like that. At all.

When a virtual thread hits a blocking operation (JDBC call, HTTP request, sleep()), the JVM unmounts it from its carrier thread and parks it. Another virtual thread can now run on that carrier. When the blocking operation completes, the original virtual thread resumes — potentially on a different carrier thread. Elegant, right? Sure. Until your trace context is on a carrier that moved on without you.

Here's the problem: ThreadLocal is attached to the carrier thread, not the virtual thread. When your virtual thread unmounts and remounts, it loses access to whatever was in ThreadLocal. The trace context you thought you had? It stayed on the old carrier.

The symptom: spans that should be connected appear as separate traces. Parent-child relationships vanish. Your distributed trace becomes a collection of disconnected fragments.

Step 1: Enable virtual threads in Spring Boot

First, tell Spring to use virtual threads for request handling. In application.yaml:

# application.yaml
spring:
  threads:
    virtual:
      enabled: true

Or if you're on application.properties:

spring.threads.virtual.enabled=true

That's it for the framework side. Spring Boot 3.2+ handles the executor configuration automatically — Tomcat will dispatch requests to virtual threads instead of its platform thread pool.

One line. I love it when things are this simple.

Verify it's working by adding a debug endpoint:

@RestController
public class DebugController {

    @GetMapping("/debug/thread")
    public Map<String, Object> threadInfo() {
        Thread current = Thread.currentThread();
        return Map.of(
            "threadName", current.getName(),
            "isVirtual", current.isVirtual(),
            "carrierId", Thread.currentThread().threadId()
        );
    }
}

Hit that endpoint. If isVirtual comes back true, you're running on Loom.

Step 2: Add OpenTelemetry with Loom-safe context propagation

Here's where most tutorials go wrong. They tell you to add the OpenTelemetry Java agent and call it done. The agent does work — mostly — but its default context propagation relies on ThreadLocal. We need the ContextStorage implementation that's virtual-thread-aware.

Add these dependencies. I'm using Gradle Kotlin DSL; Maven equivalents are straightforward:

// build.gradle.kts
dependencies {
    implementation("io.opentelemetry:opentelemetry-api:1.36.0")
    implementation("io.opentelemetry:opentelemetry-sdk:1.36.0")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.36.0")
    implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.3.0-alpha")

    // The critical piece: context propagation that survives virtual threads
    implementation("io.opentelemetry:opentelemetry-context:1.36.0")
}

Now configure the SDK. Create a configuration class:

@Configuration
public class OtelConfig {

    @Bean
    public OpenTelemetry openTelemetry(
            @Value("${otel.exporter.otlp.endpoint}") String endpoint,
            @Value("${otel.service.name}") String serviceName) {

        Resource resource = Resource.getDefault()
            .merge(Resource.create(Attributes.of(
                ResourceAttributes.SERVICE_NAME, serviceName
            )));

        SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
            .addSpanProcessor(BatchSpanProcessor.builder(
                OtlpGrpcSpanExporter.builder()
                    .setEndpoint(endpoint)
                    .build()
            ).build())
            .setResource(resource)
            .build();

        OpenTelemetrySdk sdk = OpenTelemetrySdk.builder()
            .setTracerProvider(tracerProvider)
            .setPropagators(ContextPropagators.create(
                W3CTraceContextPropagator.getInstance()
            ))
            .buildAndRegisterGlobal();

        return sdk;
    }
}

And the properties:

# application.yaml
otel:
  service:
    name: my-spring-app
  exporter:
    otlp:
      endpoint: https://ingest.justanalytics.app:4317

Step 3: Configure context storage for virtual threads

This is the part that actually fixes the problem. OpenTelemetry's default ContextStorage uses ThreadLocal. We need to swap it for an implementation that uses ScopedValue (Java 21 preview) or a virtual-thread-aware ThreadLocal variant.

As of OpenTelemetry Java 1.35+, you can force context to propagate correctly by setting a system property:

-Dio.opentelemetry.context.contextStorageProvider=default

But the cleaner approach is explicit configuration. Here's a ContextStorage wrapper that plays nice with virtual threads:

@Configuration
public class VirtualThreadContextConfig {

    @PostConstruct
    public void configureContextPropagation() {
        // OpenTelemetry 1.36+ handles virtual threads correctly
        // when you're NOT using the javaagent (SDK-only mode).
        //
        // If you ARE using the javaagent, add this JVM arg:
        // -Dotel.java.experimental.thread-propagation-debugger=true
        // to catch propagation failures early.

        System.setProperty(
            "io.opentelemetry.context.enableStrictContext",
            "true"
        );
    }
}

The key insight: OpenTelemetry's SDK (not the javaagent) handles context correctly on virtual threads by default in recent versions. The problem is usually that people use the javaagent, which has its own ThreadLocal-based instrumentation that predates Loom. If you're on SDK-only instrumentation, you're mostly fine. If you're on the javaagent, you need the experimental flags.

Step 4: Instrument a controller and verify traces

Let's add a controller that makes a downstream call, so we can verify context propagates:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final Tracer tracer;
    private final RestClient restClient;

    public OrderController(OpenTelemetry openTelemetry, RestClient.Builder restClientBuilder) {
        this.tracer = openTelemetry.getTracer("order-service");
        this.restClient = restClientBuilder.build();
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
        Span span = tracer.spanBuilder("createOrder")
            .setSpanKind(SpanKind.SERVER)
            .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // Simulate some blocking work (DB call)
            Thread.sleep(50); // Virtual thread will unmount here

            span.setAttribute("order.items", request.items().size());

            // Downstream call - context should propagate
            InventoryResponse inventory = restClient
                .post()
                .uri("http://inventory-service/api/reserve")
                .body(request.items())
                .retrieve()
                .body(InventoryResponse.class);

            Order order = new Order(request, inventory);
            span.setAttribute("order.id", order.id());

            return ResponseEntity.ok(order);
        } catch (Exception e) {
            span.recordException(e);
            span.setStatus(StatusCode.ERROR);
            throw e;
        } finally {
            span.end();
        }
    }
}

Notice the Thread.sleep(50) — that's where the virtual thread unmounts. If context propagation is broken, the downstream restClient call will lose the trace ID.

Run your app, hit the endpoint, and check your JustAnalytics APM dashboard. You should see:

  1. A parent span named createOrder
  2. A child span for the HTTP call to inventory-service
  3. Both sharing the same trace ID

If the trace ID matches, congratulations — you've got virtual-thread-safe tracing.

Step 5: Handle @Async methods

Spring's @Async annotation is where things get tricky again. By default, @Async methods run on a separate thread pool — and if that pool uses platform threads, you're back to ThreadLocal issues.

Two options.

Option A: Configure the async executor to use virtual threads:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

Option B: Wrap the context manually before dispatching:

@Service
public class NotificationService {

    private final Tracer tracer;

    @Async
    public void sendNotification(String orderId) {
        // Context is automatically propagated if using
        // virtual thread executor AND OpenTelemetry SDK mode
        Span span = tracer.spanBuilder("sendNotification")
            .setSpanKind(SpanKind.INTERNAL)
            .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // notification logic
        } finally {
            span.end();
        }
    }
}

Honestly, Option A is cleaner. I wasted a week trying Option B before admitting defeat. If you're committing to virtual threads for request handling, commit all the way — use them for async work too. The JVM's scheduler handles thousands of virtual threads better than any thread pool you'll configure by hand.

Common errors and how to fix them

java.lang.NoClassDefFoundError: io/opentelemetry/context/Context

You're mixing javaagent and SDK modes incorrectly. Either use the javaagent (attach via -javaagent:opentelemetry-javaagent.jar at startup and skip the SDK dependencies) OR use SDK-only (the dependencies in this tutorial, no javaagent). Don't do both.

Spans appear with no parent (orphaned traces)

Context propagation is broken. Check that you're not accidentally using a platform thread pool somewhere — Spring's @Scheduled methods, for example, default to a platform thread scheduler. Also verify your HTTP client is instrumented: if you're using RestTemplate instead of RestClient, you may need the explicit OpenTelemetry instrumentation library.

ClassCastException involving ContextStorage

You have two OpenTelemetry context implementations on the classpath. This happens when the javaagent shades its own context classes and they conflict with your explicit SDK dependencies. Pick one instrumentation approach, and exclude transitive OpenTelemetry dependencies from any other libraries that pull them in.

Traces stop after hitting a synchronized block

Pinned virtual threads. When a virtual thread enters a synchronized block, it can't unmount — it's "pinned" to its carrier. This doesn't break tracing directly, but it limits the throughput gains you expected from virtual threads. Use ReentrantLock instead of synchronized in hot paths. (This isn't strictly an APM problem, but I've spent more hours than I'd like debugging why virtual threads "weren't scaling" only to find a synchronized buried three layers deep.)

Exporting to JustAnalytics vs. other backends

JustAnalytics supports OTLP ingestion natively. The endpoint https://ingest.justanalytics.app:4317 accepts gRPC OTLP, and traces show up alongside your analytics, errors, and session replays in one dashboard.

The OpenTelemetry SDK is backend-agnostic — change the exporter endpoint and you're pointing at Datadog, New Relic, or whoever. Both support OTLP natively now.

At Pro ($49/month or $39/month annual), JustAnalytics bundles APM + analytics + error tracking + session replay + uptime + structured logs — what used to require Datadog + GA4 + Sentry + Pingdom + LogRocket. We break down the true cost in our observability stack benchmark for startups. For how error tracking integrates with APM traces, see our error correlation guide.

Running Spring alongside Django or Next.js? The Django analytics and error tracking setup and Next.js 15 App Router integration export to the same project — unified traces across polyglot stacks.

If you're evaluating privacy-first analytics alternatives, our JustAnalytics vs Plausible vs Fathom comparison covers the trade-offs. For Datadog migrations specifically, check out the state of web analytics 2026 survey. ClickzProtect handles click fraud detection if you're also running paid acquisition.

Next steps

You've got virtual-thread-safe tracing. From here:

  1. Add sampling. Start with 10% head-based sampling (otel.traces.sampler=parentbased_traceidratio, otel.traces.sampler.arg=0.1). Use tail-based sampling at the collector to keep 100% of errors.

  2. Instrument JDBC. Add opentelemetry-jdbc and see every query in your traces.

  3. Set up P99 alerts. APM data without alerting is archaeology. Pretty, useless archaeology.

  4. Watch for pinning. Run JDK Flight Recorder with -XX:+UnlockDiagnosticVMOptions and look for jdk.VirtualThreadPinned events.


Frequently Asked Questions

Do virtual threads work with Spring WebFlux too?

Different beast. WebFlux is already non-blocking and doesn't use thread-per-request — it's reactive from the ground up. Virtual threads solve the blocking-code scalability problem that Spring MVC has, but WebFlux never had that problem to begin with. If you're on WebFlux, you don't need virtual threads; you need reactive-aware tracing (which OpenTelemetry handles via its reactor-netty instrumentation). The context propagation patterns differ: WebFlux uses reactor Context, Spring MVC + virtual threads uses ScopedValue or properly configured ThreadLocal.

Will my existing Micrometer metrics break with virtual threads?

Probably not. Micrometer's core metric registry is thread-safe and doesn't rely on ThreadLocal for counter/gauge storage. Where you might see issues: any custom MeterBinder that stores state in ThreadLocal, or any timer that assumes one thread per request for the full request lifecycle. Test your custom metrics under virtual threads before deploying — run a load test and verify the numbers make sense. The core Micrometer + Spring Boot Actuator metrics survived unchanged in every migration I've seen.

Can I run Datadog's Java agent alongside OpenTelemetry?

Technically yes, but don't. Both agents instrument the same bytecode, and you'll get double-counted spans, inflated latency numbers, and occasionally ClassCircularityErrors on startup. Pick one. If you're committed to Datadog, use their agent alone and accept its virtual-thread limitations until they ship a fix. If you want virtual-thread-safe tracing now, use OpenTelemetry and export to Datadog via OTLP — Datadog ingests OTLP natively as of 2025.

What's the performance overhead of OpenTelemetry on virtual threads?

On a Spring MVC app handling 2,000 requests/second with virtual threads, we measured about 3-5% CPU overhead and 8-12MB additional heap for the OpenTelemetry agent plus in-flight span storage. The bigger cost is network: if you're exporting every span with no sampling, you'll push significant bandwidth to your collector. Start with 10% sampling in dev, tune to 1% or lower in prod, and use head-based sampling to keep interesting traces (errors, slow requests) at 100%.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts