How to Integrate Elastic APM Java Agent with Spring Boot
The Spring Way
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.properties
file:
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.
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:
Filter back and forth between prod
and staging
:
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.