APM for Java Quarkus and Micronaut Services
Wire OpenTelemetry APM into Quarkus and Micronaut apps — including GraalVM native images where the usual Java agent trick doesn't work.
The native-image build finished at 3:47 PM on a Thursday. Forty-three seconds of GraalVM compilation, down from twelve minutes of JVM warm-up we'd been living with. We pushed to staging, ran a load test, and watched the Datadog APM dashboard show... nothing. Zero traces. The service was running, the endpoints returned 200s, but the Java agent that had been working fine in JVM mode was completely silent.
I spent the next two hours re-reading Datadog's docs before realizing the problem. The Java agent instruments bytecode at class-load time. Native images don't load classes at runtime — they're compiled ahead-of-time. No class loader, no agent, no traces.
This is the post I needed that Thursday. It covers APM setup for Quarkus and Micronaut with OpenTelemetry, including the native-image path that actually works. The patterns apply whether you're exporting to JustAnalytics, Jaeger, or the collector of your choice.
What you'll have at the end
A Quarkus or Micronaut service that exports distributed traces to an OTLP-compatible backend. Works in JVM mode and native-image mode. You'll see request latency, database calls, HTTP client spans, and custom spans for business logic — the full APM picture.
We'll also add startup-latency tracing so you can measure how long your native binary takes to become request-ready. That's useful when you're tuning GraalVM build flags and want hard numbers instead of vibes.
Prerequisites
- Quarkus 3.8+ or Micronaut 4.3+ — both have mature OpenTelemetry support
- GraalVM 23+ if you're building native images (CE or Oracle, both work)
- An OTLP endpoint — JustAnalytics, Jaeger, or a local collector. We'll use JustAnalytics in the examples.
- Basic familiarity with Maven or Gradle — the snippets assume Maven but translate directly
- 10 minutes — this isn't complicated once you know the native-image gotcha
Step 1: Add the OpenTelemetry extension (Quarkus)
For Quarkus, the OpenTelemetry extension handles all the compile-time instrumentation. Install it via the CLI or add the dependency manually.
./mvnw quarkus:add-extension -Dextensions='opentelemetry'
Or in your pom.xml:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-opentelemetry</artifactId>
</dependency>
This pulls in the OTel SDK, the OTLP exporter, and the compile-time instrumentation for JAX-RS, REST clients, JDBC, and reactive routes. The bundle adds about 1.2MB to your native image. Worth it.
Why this matters: The extension doesn't use the Java agent. It weaves instrumentation into your code at build time, which means spans export correctly from native binaries without any runtime magic.
What you should see: After a rebuild, your logs should mention OpenTelemetry initialized during startup. No traces yet — we haven't configured the exporter.
Step 2: Add the Tracing module (Micronaut)
For Micronaut, the equivalent is the Tracing module with the OpenTelemetry provider.
<dependency>
<groupId>io.micronaut.tracing</groupId>
<artifactId>micronaut-tracing-opentelemetry</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
Micronaut's annotation processors handle the compile-time instrumentation. HTTP server requests, HTTP clients, and @Traced methods get spans automatically.
If you're using Micronaut Data or R2DBC, add the tracing integration for those as well. (I always forget this one.)
<dependency>
<groupId>io.micronaut.tracing</groupId>
<artifactId>micronaut-tracing-opentelemetry-jdbc</artifactId>
</dependency>
Gotcha: Micronaut's OTel support requires explicit dependency on the OTLP exporter — it's not pulled transitively. I forgot this the first time and got a NoClassDefFoundError for OtlpGrpcSpanExporter at runtime. Fun.
Step 3: Configure the OTLP exporter
Both frameworks use property files for exporter config. Point them at your collector.
Quarkus (application.properties):
quarkus.application.name=order-service
quarkus.otel.exporter.otlp.endpoint=https://otel.justanalytics.app:4317
quarkus.otel.exporter.otlp.headers=Authorization=Bearer YOUR_API_KEY
quarkus.otel.resource.attributes=deployment.environment=production
Micronaut (application.yml):
micronaut:
application:
name: order-service
tracing:
opentelemetry:
enabled: true
exporter:
otlp:
endpoint: https://otel.justanalytics.app:4317
headers:
Authorization: Bearer YOUR_API_KEY
resource:
attributes:
deployment.environment: production
The deployment.environment attribute is optional but helps when you're filtering traces in the dashboard. I've lost hours to "why is production slow" when I was actually looking at staging data.
What you should see: Hit any endpoint. You should see a trace appear in your dashboard within a few seconds — the request span plus any downstream calls.
Step 4: Verify spans in JVM mode first
Before you fight native-image issues, confirm everything works on the JVM.
# Quarkus
./mvnw quarkus:dev
# Micronaut
./mvnw mn:run
Hit an endpoint:
curl http://localhost:8080/orders/123
Check your dashboard. You should see:
- A root span for
GET /orders/{id} - Child spans for any database queries
- Child spans for outbound HTTP calls
If spans aren't appearing, check these in order:
- Endpoint URL — is it
https://with port 4317 (gRPC) or 4318 (HTTP)? - API key — copy-paste errors are real. I've typed
Authorizatonmore than once. Embarrassing every time. - Firewall — does your dev machine allow outbound gRPC? Corporate VPNs love blocking random ports.
The JustAnalytics dashboard shows traces in real-time with WebSocket updates, so you'll know within seconds if it's working. See our distributed tracing deep dive for span correlation patterns.
Step 5: Build and run the native image
Now the part that broke for me. Build the native image:
# Quarkus
./mvnw package -Dnative
# Micronaut (with GraalVM installed)
./mvnw package -Dpackaging=native-image
Run it:
./target/order-service-1.0.0-runner
Hit the same endpoint. Traces should appear — because the instrumentation was compiled in, not agent-injected.
If you're still seeing nothing, check the native-image build output for warnings about reflection config. OpenTelemetry's OTLP exporter uses some reflective calls that need to be registered. Both Quarkus and Micronaut handle this automatically for their supported integrations, but custom span processors might need manual config.
Step 6: Add startup-latency tracing
This is the part most tutorials skip. Cloud-native frameworks brag about sub-100ms startup times, but how do you actually measure that in production?
Add a span that captures the time from process start to ready.
Quarkus:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@Startup
public class StartupTracer {
private static final long PROCESS_START = ProcessHandle.current()
.info().startInstant().orElse(java.time.Instant.now()).toEpochMilli();
@PostConstruct
void traceStartup() {
Tracer tracer = GlobalOpenTelemetry.getTracer("startup");
Span span = tracer.spanBuilder("application.startup")
.setStartTimestamp(PROCESS_START, java.util.concurrent.TimeUnit.MILLISECONDS)
.startSpan();
span.setAttribute("startup.type", System.getProperty("org.graalvm.nativeimage.kind", "jvm"));
span.end();
}
}
Micronaut:
import io.micronaut.context.event.StartupEvent;
import io.micronaut.runtime.event.annotation.EventListener;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import jakarta.inject.Singleton;
@Singleton
public class StartupTracer {
private static final long PROCESS_START = ProcessHandle.current()
.info().startInstant().orElse(java.time.Instant.now()).toEpochMilli();
@EventListener
void onStartup(StartupEvent event) {
Tracer tracer = GlobalOpenTelemetry.getTracer("startup");
Span span = tracer.spanBuilder("application.startup")
.setStartTimestamp(PROCESS_START, java.util.concurrent.TimeUnit.MILLISECONDS)
.startSpan();
span.setAttribute("startup.type", isNativeImage() ? "native" : "jvm");
span.end();
}
private boolean isNativeImage() {
return System.getProperty("org.graalvm.nativeimage.imagecode") != null;
}
}
Now you get a span measuring the gap between process start and application ready. Compare JVM vs native startup times with actual data instead of "feels faster."
In one service we measured: JVM mode startup was 2.3 seconds, native was 47ms. 49x. The skeptical architect who kept asking "but what about JIT warm-up benefits?" finally stopped asking. (He still sends passive-aggressive Slack messages about memory footprint, but that's a different fight.)
Common errors and how to fix them
NoClassDefFoundError: OtlpGrpcSpanExporter
You forgot the explicit OTLP exporter dependency in Micronaut. Add opentelemetry-exporter-otlp to your pom.xml.
Traces appear in JVM mode but not native mode
Check your native-image build logs for reflection warnings. If you're using a custom SpanProcessor or SpanExporter, you may need to add reflection config. Both frameworks provide @RegisterForReflection (Quarkus) or GraalVM reflection JSON (Micronaut) for this.
Connection refused to the OTLP endpoint
gRPC uses port 4317, HTTP uses 4318. The endpoint URL must match. JustAnalytics accepts both protocols — check your dashboard settings for the correct URL.
Startup span shows negative duration
ProcessHandle.current().info().startInstant() isn't available on all JVMs. Fall back to Instant.now() if it's empty, or use a static initializer that captures the timestamp at class load.
High memory usage from span buffering The default batch span processor buffers up to 2048 spans. On a high-throughput native service with minimal heap, this eats into your memory budget. Annoying, but fixable. Configure a smaller buffer:
# Quarkus
quarkus.otel.bsp.max-queue-size=512
quarkus.otel.bsp.max-export-batch-size=128
Next steps
APM's running. Now what?
If you're running multiple services, wire up trace propagation so spans link across service boundaries. Both Quarkus and Micronaut inject the W3C traceparent header automatically for HTTP clients — just make sure your downstream services also have OTel configured.
For error correlation, JustAnalytics links error events to the trace they occurred in. If you've got error tracking enabled alongside APM, clicking an error takes you to the full request trace — useful when an exception happened three services deep. Check our OpenTelemetry collector setup guide for advanced routing patterns.
Teams running JVM services alongside frontend apps can use the same JustAnalytics instance for both — the under-5KB browser script exports to the same OTLP backend, so you get frontend Web Vitals and backend P99 latency in one dashboard. Our session replay integration guide shows how to link user sessions to backend traces. Cross-stack correlation matters more than most teams realize. (I didn't believe this until a "frontend bug" turned out to be a 4-second database query hiding behind a loading spinner.)
If you're also dealing with click fraud on paid acquisition funnels, ClickzProtect integrates cleanly — the conversion events you track through JustAnalytics can feed into fraud pattern detection. And for teams in pay-per-call verticals, VeloCalls handles the call-tracking side while your web APM handles the digital funnel. Developer teams using DevOS for internal tooling can route their observability data to the same JustAnalytics instance.
Frequently Asked Questions
Does the OpenTelemetry Java agent work with GraalVM native images?
No. The Java agent uses bytecode instrumentation at runtime, and native images don't have a JVM class loader to instrument. You need compile-time instrumentation via the Quarkus OpenTelemetry extension or Micronaut's Tracing module. Both inject the necessary code during the build, so spans export correctly from native binaries.
What's the performance overhead of APM on cloud-native JVM frameworks?
On JVM mode, expect 2-5% latency overhead depending on span volume — similar to Spring. Native images see about 1-3% because the instrumentation is compiled in rather than bytecode-woven. The bigger cost is memory for span buffering: budget 20-40MB on top of your baseline heap for a moderate-throughput service.
Can I use the same OpenTelemetry setup for both Quarkus and Micronaut?
The collector and backend (JustAnalytics, Jaeger, etc.) stay the same. The framework-side config differs: Quarkus uses quarkus.otel.* properties in application.properties, Micronaut uses tracing.* in application.yml. Exporter endpoints and service names translate directly, so switching frameworks doesn't mean re-learning your observability stack.
How do I trace startup latency in a native-image service?
Add a manual span in your main() or @PostConstruct lifecycle hook that starts before the framework boots and ends after it's ready. Export to a synchronous OTLP endpoint so the span actually ships before the first request. This gives you a baseline for native-image build tuning — useful when you're deciding whether to enable build-time init for slow dependencies.
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).
Author at JustAnalytics.