How to Integrate Elastic APM Java Agent with Spring Boot

The Spring Way

Mohamed Al Sayadi
Level Up Coding

--

Introduction

Application Performance Monitoring or APM provides insights into how well your application is doing in terms of speed and errors. Within the Spring context, this centers around transactions (being API calls or scheduled events) and errors or exceptions.

While not the only APM solution available, Elastic APM offers a rather convenient way to integrate with some of the most popular frameworks and technologies, such as Spring, React, and Angular. On the other hand, the APM Server works well with the rest of the Elastic Stack; it feeds directly to Elasticsearch and Kibana’s dashboard shows good out-of-the-box visualizations under Observability -> APM .

Let’s look at one way of instrumenting your Spring application with the Elastic APM Java Agent.

Prerequisites

In this piece, I’m focusing on the integration part, not the infrastructure. So, I’m assuming you already have an installation of Elasticsearch, Kibana, and the APM Server, or are comfortable running the provided docker-compose file.

More specifically, from the APM server, you will need its URL, and a secret token to authorize connections to it.

Different Ways to Integrate

Historically, you’d run the Agent separately as its own jar. You could also pass the APM jar as a javaagent argument when running your main app. Within the container context, you could run the agent as a sidecar container alongside your main container.

With that said, I find adding the Agent as a dependency and configuring it through Spring’s configuration a better and more “Springy” way of instrumentation. It also requires no changes to your Dockerfile or deployment configurations and scripts. This self-attach method is still in beta, but haven’t encountered any issues with it so far. More details on Elastic’s docs.

The Spring Way

So, how do you add the Elastic APM Java Agent the Spring way? Simply, by performing these three steps:

1- Add the dependency:

<!--Elastic APM -->
<dependency>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-agent-attach</artifactId>
<version>1.20.0</version>
</dependency>

2- Add the @Configuration class to “attach” the agent when the Spring application starts:

package io.sayadi.elasticapmspringbootintegration;

import co.elastic.apm.attach.ElasticApmAttacher;
import lombok.Setter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Setter
@Configuration
@ConfigurationProperties(prefix = "elastic.apm")
@ConditionalOnProperty(value = "elastic.apm.enabled", havingValue = "true")
public class ElasticApmConfig {

private static final String SERVER_URL_KEY = "server_url";
private String serverUrl;

private static final String SERVICE_NAME_KEY = "service_name";
private String serviceName;

private static final String SECRET_TOKEN_KEY = "secret_token";
private String secretToken;

private static final String ENVIRONMENT_KEY = "environment";
private String environment;

private static final String APPLICATION_PACKAGES_KEY = "application_packages";
private String applicationPackages;

private static final String LOG_LEVEL_KEY = "log_level";
private String logLevel;

@PostConstruct
public void init() {

Map<String, String> apmProps = new HashMap<>(6);
apmProps.put(SERVER_URL_KEY, serverUrl);
apmProps.put(SERVICE_NAME_KEY, serviceName);
apmProps.put(SECRET_TOKEN_KEY, secretToken);
apmProps.put(ENVIRONMENT_KEY, environment);
apmProps.put(APPLICATION_PACKAGES_KEY, applicationPackages);
apmProps.put(LOG_LEVEL_KEY, logLevel);

ElasticApmAttacher.attach(apmProps);
}
}

3- Add the values to the properties files of all the profiles that you want the Agent to run on:

# Elastic APM
elastic.apm.enabled=true
elastic.apm.server-url=http://localhost:8200
elastic.apm.service-name=elastic-apm-spring-boot-integration
elastic.apm.secret-token=xxVpmQB2HMzCL9PgBHVrnxjNXXw5J7bd79DFm6sjBJR5HPXDhcF8MSb3vv4bpg44
elastic.apm.environment=dev
elastic.apm.application-packages=io.sayadi.elasticapmspringbootintegration
elastic.apm.log-level=DEBUG

Step-by-step Walkthrough

Follow the steps below to create the codebase which will be run in the following section. Alternatively, you can clone the GitHub repository associated with this article.

1- Start with a fresh Spring Boot application from the Spring Initializer, with at least the Spring Web dependency.

2- Add the Elastic APM dependency to pom.xml:

<!--Elastic APM-->
<dependency>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-agent-attach</artifactId>
<version>1.20.0</version>
</dependency>

3- Add the @Configuration class:

package io.sayadi.elasticapmspringbootintegration;

import co.elastic.apm.attach.ElasticApmAttacher;
import lombok.Setter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Setter
@Configuration
@ConfigurationProperties(prefix = "elastic.apm")
@ConditionalOnProperty(value = "elastic.apm.enabled", havingValue = "true")
public class ElasticApmConfig {

private static final String SERVER_URL_KEY = "server_url";
private String serverUrl;

private static final String SERVICE_NAME_KEY = "service_name";
private String serviceName;

private static final String SECRET_TOKEN_KEY = "secret_token";
private String secretToken;

private static final String ENVIRONMENT_KEY = "environment";
private String environment;

private static final String APPLICATION_PACKAGES_KEY = "application_packages";
private String applicationPackages;

private static final String LOG_LEVEL_KEY = "log_level";
private String logLevel;

@PostConstruct
public void init() {

Map<String, String> apmProps = new HashMap<>(6);
apmProps.put(SERVER_URL_KEY, serverUrl);
apmProps.put(SERVICE_NAME_KEY, serviceName);
apmProps.put(SECRET_TOKEN_KEY, secretToken);
apmProps.put(ENVIRONMENT_KEY, environment);
apmProps.put(APPLICATION_PACKAGES_KEY, applicationPackages);
apmProps.put(LOG_LEVEL_KEY, logLevel);

ElasticApmAttacher.attach(apmProps);
}
}

4- Add the TestController to expose some endpoints with various performance simulations:

package io.sayadi.elasticapmspringbootintegration;

import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

@GetMapping("/super-fast")
public String getSuperFastApi() {

return "I'm super fast.";
}

@GetMapping("/fast")
public String getFastApi() throws InterruptedException {

Thread.sleep(20); // sleep for 20 milliseconds
return "I'm fast!";
}

@GetMapping("/slow")
public String getSlowApi() throws InterruptedException {

Thread.sleep(3000); // sleep for 3 seconds
return "I'm slow :(";
}

@GetMapping("/super-slow")
@Profile({"predev", "dev", "staging"})
public String getSuperSlowApi() throws InterruptedException {

Thread.sleep(60000); // sleep for 1 minute!
return "I'm super slow. Refactor me before moving to production!! :)";
}
}

5- Create the application-predev.propertiesfile:

server.port=8081

# Elastic APM
elastic.apm.enabled=false

6- Create the application-dev.properties file:

server.port=8082

# Elastic APM
elastic.apm.enabled=true
elastic.apm.server-url=http://localhost:8200
elastic.apm.service-name=elastic-apm-spring-boot-integration
elastic.apm.secret-token=xxVpmQB2HMzCL9PgBHVrnxjNXXw5J7bd79DFm6sjBJR5HPXDhcF8MSb3vv4bpg44
elastic.apm.environment=dev
elastic.apm.application-packages=io.sayadi.elasticapmspringbootintegration
elastic.apm.log-level=DEBUG

7- Create the application-staging.properties file:

server.port=8082

# Elastic APM
elastic.apm.enabled=true
elastic.apm.server-url=http://localhost:8200
elastic.apm.service-name=elastic-apm-spring-boot-integration
elastic.apm.secret-token=xxVpmQB2HMzCL9PgBHVrnxjNXXw5J7bd79DFm6sjBJR5HPXDhcF8MSb3vv4bpg44
elastic.apm.environment=staging
elastic.apm.application-packages=io.sayadi.elasticapmspringbootintegration
elastic.apm.log-level=INFO

8- Create the application-prod.properties file:

server.port=8083

# Elastic APM
elastic.apm.enabled=true
elastic.apm.server-url=http://localhost:8200
elastic.apm.service-name=elastic-apm-spring-boot-integration
elastic.apm.secret-token=xxVpmQB2HMzCL9PgBHVrnxjNXXw5J7bd79DFm6sjBJR5HPXDhcF8MSb3vv4bpg44
elastic.apm.environment=prod
elastic.apm.application-packages=io.sayadi.elasticapmspringbootintegration
elastic.apm.log-level=ERROR

9- Create the docker-compose.yaml file:

version: '2.2'

services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.7.1
environment:
- "cluster.routing.allocation.disk.threshold_enabled=false"
- "discovery.type=single-node"
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- 9200:9200
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9200']
interval: 10s
timeout: 5s
retries: 3

kibana:
image: docker.elastic.co/kibana/kibana:7.7.1
environment:
- "SERVER_HOST=0.0.0.0"
ports:
- 5601:5601
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:5601']
interval: 10s
timeout: 5s
retries: 3
depends_on:
elasticsearch:
condition: service_healthy

apm-server:
image: docker.elastic.co/apm/apm-server:7.7.1
ports:
- 8200:8200
environment:
- output.elasticsearch.hosts=['http://elasticsearch:9200']
- apm-server.host="0.0.0.0:8200"
- apm-server.secret_token="xxVpmQB2HMzCL9PgBHVrnxjNXXw5J7bd79DFm6sjBJR5HPXDhcF8MSb3vv4bpg44"
- setup.kibana.host="kibana:5601"
- setup.template.enabled=true
- logging.to_files=false
depends_on:
elasticsearch:
condition: service_healthy
kibana:
condition: service_healthy

10- Create the test-apis.sh script:

#!/bin/bash

api_base_url_predev=http://localhost:8081
api_base_url_dev=http://localhost:8082
api_base_url_staging=http://localhost:8083
api_base_url_prod=http://localhost:8084


for i in {0..10}
do
printf "Iteration # %s...\n" "${i}"
curl "${api_base_url_predev}/super-fast"
curl "${api_base_url_dev}/super-fast"
curl "${api_base_url_staging}/super-fast"
curl "${api_base_url_prod}/super-fast"
printf "\n"
curl "${api_base_url_predev}/fast"
curl "${api_base_url_dev}/fast"
curl "${api_base_url_staging}/fast"
curl "${api_base_url_prod}/fast"
printf "\n"
curl "${api_base_url_predev}/slow"
curl "${api_base_url_dev}/slow"
curl "${api_base_url_staging}/slow"
curl "${api_base_url_prod}/slow"
printf "\n"
curl "${api_base_url_predev}/super-slow" &
curl "${api_base_url_dev}/super-slow" &
curl "${api_base_url_staging}/super-slow" &
# The super-slow api is not on prod
printf "\n\n"
done

Running the Demo

1- Start the predev environment [terminal 1]:

mvn spring-boot:run -Dspring-boot.run.profiles=predev

Since the predev environment has the elastic.apm.enabled property set to false and the ElasticApmConfig is annotated with @ConditionalOnProperty(value = “elastic.apm.enabled”, havingValue = “true”) , this @Config class is not injected into the Spring’s context, and the Elastic APM agent would not get attached. Notice that we didn’t even run the Elastic APM server yet.

2- Start the Elasticsearch, Kibana, and Elastic APM servers [terminal 2]:

docker compose up

Once all the images are downloaded, and containers have started, navigate in a browser to http://localhost:5601 to access Kibana.

3- Start the dev environment [terminal 3]:

mvn spring-boot:run -Dspring-boot.run.profiles=dev

Notice the verbosity of the logs on this environment since we’ve set the Elastic’s agent log level to DEBUG .

4- Start the staging environment [terminal 4]:

mvn spring-boot:run -Dspring-boot.run.profiles=staging

In this environment, the log level is set to INFO and it’s noticeably less verbose.

5- Start the prod environment [terminal 5]:

mvn spring-boot:run -Dspring-boot.run.profiles=prod

By this stage, if you head to the APM dashboard on Kibana, you should find a service called elastic-apm-spring-boot-integration with three environments, dev, staging, and prod as shown in the figure below.

APM Dashboard Figure 1

6- Generate some traffic on the different environments using the test-apis.sh script. The script will call each endpoint defined in the TestController (/super-fast, /fast, /slow, and /super-slow ) ten times in each environment. Notice that the prod environment does not expose the /super-slow endpoint. This is meant to simulate a scenario where a new feature is being tested and is not yet deployed to production.

chmod +x test-apis.sh
./test-apis.sh

Wait for the script to log Iteration 10 , then wait for about another minutes until the super-slow APIs return.

Head again to the APM dashboard on Kibana and click on the elastic-apm-spring-boot-integration service to get more details:

APM Dashboard Figure 2

Filter back and forth between prod and staging :

APM Dashboard Figure 3
APM Dashboard Figure 4

The presence of an API on staging that takes on average 60,000 ms to return should draw attention to itself.

7- Clean up! Go back to these five terminals and stop all these processes. Otherwise, your computer will not be happy!

GitHub Repository

As mentioned above, all the code and configurations necessary to run this demo are in this GitHub repository.

Final Thoughts

Thanks for reading and do let me know in the comments your thoughts on this setup. In particular, I’m interested in scenarios where this beta attachment method might not work and any mitigation that can be used.

--

--