Adding Custom Instrumentation
Part 3 of an 8-part series on implementing observability in Java microservices
In Part 2, we got distributed tracing working with zero code changes. But the auto-generated spans are generic: POST /orders, GET /inventory.
When debugging production issues, you need business context: Which order failed? Which customer? What was the payment amount?
That’s where manual instrumentation comes in.
[!TIP] Hands-On Example: The
otel-demoservices demonstrate real-world custom instrumentation with@WithSpan, business attributes, and error handling. SeeOrderController.javafor practical examples.
When Auto-Instrumentation Isn’t Enough
Auto-instrumentation is fantastic for:
- HTTP requests/responses
- Database query timing
- Message queue operations
But it doesn’t know about your business logic:
- Order IDs, customer IDs, SKUs
- Business validation steps
- Internal processing phases
- Custom error contexts
Adding the OTel SDK Dependency
First, add the OpenTelemetry SDK to your project:
<!-- Maven -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-annotations</artifactId>
<version>2.1.0</version>
</dependency>
// Gradle
implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.1.0'
Note: When using the Java Agent, you don’t need the full SDK—just the annotations library. The agent provides the runtime.
Method 1: @WithSpan Annotation (Easiest)
The simplest way to add custom spans is with the @WithSpan annotation:
import io.opentelemetry.instrumentation.annotations.WithSpan;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
@Service
public class OrderService {
@WithSpan("process-order")
public Order processOrder(
@SpanAttribute("order.customer_id") String customerId,
@SpanAttribute("order.amount") BigDecimal amount) {
validateOrder(customerId, amount);
Order order = createOrder(customerId, amount);
sendConfirmation(order);
return order;
}
@WithSpan("validate-order")
private void validateOrder(String customerId, BigDecimal amount) {
// Validation logic
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidOrderException("Amount must be positive");
}
}
}
Now your trace shows:
POST /orders
└── process-order [customer_id: "cust-123", amount: 99.99]
└── validate-order
Method 2: Programmatic Spans (Full Control)
For more control, create spans programmatically:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
@Service
public class PaymentService {
private final Tracer tracer = GlobalOpenTelemetry.getTracer("payment-service");
public PaymentResult processPayment(String orderId, BigDecimal amount) {
Span span = tracer.spanBuilder("process-payment")
.setAttribute("order.id", orderId)
.setAttribute("payment.amount", amount.doubleValue())
.setAttribute("payment.currency", "USD")
.startSpan();
try (Scope scope = span.makeCurrent()) {
// Your payment logic
PaymentResult result = callPaymentGateway(amount);
span.setAttribute("payment.transaction_id", result.getTransactionId());
span.setAttribute("payment.status", result.getStatus());
return result;
} catch (PaymentException e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
}
Adding Events to Spans
Events are timestamped annotations within a span—perfect for marking checkpoints:
@WithSpan("process-order")
public Order processOrder(OrderRequest request) {
Span currentSpan = Span.current();
// Mark validation complete
currentSpan.addEvent("order.validated", Attributes.of(
AttributeKey.stringKey("validation.result"), "passed"
));
// Mark inventory reserved
reserveInventory(request.getSku());
currentSpan.addEvent("inventory.reserved");
// Mark payment processed
processPayment(request);
currentSpan.addEvent("payment.processed", Attributes.of(
AttributeKey.stringKey("payment.method"), request.getPaymentMethod()
));
return createOrder(request);
}
In Jaeger, you’ll see these events as markers on the span timeline.
Recording Exceptions
Always record exceptions for debugging:
try {
riskyOperation();
} catch (Exception e) {
Span.current().recordException(e, Attributes.of(
AttributeKey.stringKey("exception.context"), "During inventory check"
));
Span.current().setStatus(StatusCode.ERROR, "Inventory check failed");
throw e;
}
This adds the full stack trace to your span, visible in Jaeger.
Best Practices
1. Use Semantic Conventions
OpenTelemetry defines standard attribute names. Use them:
// Good - follows semantic conventions
span.setAttribute("http.request.method", "POST");
span.setAttribute("db.system", "postgresql");
span.setAttribute("messaging.system", "kafka");
// Avoid - custom names that aren't standard
span.setAttribute("method", "POST");
span.setAttribute("database", "postgresql");
2. Mind Your Span Names
Span names should be low-cardinality:
// Good - fixed name
tracer.spanBuilder("get-order-by-id")
// Bad - high cardinality (includes variable data)
tracer.spanBuilder("get-order-" + orderId)
Put variable data in attributes, not span names.
3. Don’t Over-Instrument
Every span has overhead. Focus on:
- Business operations (not every helper method)
- External calls (APIs, databases)
- Long-running processes
- Error-prone code paths
4. Add Context, Not Noise
Good attributes help debugging:
span.setAttribute("order.id", orderId);
span.setAttribute("order.item_count", items.size());
span.setAttribute("customer.tier", customer.getTier());
Context Propagation & Baggage (The “Hidden” Power)
One of the most powerful—and often overlooked—features of OpenTelemetry is Baggage.
While Attributes are attached to a single Span (and stay there), Baggage travels with the Context across process boundaries (HTTP headers).
The Problem it Solves
Imagine you have a chain of 5 microservices:
Frontend -> Auth -> Order -> Inventory -> Shipping
You want to know: “Which specific customer ID triggered this shipping request?”
But the Shipping service doesn’t have the HttpServletRequest with the auth token. It just got an internal gRPC call.
Do you change every method signature to pass customerId down the stack? No. You use Baggage.
import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.context.Scope;
// In Service A (Entry Point)
public void handleRequest(String userId) {
// Put userId into Baggage
// It will now auto-propagate to Service B, C, D... via HTTP headers
try (Scope scope = Baggage.current().toBuilder()
.put("app.user_id", userId)
.put("app.is_vip", "true")
.build()
.makeCurrent()) {
callDownstreamService();
}
}
// In Service D (Deep Downstream)
public void processShipment() {
// Retrieve from Baggage - no method arguments needed!
String userId = Baggage.current().getEntryValue("app.user_id");
// Add it to the current span so it shows up in Jaeger
Span.current().setAttribute("user.id", userId);
}
Warning: Baggage is serialized into headers. Do not put large objects or sensitive PII (like passwords) in Baggage. Use it for IDs, flags, and trace context.
Complete Example: Order Processing
Here’s a realistic example combining all techniques:
@Service
@RequiredArgsConstructor
public class OrderService {
private final Tracer tracer = GlobalOpenTelemetry.getTracer("order-service");
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
@WithSpan("create-order")
public Order createOrder(
@SpanAttribute("order.customer_id") String customerId,
@SpanAttribute("order.sku") String sku,
@SpanAttribute("order.quantity") int quantity) {
Span span = Span.current();
String orderId = UUID.randomUUID().toString();
span.setAttribute("order.id", orderId);
// Check inventory
checkInventory(sku, quantity);
span.addEvent("inventory.checked");
// Calculate pricing
BigDecimal total = calculateTotal(sku, quantity);
span.setAttribute("order.total", total.doubleValue());
// Process payment
String paymentId = processPayment(customerId, total);
span.setAttribute("payment.id", paymentId);
span.addEvent("payment.completed");
// Save order
Order order = saveOrder(orderId, customerId, sku, quantity, total);
span.addEvent("order.saved");
return order;
}
private void checkInventory(String sku, int quantity) {
Span span = tracer.spanBuilder("check-inventory")
.setAttribute("inventory.sku", sku)
.setAttribute("inventory.quantity_requested", quantity)
.startSpan();
try (Scope scope = span.makeCurrent()) {
boolean available = inventoryClient.check(sku, quantity);
span.setAttribute("inventory.available", available);
if (!available) {
throw new InsufficientInventoryException(sku);
}
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
}
}
What’s Next
You now have rich, contextual traces. But observability is more than tracing.
In Part 4, we’ll add custom metrics:
- Request counters
- Latency histograms
- Business gauges (orders per minute, active users)
We’ll export to Prometheus and build dashboards in Grafana.