Manual instrumentation lets you capture business operations that automatic instrumentation misses. Use it to track order processing, payment workflows, or any domain-specific logic that matters for debugging and monitoring.
Prerequisites
- Complete the Java OpenTelemetry instrumentation guide so your app is already sending traces
- Add the OpenTelemetry API dependency to your project
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>1.57.0</version>
</dependency>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-annotations</artifactId>
<version>2.23.0</version>
</dependency>implementation 'io.opentelemetry:opentelemetry-api:1.57.0'
implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.23.0'Step 1. Create manual spans
Using annotations (simplest approach)
Annotate methods with @WithSpan to automatically create spans:
import io.opentelemetry.instrumentation.annotations.WithSpan;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
public class OrderService {
@WithSpan("process-order")
public void processOrder(@SpanAttribute("order.id") String orderId) {
// A span wraps this entire method
validateOrder(orderId);
chargePayment(orderId);
shipOrder(orderId);
}
@WithSpan
public void validateOrder(@SpanAttribute("order.id") String orderId) {
// Child span created automatically
}
}@WithSpancreates a span for the method's lifetime@SpanAttributecaptures method parameters as span attributes- Nested
@WithSpanmethods become child spans automatically
Using the SDK (more control)
For fine-grained control, use the tracer API directly:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
public class PaymentService {
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("payment-service");
public void processPayment(String paymentId, double amount) {
Span span = tracer.spanBuilder("process-payment").startSpan();
try (Scope scope = span.makeCurrent()) {
span.setAttribute("payment.id", paymentId);
span.setAttribute("payment.amount", amount);
// Your payment logic here
chargeCard(paymentId, amount);
} finally {
span.end();
}
}
}Key points:
GlobalOpenTelemetry.getTracer()gets a tracer from the agent's configured providerspan.makeCurrent()sets this span as the parent for any child spans- Always call
span.end()in a finally block
Creating nested spans
Child spans link to their parent automatically when you use makeCurrent():
public void processOrder(String orderId) {
Span parentSpan = tracer.spanBuilder("process-order").startSpan();
try (Scope parentScope = parentSpan.makeCurrent()) {
parentSpan.setAttribute("order.id", orderId);
// This span becomes a child of process-order
Span validateSpan = tracer.spanBuilder("validate-inventory").startSpan();
try (Scope validateScope = validateSpan.makeCurrent()) {
validateSpan.setAttribute("warehouse", "WH-001");
checkInventory(orderId);
} finally {
validateSpan.end();
}
// Another child span
Span chargeSpan = tracer.spanBuilder("charge-payment").startSpan();
try (Scope chargeScope = chargeSpan.makeCurrent()) {
processPayment(orderId);
} finally {
chargeSpan.end();
}
} finally {
parentSpan.end();
}
}Step 2. Propagate context
Java keeps the active span in thread-local storage, so makeCurrent() covers everything that runs on the same thread. Two things break that chain: a thread pool, and another service. See Context Propagation for the concepts behind this step.
Inside your process
The active span follows your code as long as it stays on one thread. Hand work to an executor and the new thread starts with an empty context, so wrap the executor before you submit:
import io.opentelemetry.context.Context;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
ExecutorService executor = Executors.newFixedThreadPool(4);
Span span = tracer.spanBuilder("process-batch").startSpan();
try (Scope scope = span.makeCurrent()) {
// Child spans created in handleItem attach to process-batch.
// Await the task so process-batch still covers it when the span ends.
Future<?> task = Context.current().wrap(executor).submit(() -> handleItem(item));
task.get();
// Without wrap() the ambient context does not follow, and handleItem
// starts its own root span. Shown for comparison, do not copy this line.
// executor.submit(() -> handleItem(item));
} finally {
span.end();
}Context also offers wrapConsumer, wrapFunction, wrapSupplier, and wrapRunnable for the same purpose.
Across a service boundary
The Java agent injects and extracts for every library it instruments. Write the calls yourself for any hop it does not cover.
Injection needs a TextMapSetter that knows how to write a key onto your carrier:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.TextMapSetter;
import java.net.URI;
import java.net.http.HttpRequest;
private static final TextMapSetter<HttpRequest.Builder> SETTER =
(carrier, key, value) -> carrier.setHeader(key, value);
public void chargeCard(String paymentId) throws Exception {
Span span = tracer.spanBuilder("charge-card").startSpan();
try (Scope scope = span.makeCurrent()) {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(new URI("https://payments.internal/charge"))
.GET();
// Writes the traceparent header onto the builder
GlobalOpenTelemetry.getPropagators()
.getTextMapPropagator()
.inject(Context.current(), builder, SETTER);
client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
} finally {
span.end();
}
}Extraction needs a TextMapGetter that can list the carrier's keys and read one:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.TextMapGetter;
import java.util.Map;
private static final TextMapGetter<Map<String, String>> GETTER =
new TextMapGetter<>() {
@Override
public Iterable<String> keys(Map<String, String> carrier) {
return carrier.keySet();
}
@Override
public String get(Map<String, String> carrier, String key) {
if (carrier == null) {
return null;
}
// HTTP header names are case-insensitive, so match them that way.
// A carrier holding "Traceparent" must still resolve "traceparent".
return carrier.entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase(key))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
};
public void handleCharge(Map<String, String> headers) {
Context extracted = GlobalOpenTelemetry.getPropagators()
.getTextMapPropagator()
.extract(Context.current(), headers, GETTER);
Span span = tracer.spanBuilder("handle-charge")
.setParent(extracted)
.setSpanKind(SpanKind.SERVER)
.startSpan();
try (Scope scope = span.makeCurrent()) {
// handle-charge is now a child of charge-card in the caller's trace
capturePayment();
} finally {
span.end();
}
}Step 3. Add attributes and events
Attributes are key-value pairs that show up in SigNoz so you can filter and search traces. Events mark notable moments within a span.
Adding attributes
import io.opentelemetry.api.common.AttributeKey;
Span span = Span.current();
// String attributes
span.setAttribute("user.id", userId);
span.setAttribute("order.status", "processing");
// Numeric attributes
span.setAttribute(AttributeKey.longKey("item.count"), 5L);
span.setAttribute(AttributeKey.doubleKey("total.amount"), 149.99);
// Boolean attributes
span.setAttribute(AttributeKey.booleanKey("premium.customer"), true);Adding events
Events capture specific moments during span execution:
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributeKey;
Span span = Span.current();
// Simple event
span.addEvent("order.validated");
// Event with attributes
span.addEvent("payment.processed", Attributes.of(
AttributeKey.stringKey("payment.method"), "credit_card",
AttributeKey.stringKey("transaction.id"), "txn_abc123",
AttributeKey.doubleKey("amount"), 99.99
));
// Event with timestamp
span.addEvent("inventory.reserved", Attributes.empty(), Instant.now());Use events for:
- Cache hits/misses
- Retry attempts
- State transitions
- External API calls
Step 4. Record errors
Flag failures so they surface in SigNoz error views and alerts:
import io.opentelemetry.api.trace.StatusCode;
public void riskyOperation() {
Span span = Span.current();
try {
doSomethingRisky();
span.setStatus(StatusCode.OK);
} catch (Exception e) {
// Record the exception with full stack trace
span.recordException(e);
// Mark the span as errored
span.setStatus(StatusCode.ERROR, e.getMessage());
// Re-throw so calling code can handle it
throw e;
}
}recordException()captures the stack trace and exception detailssetStatus(StatusCode.ERROR)marks the span red in SigNoz- Always re-throw exceptions unless you're intentionally swallowing them
Getting the current span
From anywhere in your code, grab the active span to add context:
import io.opentelemetry.api.trace.Span;
public void someUtilityMethod(String customerId) {
Span currentSpan = Span.current();
if (currentSpan.isRecording()) {
currentSpan.setAttribute("customer.id", customerId);
currentSpan.addEvent("utility.invoked");
}
}Step 5. Add span links (optional)
Links connect spans that are causally related but not in a parent-child relationship. Common use cases:
- Batch processing where one trigger creates multiple independent operations
- Fan-out/fan-in patterns
- Async message processing
import io.opentelemetry.api.trace.SpanContext;
import io.opentelemetry.context.Context;
// Save the context from the triggering operation
SpanContext triggerContext = Span.current().getSpanContext();
// Later, create a linked span
Span batchItemSpan = tracer.spanBuilder("process-batch-item")
.addLink(triggerContext)
.startSpan();
try (Scope scope = batchItemSpan.makeCurrent()) {
// Process the batch item
} finally {
batchItemSpan.end();
}Validate
With your application running, verify traces are being sent to SigNoz:
- Trigger the code paths with your manual instrumentation.
- In SigNoz, open the Services tab and click Refresh. Your application should appear.
- Go to the Traces tab to see your application's traces.
Troubleshooting
Still not seeing data in SigNoz? Work through Debug missing traces, logs, and metrics, which covers SDK diagnostics, Collector connectivity, and the common ingestion errors for all three signals.
Custom spans not appearing?
Check the tracer is initialized:
Tracer tracer = GlobalOpenTelemetry.getTracer("my-service");
// If this returns a no-op tracer, the agent isn't runningMake sure you're running with the Java agent attached.
Verify spans are ended:
// Wrong - span never ends
Span span = tracer.spanBuilder("my-span").startSpan();
doWork();
// Correct - span ends in finally
Span span = tracer.spanBuilder("my-span").startSpan();
try {
doWork();
} finally {
span.end();
}Child spans not linking to parent?
Use makeCurrent() to set the parent context:
// Wrong - no parent link
Span child = tracer.spanBuilder("child").startSpan();
// Correct - parent span is current
Span parent = tracer.spanBuilder("parent").startSpan();
try (Scope scope = parent.makeCurrent()) {
Span child = tracer.spanBuilder("child").startSpan();
// child is now linked to parent
}Why does the downstream service start its own trace?
- Make sure that the caller runs
injectand that the outgoing request carries atraceparentheader. - Make sure that the receiver calls
setParent(extracted)on the span builder. Without it the entry span takes whatever is current on the thread and starts a new trace. - Make sure that both services register the same propagator format. See Context Propagation.
Attributes not showing up?
- Set attributes before calling
span.end() - Use the correct attribute types (strings, longs, doubles, booleans)
- Check attribute names don't have typos
@WithSpan not creating spans?
Make sure:
- The Java agent is attached (annotations only work with the agent)
- The annotated method is called from outside the class (self-invocation skips the proxy)
- You have
opentelemetry-instrumentation-annotationsin your dependencies
Next steps
- Java traces instrumentation guide for auto-instrumentation setup
- Collect Java application logs with OpenTelemetry
- Check OpenTelemetry Java API reference for advanced usage for manual instrumentation
- Read Context Propagation for the wire format and the failure modes shared across languages