CubeAPM
CubeAPM CubeAPM

Observability for Java Applications: A Complete Guide

Observability for Java Applications: A Complete Guide

Table of Contents

Java applications generate observability problems that other languages rarely encounter at the same scale. A Spring Boot service running on the JVM can look perfectly healthy on CPU and memory dashboards while its garbage collector pauses all threads every few seconds, adding invisible latency to every request that lands during a GC cycle. A Hibernate ORM layer can execute 47 database queries for a single API call because of an N+1 relationship that the developer never noticed during testing. A connection pool can silently queue all incoming requests once it exhausts its configured limit, producing rising p99 latency with no error in any log file.

Full Java observability requires three signal types working together: structured logs correlated to traces via MDC (Mapped Diagnostic Context), JVM and application metrics covering heap, GC, connection pools, and request rates, and distributed traces showing the exact call path that caused a specific slow request. The OpenTelemetry Java agent provides zero-code instrumentation for all three signals. The current stable release is opentelemetry-java-instrumentation v2.28.1 (May 2026), targeting the OpenTelemetry Java SDK v1.63.0 (June 2026).

This guide covers structured logging with Logback, zero-code instrumentation with the OTel Java agent, manual spans for business logic, custom metrics, Spring Boot integration options, and sending all three signals to CubeAPM.

Key Takeaways

  • The OpenTelemetry Java agent (v2.28.1, May 2026) provides zero-code instrumentation for Spring, Hibernate, JDBC, Kafka, gRPC, Redis, and Logback/Log4j2 MDC injection via a single -javaagent JAR flag.
  • JAVA_TOOL_OPTIONS is the cleanest way to attach the agent in container images and application servers; it is picked up automatically by any JVM process.
  • The OTel Java agent automatically injects traceId, spanId, and traceFlags into MDC for both Logback and Log4j2 with no code changes required.
  • CVE-2026-33701 affects RMI instrumentation in versions before v2.28.1; upgrade to v2.26.1 or later (current latest is v2.28.1).
  • opentelemetry-exporter-zipkin is deprecated in v1.63.0 (June 2026) with a last planned release in August 2026; migrate to OTLP.
  • Spring Boot 4.0 (November 2025) ships a first-party spring-boot-starter-opentelemetry; a log export bug in 4.0.0 is fixed in 4.0.1+.
  • The community OTel Spring Boot starter (io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter) supports Spring Boot 2.6+ and 3.1+ and works with GraalVM Native Image where the agent does not.
  • logstash-logback-encoder 9.0 (October 2025) requires Logback 1.3.0+ and Jackson 3.0.0+.

Why Java Observability Has Specific Challenges

  • GC pauses are invisible to application-level monitoring: The JVM can stop all threads for a garbage collection pause of 50ms to several seconds. From the application’s perspective, time simply stops. Requests that arrive during the pause queue up and then all complete at once after the pause ends. The result is a latency spike with no error in the logs and no slow span in any individual trace. Monitoring GC pause time and GC frequency as metrics is the only way to detect this pattern.
  • N+1 queries are not visible without SQL-level tracing: Hibernate and Spring Data JPA generate SQL queries dynamically. A @OneToMany relationship without FetchType.EAGER or a join fetch causes Hibernate to issue one query per child entity rather than one query for all of them. A list endpoint returning 50 orders, each with 10 line items, executes 501 queries instead of 1. Standard logging shows the endpoint was slow. Only SQL-level trace spans show the 501 queries.
  • Thread pool exhaustion produces queuing, not errors: When all threads in a Tomcat, Jetty, or Undertow thread pool are busy, new requests queue at the connector level. The application logs nothing because no request has been rejected, just delayed. p99 latency climbs while p50 stays flat. Monitoring active threads, queued requests, and pool utilization as metrics is the only way to detect this before it becomes a timeout.

Step 1: Structured Logging with Logback and MDC Trace Injection

Logback is the default logging framework in Spring Boot and the most widely used in the Java ecosystem. Configure it to output structured JSON using the logstash-logback-encoder library (current stable release: 9.0, October 2025), which requires Logback 1.3.0+ and Jackson 3.0.0+.

Add the dependency to pom.xml:

<dependency>

  <groupId>net.logstash.logback</groupId>

  <artifactId>logstash-logback-encoder</artifactId>

  <version>9.0</version>

</dependency>

Configure logback-spring.xml (for Spring Boot) to output JSON:

<?xml version="1.0" encoding="UTF-8"?>

<configuration>

  <appender name="JSON_STDOUT" class="ch.qos.logback.core.ConsoleAppender">

    <encoder class="net.logstash.logback.encoder.LogstashEncoder">

      <fieldNames>

        <timestamp>timestamp</timestamp>

        <version>[ignore]</version>

        <levelValue>[ignore]</levelValue>

      </fieldNames>

    </encoder>

  </appender>

  <root level="INFO">

    <appender-ref ref="JSON_STDOUT"/>

  </root>

</configuration>

Use SLF4J with MDC to add contextual fields to log records:

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.slf4j.MDC;

public class OrderService {

  private static final Logger log = LoggerFactory.getLogger(OrderService.class);

  public Order processOrder(String orderId, String userId) {

    MDC.put("orderId", orderId);

    MDC.put("userId", userId);

    try {

      log.info("Processing order");

      // ... business logic

      log.info("Order processed successfully");

      return order;

    } finally {

      MDC.clear();

    }

  }

}

This produces JSON output with every field searchable:

{

  "@timestamp": "2026-06-09T10:22:31.412Z",

  "level": "INFO",

  "logger_name": "com.example.OrderService",

  "message": "Processing order",

  "orderId": "ord_123",

  "userId": "usr_456"

}

Injecting trace context into logs via MDC: When the OpenTelemetry Java agent is attached (Step 2 below), it automatically injects the active traceId, spanId, and traceFlags into MDC. These fields appear in every log record produced during that request, enabling direct correlation from a log event to the trace that produced it. No code changes are required. The agent handles it via the Logger MDC auto-instrumentation, which is enabled by default for both Logback and Log4j2.

Step 2: Zero-Code Instrumentation with the OTel Java Agent

The OpenTelemetry Java agent is a single JAR that attaches to the JVM at startup using the -javaagent flag. It instruments all supported libraries via bytecode manipulation at class load time, with no code changes required.

Download the latest agent JAR:

curl -L -O https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

The current stable release is v2.28.1 (May 2026).

Attach the agent at JVM startup:

java -javaagent:./opentelemetry-javaagent.jar \

  -Dotel.service.name=my-java-service \

  -Dotel.exporter.otlp.endpoint=http://your-cubeapm-instance:4317 \

  -Dotel.exporter.otlp.protocol=grpc \

  -Dotel.traces.exporter=otlp \

  -Dotel.metrics.exporter=otlp \

  -Dotel.logs.exporter=otlp \

  -jar myapp.jar

Or configure entirely via environment variables, which is the cleaner approach for containerized deployments:

export JAVA_TOOL_OPTIONS="-javaagent:/opt/otel/opentelemetry-javaagent.jar"

export OTEL_SERVICE_NAME=my-java-service

export OTEL_EXPORTER_OTLP_ENDPOINT=http://your-cubeapm-instance:4317

export OTEL_EXPORTER_OTLP_PROTOCOL=grpc

export OTEL_TRACES_EXPORTER=otlp

export OTEL_METRICS_EXPORTER=otlp

export OTEL_LOGS_EXPORTER=otlp

JAVA_TOOL_OPTIONS is picked up by any JVM process automatically, making it the cleanest approach for container images and application servers.

What the agent instruments automatically:

Library / FrameworkWhat is instrumented
Spring MVC / Spring BootHTTP request spans with route pattern, method, status code
Spring WebFluxReactive HTTP request spans
Jakarta EE / JAX-RSHTTP request spans for JAX-RS resources
Servlet APIInbound HTTP request spans for any servlet container
JDBCSQL query spans with sanitized query text, DB type, and duration
Hibernate ORMORM operation spans on top of JDBC
Kafka (producer/consumer)Producer send spans, consumer process spans with topic and partition
gRPCClient and server call spans
Spring DataRepository method spans
Redis (Jedis, Lettuce)Redis command spans
Apache HttpClient / OkHttpOutbound HTTP client call spans
Logback / Log4j2MDC injection of traceId and spanId (no code changes needed)
Spring Scheduling / QuartzScheduled task execution spans

Security note: CVE-2026-33701 was disclosed in May 2026, affecting the RMI instrumentation in opentelemetry-java-instrumentation prior to v2.26.1. The vulnerability allows unsafe deserialization that could lead to remote code execution. It is fixed in v2.26.1 and later. Ensure you are using v2.26.1 or above. The current latest is v2.28.1.

Note on Zipkin exporter: opentelemetry-exporter-zipkin is deprecated as of opentelemetry-java v1.63.0 (June 2026), with its last planned release in August 2026. Migrate to OTLP before the final release.

Note on Java 25: Java 25 was released as LTS in September 2025. The OTel Java agent has experimental Java 25 support, but agent-transformed bytecode verification changes introduced in Java 25 can cause compatibility issues with some instrumentations. For production deployments, Java 21 LTS is the stable recommended target until full Java 25 support is confirmed.

Step 3: Manual Instrumentation for Custom Spans

The Java agent covers framework and library calls. Add manual spans for business-critical operations that sit between framework boundaries.

Add the OTel API dependency to pom.xml. The API is stable and safe to use as a direct application dependency:

<dependency>

  <groupId>io.opentelemetry</groupId>

  <artifactId>opentelemetry-api</artifactId>

  <version>1.63.0</version>

</dependency>

Get a tracer and create a child span:

import io.opentelemetry.api.GlobalOpenTelemetry;

import io.opentelemetry.api.trace.Span;

import io.opentelemetry.api.trace.StatusCode;

import io.opentelemetry.api.trace.Tracer;

import io.opentelemetry.context.Scope;

public class PricingService {

  private static final Tracer tracer =

      GlobalOpenTelemetry.getTracer("com.example.PricingService", "1.0.0");

  public PricingResult calculatePricing(String orderId, List<LineItem> items) {

    Span span = tracer.spanBuilder("calculate_pricing")

        .setAttribute("order.id", orderId)

        .setAttribute("order.item_count", items.size())

        .startSpan();

    try (Scope scope = span.makeCurrent()) {

      PricingResult result = pricingEngine.compute(items);

      span.setAttribute("pricing.total", result.getTotal());

      span.setAttribute("pricing.discount_applied", result.isDiscountApplied());

      return result;

    } catch (Exception e) {

      span.recordException(e);

      span.setStatus(StatusCode.ERROR, e.getMessage());

      throw e;

    } finally {

      span.end();

    }

  }

}

For Spring applications, the @WithSpan annotation from opentelemetry-instrumentation-annotations creates a span automatically around a method without try-with-resources boilerplate. Note: @WithSpan requires spring-boot-starter-aop when used with the Spring Boot OTel starter:

<dependency>

  <groupId>io.opentelemetry.instrumentation</groupId>

  <artifactId>opentelemetry-instrumentation-annotations</artifactId>

  <version>2.28.1</version>

</dependency>
import io.opentelemetry.instrumentation.annotations.WithSpan;

import io.opentelemetry.instrumentation.annotations.SpanAttribute;

@Service

public class RecommendationService {

  @WithSpan("generate_recommendations")

  public List<Product> getRecommendations(

      @SpanAttribute("user.id") String userId,

      @SpanAttribute("category") String category) {

    // Method body automatically wrapped in a span

    return recommendationEngine.compute(userId, category);

  }

}

Step 4: Spring Boot Integration Options

There are three ways to integrate OpenTelemetry with Spring Boot, depending on your Spring Boot version and requirements.

Option A: OTel Java Agent (recommended default for all Spring Boot versions)

Attach the agent as described in Step 2. This is the OTel project’s own recommended default for Spring Boot applications and provides the broadest auto-instrumentation coverage.

Option B: OpenTelemetry Spring Boot Starter (Spring Boot 2.6+ and 3.1+)

The community OTel starter from io.opentelemetry.instrumentation works with Spring Boot 2.6+ and Spring Boot 3.1+, and supports GraalVM Native Image (where the Java agent does not work). Use this when the agent’s startup overhead is a concern, or when deploying as a native image.

<dependency>

  <groupId>io.opentelemetry.instrumentation</groupId>

  <artifactId>opentelemetry-spring-boot-starter</artifactId>

  <version>2.28.1</version>

</dependency>

Configure in application.properties:

otel.exporter.otlp.endpoint=http://your-cubeapm-instance:4318

otel.traces.exporter=otlp

otel.metrics.exporter=otlp

otel.logs.exporter=otlp

otel.service.name=my-spring-service

management.tracing.sampling.probability=1.0

Note: the starter is marked as stable but pulls in some alpha-suffixed dependencies.

Option C: Spring Boot 4 native OTel starter (Spring Boot 4.0+ only)

Spring Boot 4.0 (released November 20, 2025, current stable: 4.0.5) ships a first-party starter spring-boot-starter-opentelemetry that uses Micrometer internally and exports traces, metrics, and logs via OTLP. Configuration follows the management.* prefix:

<dependency>

  <groupId>org.springframework.boot</groupId>

  <artifactId>spring-boot-starter-opentelemetry</artifactId>

</dependency>

Configure in application.properties:

management.opentelemetry.resource-attributes.service.name=my-spring-service

management.opentelemetry.tracing.export.otlp.endpoint=http://your-cubeapm-instance:4318

management.tracing.sampling.probability=1.0

Note on Spring Boot 4.0.0 and logs: A bug in Spring Boot 4.0.0 prevents log export unless spring-boot-starter-actuator is also on the classpath. Upgrade to Spring Boot 4.0.1 or higher to avoid this, or add spring-boot-starter-actuator as a workaround.

Key JVM metrics exposed automatically via Micrometer (all three options):

MetricDescription
jvm.memory.usedHeap and non-heap memory used in bytes
jvm.gc.pauseGC pause duration histogram
jvm.gc.memory.promotedBytes promoted from young to old generation per GC
jvm.threads.liveCurrent number of live threads
jvm.classes.loadedNumber of classes currently loaded
http.server.requestsHTTP request rate, duration, and status code histogram
hikaricp.connections.activeActive HikariCP connection pool connections
hikaricp.connections.pendingRequests waiting for a connection from the pool

Step 5: Custom Metrics with the OTel Metrics API

For business-level metrics not provided by auto-instrumentation, use the OTel Metrics API:

import io.opentelemetry.api.GlobalOpenTelemetry;

import io.opentelemetry.api.metrics.LongCounter;

import io.opentelemetry.api.metrics.LongHistogram;

import io.opentelemetry.api.metrics.Meter;

import io.opentelemetry.api.common.Attributes;

public class OrderMetrics {

  private static final Meter meter =

      GlobalOpenTelemetry.getMeter("com.example.orders", "1.0.0");

  private static final LongCounter ordersProcessed = meter

      .counterBuilder("orders.processed")

      .setDescription("Total number of orders processed")

      .setUnit("1")

      .build();

  private static final LongHistogram orderProcessingDuration = meter

      .histogramBuilder("orders.processing.duration")

      .ofLongs()

      .setDescription("Time to process an order in milliseconds")

      .setUnit("ms")

      .build();

  public static void recordOrderProcessed(String orderType, String region) {

    ordersProcessed.add(1,

        Attributes.builder()

            .put("order.type", orderType)

            .put("region", region)

            .build());

  }

  public static void recordProcessingDuration(long durationMs, String orderType) {

    orderProcessingDuration.record(durationMs,

        Attributes.builder()

            .put("order.type", orderType)

            .build());

  }

}

Step 6: Monitor Java Application Health with CubeAPM

cubeapm

CubeAPM receives all three signal types from Java applications via OTLP: traces, metrics, and logs. Setting OTEL_EXPORTER_OTLP_ENDPOINT to your CubeAPM instance and attaching the Java agent is all the configuration required.

What CubeAPM monitors for Java applications:

  • HTTP request rate, error rate, and p99 latency per endpoint (Spring MVC, JAX-RS, Servlet)
  • SQL query spans with sanitized query text, duration, and error status (JDBC, Hibernate)
  • JVM heap usage, GC pause time, and GC frequency
  • Thread pool utilization (active threads, queued requests) from HikariCP and Tomcat metrics
  • Outbound HTTP client spans (Apache HttpClient, OkHttp)
  • Kafka producer and consumer spans with topic, partition, and consumer group
  • Custom business metrics and spans
  • Structured logs (Logback JSON) correlated to traces via MDC-injected traceId and spanId
  • Distributed traces across Java microservices with end-to-end flame graphs

Key alerts to configure for Java applications in CubeAPM:

AlertConditionSeverity
High error rateHTTP error rate > 1% for 5 minWarning
High p99 latencyp99 request duration > 2,000 msWarning
GC pressureGC pause time rate > 200ms/sWarning
High heap usagejvm.memory.used > 80% of max heapWarning
Connection pool exhaustionhikaricp.connections.pending > 0 for 5 minWarning
Slow SQL queriesAny JDBC span > 500 msWarning
N+1 query patternJDBC span count per trace > 20Warning
Thread pool saturationActive threads approaching pool maximumWarning

Read the docs to configure OTLP ingestion and Java application monitoring.

Summary

Java observability requires monitoring JVM internals alongside application behavior. GC pauses, connection pool exhaustion, and N+1 query patterns are all invisible to standard server monitoring. The OpenTelemetry Java agent provides zero-code instrumentation for all of these in a single JAR attached at startup.

SignalCollection methodKey data
Structured logsLogback + logstash-logback-encoder 9.0 + MDC traceId/spanId injectionJSON log lines with trace context, auto-injected by OTel agent
Distributed tracesOTel Java agent v2.28.1 + @WithSpan for custom spansHTTP, JDBC, Hibernate, Kafka, gRPC, Redis spans
JVM metricsMicrometer (all Spring Boot options) or OTel agent JVM metricsHeap, GC pause, threads, class loading, HikariCP pool
Custom metricsOTel Metrics API (counterBuilder, histogramBuilder)Business counters, duration histograms
Zero-code setup-javaagent:opentelemetry-javaagent.jar via JAVA_TOOL_OPTIONSAll frameworks and libraries, no code changes

Disclaimer: All OTel Java package versions and API calls sourced from official OTel Java documentation at opentelemetry.io/docs/languages/java/ and GitHub releases, verified June 2026. Current stable: opentelemetry-java SDK v1.63.0 (June 6, 2026); opentelemetry-java-instrumentation v2.28.1 (May 21, 2026). CVE-2026-33701 affects RMI instrumentation before v2.26.1; upgrade to v2.26.1 or later. opentelemetry-exporter-zipkin deprecated in v1.63.0, last planned release August 2026 (source: github.com/open-telemetry/opentelemetry-java/releases). Default OTLP endpoint for the Java agent is http://localhost:4317 (gRPC). logstash-logback-encoder 9.0 (October 2025) requires Logback 1.3.0+ and Jackson 3.0.0+ (source: github.com/logfellow/logstash-logback-encoder). Community OTel Spring Boot starter (io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter) supports Spring Boot 2.6+ and 3.1+ (source: opentelemetry.io/docs/zero-code/java/spring-boot-starter/). Spring Boot 4.0 native starter (spring-boot-starter-opentelemetry) introduced in Spring Boot 4.0 (November 20, 2025); log export bug present in 4.0.0, fixed in 4.0.1+. Java 25 (LTS, September 2025): OTel Java agent support is experimental; Java 21 LTS recommended for production. CubeAPM: $0.15/GB, no per-service or per-host fees.

Also read:

Observability for Node.js Applications: Logs, Metrics, and Traces

Observability for Python Applications: Logs, Metrics, and Traces

Observability for Serverless Applications on AWS Lambda: What to Track and How

×
×