For the complete documentation index, see llms.txt. Markdown versions are available by appending .md to documentation URLs.

How to add manual instrumentation in Java

SigNoz Cloud - This page applies to SigNoz Cloud editions.
Self-Host - This page applies to self-hosted SigNoz editions.

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

<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>

Step 1. Create manual spans

Using annotations (simplest approach)

Annotate methods with @WithSpan to automatically create spans:

OrderService.java
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
    }
}
  • @WithSpan creates a span for the method's lifetime
  • @SpanAttribute captures method parameters as span attributes
  • Nested @WithSpan methods become child spans automatically

Using the SDK (more control)

For fine-grained control, use the tracer API directly:

PaymentService.java
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 provider
  • span.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():

OrderProcessor.java
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:

ThreadPool.java
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:

Inject.java
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:

Extract.java
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 details
  • setStatus(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");
    }
}

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:

  1. Trigger the code paths with your manual instrumentation.
  2. In SigNoz, open the Services tab and click Refresh. Your application should appear.
  3. 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 running

Make 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 inject and that the outgoing request carries a traceparent header.
  • 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:

  1. The Java agent is attached (annotations only work with the agent)
  2. The annotated method is called from outside the class (self-invocation skips the proxy)
  3. You have opentelemetry-instrumentation-annotations in your dependencies

Next steps

Is this page helpful

Last updated—September 03, 2026

Edit on GitHub