CubeAPM
CubeAPM CubeAPM

Micronaut Monitoring with OpenTelemetry: Complete Setup Guide

Micronaut Monitoring with OpenTelemetry: Complete Setup Guide

Table of Contents

Micronaut’s compile-time dependency injection and low memory footprint make it a strong choice for microservices and GraalVM native images. But fast startup times mean nothing if you cannot see what your services are doing in production. A slow database query or a cascading failure across three services is invisible without distributed tracing and metrics in place.

This guide walks through setting up OpenTelemetry in a Micronaut application from scratch: adding the right dependencies, configuring the OTLP exporter, creating custom spans, exporting metrics via Micrometer, and shipping everything to a backend that can actually surface root causes.

Prerequisites

Before starting, make sure you have the following in place:

  • Micronaut 4.x project (Gradle or Maven)
  • Java 17 or later (Java 21 recommended for virtual threads)
  • An OpenTelemetry-compatible backend running and reachable (Jaeger, Zipkin, or an OTLP-compatible collector like the OpenTelemetry Collector)
  • Basic familiarity with application.yml configuration in Micronaut
  • Docker available locally if you want to run Jaeger or the OTel Collector for testing
  • GraalVM 22.3+ if you are targeting native image builds (see the native image note in Step 6)

Step 1: Add OpenTelemetry Dependencies

Micronaut provides official OpenTelemetry support through the micronaut-tracing module. This module uses the OpenTelemetry Autoconfigure SDK, which means it reads configuration from environment variables and application properties without requiring you to wire up the OpenTelemetry object manually.

Add the following to your Gradle build file:

// build.gradle
dependencies {
    // Core Micronaut tracing with OpenTelemetry
    implementation("io.micronaut.tracing:micronaut-tracing-opentelemetry")
    implementation("io.micronaut.tracing:micronaut-tracing-opentelemetry-http")

    // OTLP exporter — sends spans to any OTLP-compatible backend
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")

    // Autoconfigure SDK — reads config from properties/env vars
    implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")

    // Optional: propagation support for W3C TraceContext and Baggage
    implementation("io.opentelemetry:opentelemetry-extension-trace-propagators")
}

For Maven projects:

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>io.micronaut.tracing</groupId>
        <artifactId>micronaut-tracing-opentelemetry</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micronaut.tracing</groupId>
        <artifactId>micronaut-tracing-opentelemetry-http</artifactId>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-sdk-extension-autoconfigure</artifactId>
    </dependency>
</dependencies>

If you are using the Micronaut CLI, you can scaffold a project with tracing included:

mn create-app my-service --features tracing-opentelemetry-exporter-otlp

One thing competitors miss: there is no opentelemetry-micronaut-starter equivalent to what Spring Boot offers. As confirmed in the OpenTelemetry Java instrumentation discussion, zero-code instrumentation without the Java agent is not currently supported for Micronaut. The Micronaut tracing module is the recommended approach for agentless setup, and it does require the dependency additions above.

Step 2: Configure the OTLP Exporter in application.yml

With the dependencies in place, configure the exporter endpoint and service name. The Micronaut tracing module reads OpenTelemetry SDK autoconfigure properties directly from application.yml:

# src/main/resources/application.yml
micronaut:
  application:
    name: my-micronaut-service

otel:
  traces:
    exporter: otlp
  metrics:
    exporter: otlp
  logs:
    exporter: otlp
  exporter:
    otlp:
      endpoint: http://localhost:4317
      protocol: grpc
  resource:
    attributes: service.name=my-micronaut-service,service.version=1.0.0,deployment.environment=production
  propagators: tracecontext,baggage

tracing:
  opentelemetry:
    enabled: true
    exclusions:
      - /health
      - /env/.*
      - /metrics

The exclusions list is important in production. Without it, every health check call from your load balancer creates a span, inflating trace volume and adding noise to your dashboards. Health and readiness endpoints, metrics scrape paths, and internal diagnostics endpoints should always be excluded.

If you prefer environment variables (common in Kubernetes deployments):

export OTEL_SERVICE_NAME=my-micronaut-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_PROPAGATORS=tracecontext,baggage

Environment variables take precedence over application.yml values when both are set, which makes them useful for per-environment configuration without changing the bundled config file.

Step 3: Add Custom Spans and Span Tags

Micronaut tracing provides two annotations for creating spans in your application code without writing any manual instrumentation: @NewSpan and @ContinueSpan.

@NewSpan creates a new child span every time the annotated method is called. @ContinueSpan continues the active span and optionally adds tags to it via @SpanTag.

import io.micronaut.tracing.annotation.NewSpan;
import io.micronaut.tracing.annotation.ContinueSpan;
import io.micronaut.tracing.annotation.SpanTag;
import jakarta.inject.Singleton;

@Singleton
public class OrderService {

    // Creates a new span named "place-order" every time this method is called
    @NewSpan("place-order")
    public OrderResult placeOrder(@SpanTag("order.customer_id") String customerId,
                                   @SpanTag("order.total") double total) {
        return processOrder(customerId, total);
    }

    // Continues the active span — adds a tag without creating a child span
    @ContinueSpan
    public OrderResult processOrder(@SpanTag("order.processing_step") String customerId,
                                     double total) {
        // business logic
        return new OrderResult();
    }
}

For programmatic span creation when annotations are not enough (for example, inside a loop or a conditional branch), use the OpenTelemetry API directly:

import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Scope;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
public class PaymentService {

    @Inject
    Tracer tracer;

    public void processPayment(String paymentId, double amount) {
        Span span = tracer.spanBuilder("process-payment")
                .setAttribute("payment.id", paymentId)
                .setAttribute("payment.amount", amount)
                .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // payment logic here
            span.setAttribute("payment.status", "success");
        } catch (Exception e) {
            span.recordException(e);
            span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR, e.getMessage());
            throw e;
        } finally {
            span.end();
        }
    }
}

Always call span.end() in a finally block. A span that never ends does not get exported — and you will not see it in your tracing backend at all, which is a silent failure that is easy to miss during testing.

Step 4: Set Up Metrics with Micrometer and OTLP

Micronaut uses Micrometer for metrics. To export metrics via OTLP alongside traces, add the Micrometer OTLP registry dependency:

// build.gradle
dependencies {
    implementation("io.micronaut.micrometer:micronaut-micrometer-core")
    implementation("io.micronaut.micrometer:micronaut-micrometer-registry-otlp")
}

Configure the OTLP metrics endpoint in application.yml:

micronaut:
  metrics:
    enabled: true
    export:
      otlp:
        enabled: true
        url: http://localhost:4318/v1/metrics
        step: PT30S  # export interval — 30 seconds

Note the difference in ports: traces typically use gRPC on port 4317, while Micrometer’s OTLP registry uses HTTP on port 4318. If you point the Micrometer exporter at the gRPC port, metrics will silently fail to export with no error in logs — this is a common setup mistake.

Micronaut auto-configures standard JVM and HTTP metrics out of the box when Micrometer is on the classpath. You get JVM memory, GC pause times, CPU usage, and HTTP server request counts and latencies without writing any additional code.

To define a custom metric:

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Counter;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
public class CheckoutService {

    private final Counter checkoutCounter;

    public CheckoutService(MeterRegistry registry) {
        this.checkoutCounter = Counter.builder("checkout.initiated")
                .description("Number of checkout sessions started")
                .tag("region", "us-east")
                .register(registry);
    }

    public void initiateCheckout(String userId) {
        checkoutCounter.increment();
        // checkout logic
    }
}

Step 5: Run a Local OpenTelemetry Collector and Backend

For local development and testing, the fastest path is running Jaeger with OTLP support via Docker:

docker run -d --name jaeger \
  -p 4317:4317 \
  -p 4318:4318 \
  -p 16686:16686 \
  jaegertracing/all-in-one:latest

Jaeger’s UI is available at http://localhost:16686. Traces from your Micronaut service will appear there within seconds of sending requests.

For a production-style setup, run the OpenTelemetry Collector as an intermediary. The collector handles batching, retry, and fan-out to multiple backends. A minimal collector config:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1000

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  logging:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/jaeger, logging]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [logging]

Run the collector:

docker run -d --name otel-collector \
  -p 4317:4317 \
  -p 4318:4318 \
  -v $(pwd)/otel-collector-config.yaml:/etc/otelcol/config.yaml \
  otel/opentelemetry-collector:latest

Using a collector in the middle gives you flexibility: you can swap backends without touching your application config. Point the collector at Jaeger today, and add a second exporter to CubeAPM or any other OTLP-compatible backend later by editing only the collector config.

Step 6: Configure for GraalVM Native Image

Native image builds in Micronaut are one of its headline features, but they create a specific OpenTelemetry challenge. The OTel Autoconfigure SDK uses reflection and service loader mechanisms that need to be declared explicitly for GraalVM’s ahead-of-time compilation.

The Micronaut Gradle plugin handles most of this automatically when you use the micronaut-tracing-opentelemetry module. However, if you see errors like ClassNotFoundException or missing exporters at native image startup, add the following to your GraalVM reflect config:

[
  {
    "name": "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter",
    "allPublicConstructors": true,
    "allPublicMethods": true
  },
  {
    "name": "io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk",
    "allPublicConstructors": true,
    "allPublicMethods": true
  }
]

Place this file at src/main/resources/META-INF/native-image/reflect-config.json.

For Hibernate Reactive users on Micronaut 4: there is a known issue where OTLP tracing for DB operations does not propagate correctly when using SessionFactory.openSession(). The Micronaut Data maintainers confirmed that Micronaut Data uses its own session propagation, meaning OTel Java agent instrumentation for Hibernate Reactive does not connect traces to parent spans automatically. This was partially resolved in the OTel Java instrumentation project — update to opentelemetry-java-instrumentation 1.30.0 or later if you are using the Java agent alongside native Micronaut tracing.

Teams using OpenTelemetry across their Micronaut microservices often find that understanding what infrastructure monitoring covers helps them decide which signals to prioritize alongside traces and metrics.

Step 7: Send Telemetry to a Production Backend

Once your local setup is working, connect to a production-grade backend. CubeAPM accepts traces, metrics, and logs over OTLP natively, runs inside your own VPC or on-premises infrastructure, and charges $0.15/GB ingested with no per-seat fees. For a Micronaut service shipping 500GB of telemetry per month, that is $75/month regardless of how many engineers access the dashboards.

To point your Micronaut service at CubeAPM, update the OTLP endpoint:

otel:
  exporter:
    otlp:
      endpoint: https://your-cubeapm-instance:4317
      headers:
        Authorization: "Bearer YOUR_API_KEY"
  resource:
    attributes: service.name=my-micronaut-service,deployment.environment=production

CubeAPM correlates traces with logs and infrastructure metrics automatically. If a Micronaut service starts showing high p99 latency, you can jump from the trace view directly into the logs for that specific request and then into the host metrics for the pod it ran on — without switching tools or rebuilding context.

Other OTLP-compatible backends work the same way. Jaeger, Grafana Tempo, Honeycomb, and SigNoz all accept the same OTLP endpoint configuration. The only thing that changes is the endpoint URL and any authentication headers. This is the practical value of building on OpenTelemetry: your instrumentation code stays the same regardless of which backend you use.

Pricing based on publicly available information as of June 2026. Verify current rates at [CubeAPM pricing](https://cubeapm.com/pricing/).

Step 8: Verify and Test the Setup

Before calling the setup complete, verify that all three signal types are actually reaching your backend.

Start your Micronaut application and send a test request:

curl http://localhost:8080/your-endpoint

Check that traces appear in your backend UI within 10 seconds. If they do not, check the following:

# Check Micronaut startup logs for OTel initialization
grep -i "opentelemetry\|otel\|tracing" application.log

# Verify the OTLP endpoint is reachable from your service
curl -v http://localhost:4317

# Enable OTel SDK debug logging to see export attempts
export OTEL_LOG_LEVEL=debug

Add this to application.yml to enable verbose OTel logging during debugging:

logger:
  levels:
    io.opentelemetry: DEBUG
    io.micronaut.tracing: DEBUG

To verify metrics are exporting, check the Micrometer OTLP exporter logs for lines like Exporting N metrics every 30 seconds (or whatever step interval you configured). If you see Connection refused in the logs, the port mismatch between gRPC (4317) and HTTP (4318) is the most likely cause.

Troubleshooting Common Issues

Traces not appearing in the backend

The most common cause is a mismatch between the configured endpoint and what the backend is actually listening on. Verify the protocol matches: gRPC endpoints use port 4317, HTTP/protobuf endpoints use 4318. Setting otel.exporter.otlp.protocol=grpc and pointing at port 4318 will silently fail.

Also confirm that tracing.opentelemetry.enabled: true is set. Without this, the Micronaut tracing module initializes but does not activate.

Metrics exporting but traces not appearing

Micrometer and the OTel tracing SDK use separate exporters. Check that io.micronaut.tracing:micronaut-tracing-opentelemetry-http is on the classpath — the HTTP module is required for tracing HTTP server and client requests automatically. Metrics can export without it, but traces from HTTP handlers will be missing.

Health check spans flooding the trace backend

Add exclusion patterns in application.yml:

tracing:
  opentelemetry:
    exclusions:
      - /health
      - /ready
      - /metrics
      - /env/.*

This is especially important in Kubernetes, where liveness and readiness probes hit /health every few seconds.

Custom spans not showing as children of the HTTP handler span

This happens when the active span context is not propagated correctly into a new thread. If you are using @Async methods or virtual threads, the OTel context does not automatically cross thread boundaries. Wrap async calls using Context.current().wrap(runnable):

import io.opentelemetry.context.Context;

executor.submit(Context.current().wrap(() -> {
    // This runnable now carries the parent span context
    doSomethingAsync();
}));

Native image build fails with ClassNotFoundException on OTel classes

The GraalVM native image compiler does not see OTel service loader registrations by default. Add the reflect config from Step 6, and ensure you are running ./gradlew nativeCompile with the Micronaut Gradle plugin version 4.x or later, which includes OTel-related GraalVM hints.

Hibernate Reactive traces missing or not linked to parent

Update to OpenTelemetry Java instrumentation 1.30.0 or later. This version added instrumentation for SessionFactory.withSession(), which is the path Hibernate Reactive uses internally. Earlier versions only instrumented openSession(), which Micronaut Data does not call. See the resolved GitHub discussion for full context.

Teams expanding beyond tracing to cover the full request lifecycle often explore real user monitoring to connect backend trace data with frontend user experience signals.

Micronaut’s OpenTelemetry setup gives you a portable, vendor-neutral telemetry foundation that works across Jaeger, Grafana Tempo, CubeAPM, and any other OTLP-compatible backend. Once traces, metrics, and logs are flowing, the next priority is correlating them — linking a trace ID in a log line to the full span tree in your APM backend. That correlation is where monitoring shifts from reactive debugging to proactive detection, and it is what separates an instrumented service from an observable one.

Disclaimer: The information in this article reflects the latest details available at the time of publication and may change as technologies and products evolve. Features, pricing, and plan limits can change over time. Always verify the latest information directly with the vendor before making purchasing or deployment decisions.

Frequently Asked Questions

Is Micronaut still used?

Micronaut is actively maintained and widely used for microservices that need low memory footprint and fast startup, particularly in GraalVM native image environments. Its compile-time dependency injection makes it a strong alternative to Spring Boot in latency-sensitive or resource-constrained deployments. The project sees regular releases and has a growing ecosystem of integrations.

Why would you use OpenTelemetry?

OpenTelemetry lets you instrument your application once and send telemetry to any compatible backend. Without it, switching monitoring vendors requires re-instrumenting every service. With OTel, you change the exporter endpoint and nothing else. It also provides a standard data model for traces, metrics, and logs, which makes correlation across signal types more reliable.

What are the key differences between Micrometer and OpenTelemetry?

Micrometer is a metrics facade designed for JVM applications, similar to how SLF4J works for logging. It abstracts metric collection and lets you plug in different registry backends. OpenTelemetry is a full observability framework covering traces, metrics, and logs with a vendor-neutral wire protocol. In Micronaut, Micrometer handles metrics collection while the micronaut-tracing module handles traces via the OTel SDK. They can coexist and both export via OTLP.

What is Micronaut vs Spring Boot?

Both are Java application frameworks, but they take different approaches to dependency injection. Spring Boot resolves dependencies at runtime using reflection. Micronaut resolves them at compile time using annotation processors, which eliminates reflection overhead and reduces startup time from seconds to milliseconds. This compile-time approach is what enables Micronaut to produce GraalVM native images with lower memory usage than equivalent Spring Boot applications.

Can I use the OpenTelemetry Java agent with Micronaut instead of the micronaut-tracing module?

Yes, but with limitations. The Java agent instruments Micronaut HTTP handlers automatically, but it does not have zero-code support for all Micronaut-specific constructs. As discussed in the OpenTelemetry Java instrumentation community, there is no micronaut-starter equivalent to the Spring Boot auto-configuration. For native image builds, the Java agent does not work at all because it relies on runtime bytecode manipulation, which native image compilation does not support.

How do I exclude health check endpoints from tracing in Micronaut?

Add exclusion patterns under the tracing configuration in application.yml using the exclusions list. Patterns support regular expressions, so you can exclude entire path prefixes like health, metrics, and env endpoints with a single pattern. This prevents probe traffic from Kubernetes or load balancers from generating thousands of low-value spans per hour.

Does Micronaut OpenTelemetry work with GraalVM native images?

Yes, with some additional configuration. The micronaut-tracing-opentelemetry module includes GraalVM hints for the most common code paths. However, the OTel Autoconfigure SDK uses service loader mechanisms that may require explicit reflect config entries for custom exporters or propagators. The Java agent approach does not work with native images at all, making the micronaut-tracing module the only viable option for native builds.

×
×