Most Java services already have some telemetry — access logs, a /health endpoint, maybe a JVM metrics scrape. That is rarely enough when a request crosses three processes and fails in the third. OpenTelemetry gives you a vendor-neutral way to emit traces, metrics, and logs from the same mental model.
This post is a small, self-contained example using the OpenTelemetry Java API. It is the kind of snippet I reach for when the auto-instrumentation agent already covers HTTP, but a domain operation still looks like a blank gap in the trace.
What you want from a span#
A useful span answers three questions:
- What work happened? The span name should be stable and readable (
OrderService.placeOrder, notPOST /api/v2/o). - Which instance of that work? Attributes such as
order.idorcustomer.tierlet you filter in the backend. - Did it succeed, and if not, why? Status and recorded exceptions turn a red trace into a diagnosis.
Keep high-cardinality identifiers as attributes, not as span names. Span names become time series; attributes stay queryable.
A minimal custom span#
The API is intentionally small. You ask the global tracer provider for a tracer, start a span, put work inside a scope, and end the span in finally.
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
public final class OrderService {
private static final Tracer TRACER =
GlobalOpenTelemetry.getTracer("com.example.orders", "1.0.0");
private final PaymentClient payments;
public OrderService(PaymentClient payments) {
this.payments = payments;
}
public Order placeOrder(OrderRequest request) {
Span span = TRACER.spanBuilder("OrderService.placeOrder")
.setAttribute("order.id", request.orderId())
.setAttribute("order.items", request.itemCount())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
Order order = persist(request);
payments.charge(order);
span.setStatus(StatusCode.OK);
return order;
} catch (RuntimeException exception) {
span.recordException(exception);
span.setStatus(StatusCode.ERROR, exception.getMessage());
throw exception;
} finally {
span.end();
}
}
private Order persist(OrderRequest request) {
return new Order(request.orderId());
}
}makeCurrent() matters. Anything you call while the Scope is open — including the HTTP client the Java agent already instruments — should become a child of OrderService.placeOrder. Without the scope, you get a floating sibling span and the waterfall stops making sense.
Agent, SDK, or both#
For most production Java services I start with the OpenTelemetry Java agent. It covers incoming HTTP, JDBC, commonly used clients, and a lot of framework glue. Custom spans like the one above sit on top of that: you add domain language the agent cannot invent.
Use the SDK directly when you are writing a library, a non-HTTP worker, or a test that needs an in-memory exporter. The API you call (spanBuilder, setAttribute, recordException) stays the same. The difference is who installs the TracerProvider.
What I leave out on purpose#
- Do not put secrets, tokens, or raw payloads on spans. Treat attributes as something an on-call engineer will read in a shared backend.
- Do not create a span per loop iteration on a tight path. Batch work, or use events on a parent span.
- Do name tracers after the instrumentation scope (
com.example.orders), not after the process hostname.
If you want a next step, export to a local collector with OTLP and open the same request in your trace UI. If the custom span is the parent of the auto-instrumented client call, the setup is doing its job.
More notes on talks and related projects will land on the Projects/Talks page.
