Observe a Java App with OpenTelemetry
Instrument a Spring Boot API with OpenTelemetry and visualize traces in Jaeger.
Introduction
30 Second Summary
When something breaks in a web app, finding the problem without visibility into each request is like debugging in the dark. Observability gives you a flashlight.
In this project, you will build a Java REST API and instrument it with OpenTelemetry to automatically capture traces of every request. You will then visualize those traces in Jaeger's web UI to see exactly what happened inside your app.
What You'll Build
A Spring Boot dice-rolling API that sends detailed trace data to a local Jaeger instance through the OpenTelemetry Java Agent.
By the end of this project, you'll have:
- A Spring Boot REST API with a /rolldice endpoint that returns random numbers.
- Zero-code instrumentation via the OpenTelemetry Java Agent, capturing traces without changing your application code.
- A local Jaeger instance running in Docker, receiving and displaying trace data through its built-in web UI.
- Secret Mission: Add manual instrumentation with custom spans and business-relevant attributes to go beyond auto-instrumentation.
Are there any prerequisites?
You should have basic Java knowledge. You will also need Java 21, IntelliJ IDEA Community Edition, Docker Desktop, and Gradle installed on your machine.
Before We Start
Before We Start...
Set Up Your Observability Stack
Before you can observe your application, you need the infrastructure that collects and displays trace data.
In this step, you will verify your local tools, spin up a Jaeger tracing backend in Docker, and download the OpenTelemetry Java Agent. Together, these form the observability pipeline that will capture every request flowing through your app.
In this step, get ready to:
- Verify Java 21 and Gradle are available from your terminal.
- Start a Jaeger tracing backend in Docker.
- Download the OpenTelemetry Java Agent JAR.
Verify your tools
Your project depends on Java 21 for the Spring Boot app and Gradle for building it. A quick version check confirms both are accessible from your terminal.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal (macOS) or PowerShell (Windows) and pressing Enter.
- Check your Java version by running:
java -version
You should see output that includes 21 in the version number.
- Check your Gradle version by running:
gradle --version
You should see a Gradle version number printed alongside the JVM info.
Not seeing a version number?
If java -version says command not found, Java is not on your system PATH. Reinstall Java 21 from the official Adoptium or Oracle JDK downloads page and ensure the installer adds it to your PATH.
If gradle --version fails, Gradle may not be installed or its bin directory is not on your PATH. Visit the Gradle installation guide at gradle.org/install for setup instructions.
Help me troubleshoot my Java or Gradle installation
Start Jaeger in Docker
Jaeger is your tracing backend. It receives trace data from your app and provides a web UI for exploring it. The all-in-one image bundles the collector, storage, and UI in a single container.
- Make sure Docker Desktop is running. If it is not open, press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter.
- Start the Jaeger container by running this command in your terminal:
docker run --rm --name jaeger -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/jaeger:2.19.0
What does this command do?
- docker run pulls the image (if not cached) and starts a container.
- --rm automatically removes the container when you stop it, keeping your system clean.
- --name jaeger gives the container a friendly name so you can reference it easily.
- -p 16686:16686 exposes the Jaeger web UI on port 16686.
- -p 4317:4317 exposes the OTLP gRPC endpoint where your app sends traces.
- -p 4318:4318 exposes the OTLP HTTP endpoint as an alternative.
- Wait a few seconds for Jaeger to start. You will see log output in the terminal as it initializes.
- Open your browser and go to http://localhost:16686.
You should see the Jaeger UI with a search form. There are no traces yet because no app is sending data.
Don't see the Jaeger UI?
If the page does not load, check that the docker run command finished without errors in your terminal. Look for messages about port conflicts.
If you see a port already in use error, another process is occupying port 16686 or 4317. Stop that process or adjust the port mapping.
Help me fix my Jaeger Docker container
Keep this terminal open
The Jaeger container runs in the foreground. If you close this terminal or press Ctrl+C, Jaeger stops. Open a new terminal tab or window for the remaining commands.
Download the OpenTelemetry Java Agent
The OpenTelemetry Java Agent is a single JAR file that attaches to your app at startup. It automatically intercepts HTTP requests, database calls, and other operations to generate traces without any code changes.
- Open a new terminal tab or window.
- Move to your Desktop by running this command:
cd ~/Desktop
- Download the OpenTelemetry Java Agent JAR by running the command for your operating system:
macOS
curl -L -o opentelemetry-javaagent.jar "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.28.1/opentelemetry-javaagent.jar"
Windows
Invoke-WebRequest -Uri "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.28.1/opentelemetry-javaagent.jar" -OutFile "opentelemetry-javaagent.jar"
What is the OpenTelemetry Java Agent?
This JAR uses bytecode manipulation to intercept calls inside popular frameworks like Spring MVC, JDBC, and HTTP clients. When you attach it to your app at startup, it generates traces automatically without you writing any tracing code. This is called zero-code instrumentation.
- Confirm the file downloaded by running:
ls opentelemetry-javaagent.jar
You should see opentelemetry-javaagent.jar listed. The file is approximately 23 MB.
Download failed or file is missing?
If curl or Invoke-WebRequest returned an error, check your internet connection and try the command again. GitHub releases occasionally rate-limit downloads.
If the file is present but much smaller than 23 MB, the download may have captured an error page instead of the JAR. Delete the file and re-run the download command.
Help me download the OpenTelemetry Java Agent
Your observability stack is ready. You have Jaeger waiting for traces and the OpenTelemetry agent ready to attach to your app. Next up, you will build the Spring Boot REST API that produces those traces.
Build the Spring Boot REST API
Your observability backend is ready and waiting for data. Now you need an application that actually produces traces.
In this step, you will build a simple Spring Boot REST API with a single endpoint that rolls a dice. This gives you a lightweight, working app to instrument with OpenTelemetry in the next step.
In this step, get ready to:
- Create the project directory and source files.
- Write a Spring Boot app with a dice-rolling endpoint.
- Build and test the app locally on port 8080.
Create the project directory and files
You need a dedicated folder to hold your application code and Gradle build files. The Jaeger container from the previous step is occupying your existing terminal, so you will open a new one.
- Open a new terminal window by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Navigate to your Desktop by running this command:
cd ~/Desktop
- Create the project directory and move into it by running these commands:
mkdir dice-app
cd dice-app
- Create the four project files by running the command for your operating system:
macOS
touch settings.gradle.kts build.gradle.kts DiceApplication.java RollController.java
Windows
New-Item -ItemType "File" -Path "settings.gradle.kts", "build.gradle.kts", "DiceApplication.java", "RollController.java"
- Confirm the files exist by running:
ls
You should see settings.gradle.kts, build.gradle.kts, DiceApplication.java, and RollController.java in the output.
Not seeing all four files?
Make sure you ran cd dice-app before creating the files. If you accidentally created them in the parent directory, delete them and re-run the commands from inside dice-app.
help me create the project files for my dice-app
Write the application code
Now you will fill each file with code. Open the dice-app folder in your code editor so you can edit the files you just created.
- Open settings.gradle.kts and paste this single line:
rootProject.name = "dice-app"
- Save settings.gradle.kts.
- Open build.gradle.kts and paste the following build configuration:
plugins {
java
id("org.springframework.boot") version "3.5.15"
id("io.spring.dependency-management") version "1.1.0"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
sourceSets {
main {
java.setSrcDirs(setOf("."))
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}
What does this build file do?
- The Spring Boot and dependency-management plugins handle packaging your app into a runnable fat JAR.
- sourceSets { main { java.setSrcDirs(setOf(".")) } } tells Gradle to look for Java files in the project root instead of the traditional src/main/java hierarchy. This keeps the tutorial simple.
- spring-boot-starter-web pulls in Spring MVC, an embedded Tomcat server, and everything needed for REST endpoints.
- Save build.gradle.kts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved build.gradle.kts before moving on.
ⓧ I'd like to double check the full code
plugins {
java
id("org.springframework.boot") version "3.5.15"
id("io.spring.dependency-management") version "1.1.0"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
sourceSets {
main {
java.setSrcDirs(setOf("."))
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}
- Open DiceApplication.java and paste the following code:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.Banner;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DiceApplication.class);
app.setBannerMode(Banner.Mode.OFF);
app.run(args);
}
}
What does this class do?
- @SpringBootApplication marks this class as the entry point and triggers Spring's auto-configuration.
- Banner.Mode.OFF suppresses the large ASCII art banner that Spring Boot normally prints at startup. This keeps your console output clean so you can spot OpenTelemetry logs later.
- Save DiceApplication.java.
✔️ Awesome, I've got everything!
Great. Make sure you have saved DiceApplication.java before moving on.
ⓧ I'd like to double check the full code
import org.springframework.boot.SpringApplication;
import org.springframework.boot.Banner;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DiceApplication.class);
app.setBannerMode(Banner.Mode.OFF);
app.run(args);
}
}
- Open RollController.java and paste the following code:
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RollController {
private static final Logger logger = LoggerFactory.getLogger(RollController.class);
@GetMapping("/rolldice")
public String index(@RequestParam("player") Optional<String> player) {
int result = this.getRandomNumber(1, 6);
if (player.isPresent()) {
logger.info("{} is rolling the dice: {}", player.get(), result);
} else {
logger.info("Anonymous player is rolling the dice: {}", result);
}
return Integer.toString(result);
}
public int getRandomNumber(int min, int max) {
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
}
What does this controller do?
- @RestController tells Spring this class handles HTTP requests and returns data directly (not a view template).
- @GetMapping("/rolldice") maps GET requests at the /rolldice path to the index method.
- Optional<String> player with @RequestParam("player") makes the player query parameter optional. Callers can hit /rolldice with or without ?player=Alice.
- ThreadLocalRandom generates a thread-safe random number between min and max inclusive.
- Save RollController.java.
✔️ Awesome, I've got everything!
Great. Make sure you have saved RollController.java before moving on.
ⓧ I'd like to double check the full code
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RollController {
private static final Logger logger = LoggerFactory.getLogger(RollController.class);
@GetMapping("/rolldice")
public String index(@RequestParam("player") Optional<String> player) {
int result = this.getRandomNumber(1, 6);
if (player.isPresent()) {
logger.info("{} is rolling the dice: {}", player.get(), result);
} else {
logger.info("Anonymous player is rolling the dice: {}", result);
}
return Integer.toString(result);
}
public int getRandomNumber(int min, int max) {
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
}
Build and test the app
With all four files saved, you can compile the project into a runnable JAR and verify the endpoint works before adding instrumentation.
- Build the project by running this command from inside your dice-app directory:
gradle assemble
You should see BUILD SUCCESSFUL in the output. Gradle has compiled your code and packaged it into a fat JAR.
Seeing BUILD FAILED?
- Double check that settings.gradle.kts contains exactly rootProject.name = "dice-app" and nothing else.
- Confirm build.gradle.kts has no typos in the plugin version strings.
- Make sure you are running gradle assemble from inside the dice-app folder (the same folder that contains the .gradle.kts files).
help me fix my Gradle build failure
- Start the application by running:
java -jar build/libs/dice-app-0.0.1-SNAPSHOT.jar
After a few seconds, you should see a log line containing Started DiceApplication in your terminal. The app is now listening on port 8080.
- Open your browser and visit:
http://localhost:8080/rolldice
You should see a single number between 1 and 6. Refresh the page a few times to see the number change.
- Return to the terminal running your app and press Ctrl+C to stop it.
Why stop the app?
You will re-launch the app with the OpenTelemetry agent attached in the next step. Stopping it now frees port 8080 so the instrumented version can start cleanly.
Browser shows an error or nothing loads?
- Make sure your terminal still shows the Started DiceApplication log. If the process crashed, scroll up for error messages.
- Confirm you are visiting http://localhost:8080/rolldice (not https, and not a different port).
- If port 8080 is already in use by another process, stop that process first or check for leftover Java processes.
help me get my Spring Boot app running on port 8080
Your REST API is built and tested. Next up, you will attach the OpenTelemetry Java Agent to this app and watch traces flow into Jaeger automatically.
Instrument with OpenTelemetry
Your dice-rolling API works perfectly. But right now, you have no visibility into what happens when a request hits your app.
In this step, you will attach the OpenTelemetry Java Agent to your running app. The agent automatically instruments every HTTP request and sends trace data to Jaeger. The best part is that you will not change a single line of your application code.
In this step, get ready to:
- Run the app with the OpenTelemetry Java Agent attached via JVM flags.
- Generate traces by hitting your API endpoints.
Run the app with the OpenTelemetry Java Agent
The OpenTelemetry Java Agent uses zero-code instrumentation to intercept HTTP requests at the JVM level. You attach it as a startup flag, and it handles the rest.
- Copy the OpenTelemetry Java Agent JAR into your dice-app directory by running this command:
macOS
cp ../opentelemetry-javaagent.jar .
Windows
copy ..\opentelemetry-javaagent.jar .
- Confirm the file was copied by running:
ls
You should see opentelemetry-javaagent.jar listed alongside your other project files like build.gradle.kts and DiceApplication.java.
- Launch the app with the OpenTelemetry Java Agent by running this command:
java -javaagent:opentelemetry-javaagent.jar -Dotel.service.name=dice-service -Dotel.traces.exporter=otlp -Dotel.exporter.otlp.endpoint=http://localhost:4317 -jar build/libs/dice-app-0.0.1-SNAPSHOT.jar
What do these flags do?
- -javaagent:opentelemetry-javaagent.jar tells the JVM to load the OpenTelemetry agent at startup. The agent uses bytecode manipulation to intercept HTTP calls automatically.
- -Dotel.service.name=dice-service sets the name that identifies your app in Jaeger's UI.
- -Dotel.traces.exporter=otlp tells the agent to send trace data using the OTLP protocol.
- -Dotel.exporter.otlp.endpoint=http://localhost:4317 points the agent to your local Jaeger instance, which is listening for traces on port 4317.
Watch the console output. You should see log lines prefixed with [otel.javaagent] appearing before the Spring Boot startup messages. These confirm the agent loaded successfully.
What is zero-code instrumentation?
The OpenTelemetry Java Agent intercepts method calls at the JVM level using bytecode manipulation. It automatically instruments frameworks like Spring MVC, JDBC, and HTTP clients without you editing any source code.
This is why your RollController.java stays exactly the same. The agent handles everything externally.
Not seeing [otel.javaagent] logs?
- Make sure you are running the command from inside the dice-app directory where you copied the JAR file.
- Check for typos in -javaagent: including the colon at the end. A missing colon will silently skip the agent.
- Verify opentelemetry-javaagent.jar is approximately 23 MB. A partial download will cause the agent to fail silently.
Help me debug why the OpenTelemetry Java Agent is not loading.
Generate traces by hitting your endpoints
Every HTTP request to your app now produces a trace that flows through the OTLP protocol to Jaeger. Time to generate some data.
- Open your browser and visit this URL:
http://localhost:8080/rolldice
- You should see a number between 1 and 6, just like before. The app behaves identically from the outside.
- Now visit the endpoint with a player name:
http://localhost:8080/rolldice?player=Alice
- Visit the endpoint one more time with a different player:
http://localhost:8080/rolldice?player=Bob
Each request still returns a dice number. Behind the scenes, the agent captured a full trace for each one and sent it to Jaeger on port 4317.
What is happening behind the scenes?
For every request, the OpenTelemetry agent creates a span that records the HTTP method, URL path, response status code, and duration. It bundles these spans into a trace and ships it to Jaeger using the OTLP protocol over gRPC.
You did not write any tracing code. The agent intercepted Spring MVC's request handling and generated all of this automatically.
Not getting a response from the endpoint?
- Check that the app is still running in your terminal. If you accidentally pressed Ctrl+C, re-run the full java -javaagent:... command from above.
- Confirm you are using port 8080 in the URL, not 16686 (which is Jaeger's UI port).
Help me figure out why my dice app is not responding to requests.
Your app is now fully instrumented and sending traces to Jaeger. Next up, you will explore those traces in Jaeger's web UI and see exactly what the agent captured for each request.
Explore Traces in Jaeger
In the previous step, you attached the OpenTelemetry Java Agent to your app and sent requests that generated traces. The traces are now sitting in Jaeger, waiting to be explored.
This is where observability pays off. Instead of guessing what happened inside your application, you can see the exact timeline of every request.
In this step, get ready to:
- Find your traces in the Jaeger UI.
- Inspect an individual trace and its span details.
- Understand the trace structure that auto-instrumentation provides.
Find your traces
Your app has been sending trace data to Jaeger through the OTLP protocol on port 4317. Now you can query those traces in Jaeger's web UI.
- Switch back to the Jaeger UI in your browser at http://localhost:16686.
- In the left-hand search panel, click the Service dropdown.
- Select dice-service from the list.
- Click Find Traces.
You should see a list of traces, one for each request you made to /rolldice in the previous step.
Why dice-service?
The name dice-service comes from the -Dotel.service.name=dice-service flag you passed when launching the app with the agent. This is how OpenTelemetry labels all spans from your app so Jaeger can group them by service.
Don't see dice-service in the dropdown?
Make sure your Spring Boot app is still running in the terminal with the agent attached. If you stopped it, re-launch it with the same java -javaagent:... command from the previous step, hit the endpoint again, then refresh the Jaeger page.
Help me troubleshoot why my traces aren't appearing in Jaeger
Inspect a trace
Each row in the results list represents a single trace. A trace is the complete record of one request flowing through your application.
- Click on any trace in the results list to open the timeline view.
- You will see one or more colored bars representing spans. Each span represents a unit of work during the request.
- Click on the HTTP span bar to expand its details.
In the expanded span details, look for these attributes:
- HTTP method (GET).
- URL path (/rolldice).
- HTTP status code (200).
- Duration in milliseconds.
What is a span?
A span is one step in the journey of a request. For a simple endpoint like /rolldice, you will see a single HTTP span that covers the entire request-response cycle.
In more complex applications, a single trace can contain dozens of spans: one for the HTTP handler, one for a database query, one for an external API call, and so on. Each span records what happened and how long it took.
Understand the trace structure
The real power of distributed tracing becomes clear when you compare traces side by side.
- Click the back arrow to return to the trace list.
- Open a trace from a request you made with the player parameter (e.g. /rolldice?player=Alice).
- Expand the span details and look at the tags and attributes.
- Now open a trace from a request without the player parameter.
- Compare the span tags between the two traces.
Notice how the span tags differ based on the request parameters. The URL and query string attributes change, giving you full visibility into what each request contained.
Why is this powerful?
Without writing a single line of tracing code, the OpenTelemetry agent captured detailed information about every request. In production, this means you can debug slow responses, find errors, and understand traffic patterns without adding print statements or log lines to your code.
This is the observability pipeline in action: your app generates traces automatically, the agent sends them via OTLP, and Jaeger stores and visualizes them.
Secret mission
Add Manual Instrumentation
You've seen what zero-code instrumentation can do. Now go deeper. Add your own custom spans with business-relevant attributes to the dice-rolling logic, creating a child span that nests inside the automatic HTTP span. This is how production engineers pinpoint exactly where time is spent inside their application code.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine using Docker and local files. There are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Jaeger Docker container (jaeger) running on ports 16686, 4317, and 4318.
- jaegertracing/jaeger:2.19.0 Docker image stored locally.
- dice-app project directory with source code, build artifacts, and the OpenTelemetry Java Agent JAR.
- opentelemetry-javaagent.jar file on your Desktop (the original download).
Keep everything running
No action needed. Choose this if you are still actively experimenting with your observability stack or want to send more requests to explore Jaeger further.
- Your Spring Boot app continues running in its terminal window.
- The Jaeger container continues collecting traces on ports 4317 and 4318.
- Open http://localhost:16686 any time to explore your traces.
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU, but keep all your project files for later.
- Switch to the terminal running your Spring Boot app and press Ctrl+C to stop it.
- Stop the Jaeger container by running:
docker stop jaeger
Because the container was started with the --rm flag, stopping it automatically removes the container. Your trace data will be gone, but the Docker image stays on disk so you can start a fresh Jaeger instance quickly next time.
- Your dice-app folder and opentelemetry-javaagent.jar remain on your Desktop, ready for you to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources completely from your machine.
- Switch to the terminal running your Spring Boot app and press Ctrl+C to stop it.
- Stop the Jaeger container by running:
docker stop jaeger
- Remove the Jaeger Docker image from your machine by running:
docker rmi jaegertracing/jaeger:2.19.0
- Delete the project directory and the original agent JAR from your Desktop by running:
macOS
cd ~/Desktop
rm -rf dice-app
rm opentelemetry-javaagent.jar
Windows
cd ~\Desktop
rmdir /s /q dice-app
del opentelemetry-javaagent.jar
- Confirm the files are gone by running:
ls
You should no longer see dice-app or opentelemetry-javaagent.jar in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully observable Java application from scratch and watched real distributed traces flow through a production-grade observability pipeline.
You've learned how to:
- Built a Spring Boot REST API and instrumented it with the OpenTelemetry Java Agent for zero-code distributed tracing.
- Ran a local Jaeger instance in Docker and explored traces, spans, and attributes in its web UI.
- Understood the full observability pipeline: App instrumentation to OTLP protocol to backend visualization.
- Secret Mission: Added manual instrumentation with custom spans and business-relevant attributes, combining auto and manual tracing in a single application.
Ready to quiz yourself?