Post

Setting Up OpenTelemetry for Java Applications

Introduction to OpenTelemetry

OpenTelemetry is an open-source observability framework that provides a unified set of APIs, libraries, and instrumentation to capture distributed traces, metrics, and logs from your applications. As a CNCF project, it has become the industry standard for application observability.

Why Use OpenTelemetry?

  • Vendor-Agnostic: Export data to multiple backends (Jaeger, Zipkin, Prometheus, etc.)
  • Comprehensive: Supports traces, metrics, and logs
  • Auto-Instrumentation: Automatic instrumentation for popular Java frameworks
  • Standards-Based: Industry-standard semantic conventions
  • Active Community: Strong ecosystem and regular updates

Prerequisites

Before getting started, ensure you have:

  • Java Development Kit (JDK) 8 or higher
  • Maven 3.6+ or Gradle 6.0+
  • IDE (IntelliJ IDEA, Eclipse, or VS Code)
  • Basic knowledge of Spring Boot or Java web frameworks

Setting Up OpenTelemetry in Java

There are two main approaches to instrumenting Java applications with OpenTelemetry:

  1. Automatic Instrumentation (Java Agent) - Zero code changes
  2. Manual Instrumentation - More control and customization

We’ll cover both approaches.

Approach 1: Automatic Instrumentation with Java Agent

This is the easiest way to get started with OpenTelemetry in Java applications.

Step 1: Download the OpenTelemetry Java Agent

Download the latest OpenTelemetry Java Agent JAR:

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

Or specify a specific version:

1
wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.32.0/opentelemetry-javaagent.jar

Step 2: Create a Spring Boot Application

Create a new Spring Boot application:

1
2
3
# Using Spring Initializr or create manually
mkdir opentelemetry-java-demo
cd opentelemetry-java-demo

pom.xml (Maven):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>opentelemetry-demo</artifactId>
    <version>1.0.0</version>
    
    <properties>
        <java.version>17</java.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 3: Create a Sample REST Controller

src/main/java/com/example/demo/controller/ProductController.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.example.demo.controller;

import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    private static final Logger logger = LoggerFactory.getLogger(ProductController.class);
    private List<Product> products = new ArrayList<>();
    
    public ProductController() {
        // Initialize with sample data
        products.add(new Product(1L, "Laptop", 1299.99));
        products.add(new Product(2L, "Mouse", 29.99));
        products.add(new Product(3L, "Keyboard", 79.99));
    }
    
    @GetMapping
    public List<Product> getAllProducts() {
        logger.info("Fetching all products");
        simulateDelay(100);
        return products;
    }
    
    @GetMapping("/{id}")
    public Optional<Product> getProductById(@PathVariable Long id) {
        logger.info("Fetching product with id: {}", id);
        simulateDelay(50);
        return products.stream()
                .filter(p -> p.getId().equals(id))
                .findFirst();
    }
    
    @PostMapping
    public Product createProduct(@RequestBody Product product) {
        logger.info("Creating new product: {}", product.getName());
        product.setId((long) (products.size() + 1));
        products.add(product);
        simulateDelay(150);
        return product;
    }
    
    private void simulateDelay(int milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

class Product {
    private Long id;
    private String name;
    private Double price;
    
    public Product() {}
    
    public Product(Long id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }
    
    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
}

Step 4: Run with the Java Agent

Run your application with the OpenTelemetry Java Agent:

1
2
3
4
5
6
7
java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.service.name=java-product-service \
     -Dotel.traces.exporter=otlp \
     -Dotel.metrics.exporter=otlp \
     -Dotel.logs.exporter=otlp \
     -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
     -jar target/opentelemetry-demo-1.0.0.jar

Or with Maven:

1
mvn spring-boot:run -Dspring-boot.run.jvmArguments="-javaagent:opentelemetry-javaagent.jar -Dotel.service.name=java-product-service -Dotel.traces.exporter=otlp -Dotel.exporter.otlp.endpoint=http://localhost:4317"

Approach 2: Manual Instrumentation

For more control over your instrumentation, use the OpenTelemetry SDK directly.

Step 1: Add Dependencies

Add OpenTelemetry dependencies to your pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<properties>
    <opentelemetry.version>1.32.0</opentelemetry.version>
</properties>

<dependencies>
    <!-- OpenTelemetry API -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-api</artifactId>
        <version>${opentelemetry.version}</version>
    </dependency>
    
    <!-- OpenTelemetry SDK -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-sdk</artifactId>
        <version>${opentelemetry.version}</version>
    </dependency>
    
    <!-- OpenTelemetry OTLP Exporter -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
        <version>${opentelemetry.version}</version>
    </dependency>
    
    <!-- OpenTelemetry Instrumentation for Spring Boot -->
    <dependency>
        <groupId>io.opentelemetry.instrumentation</groupId>
        <artifactId>opentelemetry-spring-boot-starter</artifactId>
        <version>1.32.0-alpha</version>
    </dependency>
</dependencies>

Step 2: Configure OpenTelemetry

Create a configuration class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.demo.config;

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.context.propagation.ContextPropagators;
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenTelemetryConfig {
    
    @Value("${otel.service.name:java-product-service}")
    private String serviceName;
    
    @Value("${otel.exporter.otlp.endpoint:http://localhost:4317}")
    private String otlpEndpoint;
    
    @Bean
    public OpenTelemetry openTelemetry() {
        Resource resource = Resource.getDefault()
                .merge(Resource.create(Attributes.of(
                        ResourceAttributes.SERVICE_NAME, serviceName,
                        ResourceAttributes.SERVICE_VERSION, "1.0.0"
                )));
        
        OtlpGrpcSpanExporter spanExporter = OtlpGrpcSpanExporter.builder()
                .setEndpoint(otlpEndpoint)
                .build();
        
        SdkTracerProvider sdkTracerProvider = SdkTracerProvider.builder()
                .addSpanProcessor(BatchSpanProcessor.builder(spanExporter).build())
                .setResource(resource)
                .build();
        
        return OpenTelemetrySdk.builder()
                .setTracerProvider(sdkTracerProvider)
                .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
                .buildAndRegisterGlobal();
    }
}

Step 3: Add Custom Instrumentation

Create a service with custom spans:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.example.demo.service;

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    
    private final Tracer tracer;
    
    public OrderService(OpenTelemetry openTelemetry) {
        this.tracer = openTelemetry.getTracer("OrderService", "1.0.0");
    }
    
    public String processOrder(Long orderId) {
        Span span = tracer.spanBuilder("processOrder").startSpan();
        
        try (Scope scope = span.makeCurrent()) {
            span.setAttribute("order.id", orderId);
            
            // Validate order
            validateOrder(orderId);
            
            // Process payment
            processPayment(orderId);
            
            // Ship order
            shipOrder(orderId);
            
            span.setAttribute("order.status", "completed");
            return "Order processed successfully";
        } catch (Exception e) {
            span.recordException(e);
            span.setAttribute("error", true);
            throw e;
        } finally {
            span.end();
        }
    }
    
    private void validateOrder(Long orderId) {
        Span span = tracer.spanBuilder("validateOrder").startSpan();
        try (Scope scope = span.makeCurrent()) {
            span.setAttribute("order.id", orderId);
            // Validation logic
            Thread.sleep(50);
            span.setAttribute("validation.status", "passed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            span.end();
        }
    }
    
    private void processPayment(Long orderId) {
        Span span = tracer.spanBuilder("processPayment").startSpan();
        try (Scope scope = span.makeCurrent()) {
            span.setAttribute("order.id", orderId);
            // Payment processing logic
            Thread.sleep(100);
            span.setAttribute("payment.status", "success");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            span.end();
        }
    }
    
    private void shipOrder(Long orderId) {
        Span span = tracer.spanBuilder("shipOrder").startSpan();
        try (Scope scope = span.makeCurrent()) {
            span.setAttribute("order.id", orderId);
            // Shipping logic
            Thread.sleep(75);
            span.setAttribute("shipping.status", "dispatched");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            span.end();
        }
    }
}

Step 4: Configure Application Properties

Add to application.properties or application.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# application.yml
otel:
  service:
    name: java-product-service
  exporter:
    otlp:
      endpoint: http://localhost:4317
  traces:
    exporter: otlp
  metrics:
    exporter: otlp

spring:
  application:
    name: java-product-service

logging:
  level:
    io.opentelemetry: DEBUG

Setting Up an Observability Backend

Using Jaeger with Docker

1
2
3
4
5
6
docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest

Access Jaeger UI at: http://localhost:16686

Using Docker Compose

Create docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: '3.8'

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    container_name: jaeger
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP

  app:
    build: .
    container_name: java-app
    environment:
      - OTEL_SERVICE_NAME=java-product-service
      - OTEL_TRACES_EXPORTER=otlp
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
    ports:
      - "8080:8080"
    depends_on:
      - jaeger

Run with:

1
docker-compose up -d

Testing Your Implementation

Step 1: Build and Run

1
2
mvn clean package
java -jar target/opentelemetry-demo-1.0.0.jar

Step 2: Make API Calls

1
2
3
4
5
6
7
8
9
10
# Get all products
curl http://localhost:8080/api/products

# Get a specific product
curl http://localhost:8080/api/products/1

# Create a new product
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Monitor","price":299.99}'

Step 3: View Traces in Jaeger

  1. Open http://localhost:16686 in your browser
  2. Select java-product-service from the service dropdown
  3. Click “Find Traces”
  4. Explore the traces and spans

Best Practices

1. Use Semantic Conventions

Follow OpenTelemetry semantic conventions:

1
2
3
4
5
span.setAttribute("http.method", "GET");
span.setAttribute("http.url", "/api/products");
span.setAttribute("http.status_code", 200);
span.setAttribute("db.system", "postgresql");
span.setAttribute("db.statement", "SELECT * FROM products");

2. Add Meaningful Attributes

1
2
3
span.setAttribute("user.id", userId);
span.setAttribute("order.total", orderTotal);
span.setAttribute("product.category", category);

3. Handle Errors Properly

1
2
3
4
5
6
7
try {
    // Business logic
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR, e.getMessage());
    throw e;
}

4. Use Span Events

1
2
3
4
span.addEvent("Order validated");
span.addEvent("Payment processed", Attributes.of(
    AttributeKey.stringKey("payment.method"), "credit_card"
));

5. Configure Sampling

For production, configure sampling in application.yml:

1
2
3
4
otel:
  traces:
    sampler:
      probability: 0.1  # Sample 10% of traces

Or programmatically:

1
2
3
SdkTracerProvider.builder()
    .setSampler(Sampler.traceIdRatioBased(0.1))
    .build();

Common Issues and Troubleshooting

Issue 1: No Traces Appearing

  • Verify OTLP endpoint is accessible
  • Check if Jaeger/collector is running
  • Ensure service name is configured
  • Check application logs for errors

Issue 2: Performance Impact

  • Use batch span processors (default)
  • Configure appropriate sampling rates
  • Limit custom span creation
  • Use async exporters

Issue 3: Missing Context Propagation

  • Ensure W3C Trace Context propagator is configured
  • Check HTTP headers are being propagated
  • Verify instrumentation covers all HTTP clients

Additional Resources

Conclusion

OpenTelemetry provides powerful observability capabilities for Java applications. Whether you choose automatic instrumentation with the Java agent for quick setup or manual instrumentation for fine-grained control, OpenTelemetry gives you the flexibility to instrument your applications effectively. Start with automatic instrumentation and add custom spans where you need additional visibility into your application’s behavior.

Happy tracing!

This post is licensed under CC BY 4.0 by the author.