The Elastic APM Java agent doesn’t just observe your JVM application; it fundamentally changes how your application’s code is executed at runtime.

Let’s see this in action. Imagine a simple Spring Boot application.

// src/main/java/com/example/demo/DemoApplication.java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @GetMapping("/hello")
    public String hello() {
        return "Hello from Spring Boot!";
    }
}

To instrument this with Elastic APM, you’d typically download the agent JAR and attach it via the -javaagent JVM argument.

java -javaagent:elastic-apm-agent-1.x.x.jar \
     -Delastic.apm.service_name=my-spring-app \
     -Delastic.apm.server_urls=http://localhost:8200 \
     -jar target/demo-0.0.1-SNAPSHOT.jar

When you start the application with these arguments, the elastic-apm-agent-1.x.x.jar is loaded before your application’s main method. It uses Java’s Instrumentation API to dynamically weave itself into your application’s classes. For instance, it might modify the hello() method to include timing logic and calls to the APM agent’s internal APIs to record the start and end of the request, along with any exceptions. This weaving happens in memory as classes are loaded, meaning no recompilation or modification of your source code is necessary.

The core problem the APM agent solves is visibility into distributed systems. Before APM, understanding the performance of a request that spanned multiple microservices was a detective game. You’d look at logs from each service, try to correlate timestamps, and guess where the bottleneck was. The APM agent transforms this by creating a unified view of transactions. When a request enters your Spring app, the agent captures it as a "transaction." If this transaction then makes a call to another service (e.g., via an HTTP client), the agent injects tracing headers into that outgoing request. When the response comes back, the agent links it to the original transaction. This creates a "trace," which is a collection of all the spans (individual operations) that make up a single request’s journey through your system.

The key levers you control are primarily through system properties passed to the JVM. elastic.apm.service_name is crucial for identifying your application in the Elastic APM UI. elastic.apm.server_urls tells the agent where to send the collected data – usually your Elastic APP server or Elastic Cloud endpoint. Beyond these basics, you can fine-tune what gets captured. elastic.apm.transaction_sample_rate (e.g., 0.1 for 10%) allows you to sample transactions if your application is very high throughput, reducing overhead and data volume. elastic.apm.capture_body (errors or all) controls whether request and response bodies are sent to the server, which can be useful for debugging but increases data size. For Spring applications, specific auto-instrumentation for common frameworks like Spring MVC, Spring Data JPA, and RestTemplate is enabled by default. You can disable specific instrumentation modules if they interfere with your application or if you don’t need them using elastic.apm.disable_instrumentations.

What most people don’t realize is that the agent’s ability to trace requests across service boundaries relies on it automatically propagating trace context. When your instrumented Spring application makes an HTTP request to another service (even one not instrumented by Elastic APM), the agent injects special HTTP headers (like traceparent and tracestate according to W3C Trace Context standards). If the downstream service is also instrumented, it will recognize these headers and continue the trace. If it’s not instrumented, the trace effectively "stops" there, but you still see the duration of that outbound call as a span within your original transaction.

The next hurdle you’ll likely face is understanding how to configure custom transaction types and custom spans to better map your application’s internal logic to the observability data.

Want structured learning?

Take the full Elastic-apm course →