This document describes how to use OpenTelemetry distributed tracing with the Pulsar Java client.
The Pulsar Java client provides built-in support for OpenTelemetry distributed tracing. This allows you to:
Producer tracing creates spans for:
send() or sendAsync() is called and completes when broker acknowledges receiptConsumer tracing creates spans for:
Trace context is automatically propagated using W3C TraceContext format:
traceparent - Contains trace ID, span ID, and trace flagstracestate - Contains vendor-specific trace informationContext is injected into and extracted from message properties, enabling seamless trace propagation across services.
The Pulsar client already includes OpenTelemetry API dependencies. You'll need to add the SDK and exporters:
<dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-sdk</artifactId> <version>${opentelemetry.version}</version> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> <version>${opentelemetry.version}</version> </dependency>
There are three ways to enable tracing:
# Start your application with the Java Agent java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=my-service \ -Dotel.exporter.otlp.endpoint=http://localhost:4317 \ -jar your-application.jar
// Just enable tracing - uses GlobalOpenTelemetry from the agent PulsarClient client = PulsarClient.builder() .serviceUrl("pulsar://localhost:6650") .enableTracing(true) // That's it! .build();
OpenTelemetry openTelemetry = // configure your OpenTelemetry instance PulsarClient client = PulsarClient.builder() .serviceUrl("pulsar://localhost:6650") .openTelemetry(openTelemetry, true) // Set OpenTelemetry AND enable tracing .build();
// Configure GlobalOpenTelemetry once in your application GlobalOpenTelemetry.set(myOpenTelemetry); // Enable tracing in the client - uses GlobalOpenTelemetry PulsarClient client = PulsarClient.builder() .serviceUrl("pulsar://localhost:6650") .enableTracing(true) .build();
What happens when tracing is enabled:
If you prefer manual control, you can add interceptors explicitly:
import org.apache.pulsar.client.impl.tracing.OpenTelemetryProducerInterceptor; import org.apache.pulsar.client.impl.tracing.OpenTelemetryConsumerInterceptor; // Create client (tracing not enabled globally) PulsarClient client = PulsarClient.builder() .serviceUrl("pulsar://localhost:6650") .openTelemetry(openTelemetry) .build(); // Add interceptor manually to specific producer Producer<String> producer = client.newProducer(Schema.STRING) .topic("my-topic") .intercept(new OpenTelemetryProducerInterceptor()) .create(); // Add interceptor manually to specific consumer Consumer<String> consumer = client.newConsumer(Schema.STRING) .topic("my-topic") .subscriptionName("my-subscription") .intercept(new OpenTelemetryConsumerInterceptor<>()) .subscribe();
This example shows how to create a complete trace from an HTTP request through Pulsar to a consumer:
// Service 1: HTTP API that publishes to Pulsar @POST @Path("/order") public Response createOrder(@Context HttpHeaders headers, Order order) { // Extract trace context from incoming HTTP request Context context = TracingProducerBuilder.extractFromHeaders( convertHeaders(headers)); // Publish to Pulsar with trace context TracingProducerBuilder tracingBuilder = new TracingProducerBuilder(); producer.newMessage() .value(order) .let(builder -> tracingBuilder.injectContext(builder, context)) .send(); return Response.accepted().build(); } // Service 2: Pulsar consumer that processes orders Consumer<Order> consumer = client.newConsumer(Schema.JSON(Order.class)) .topic("orders") .subscriptionName("order-processor") .intercept(new OpenTelemetryConsumerInterceptor<>()) .subscribe(); while (true) { Message<Order> msg = consumer.receive(); // Trace context is automatically extracted // Any spans created here will be part of the same trace processOrder(msg.getValue()); consumer.acknowledge(msg); }
You can create custom spans during message processing:
import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Scope; Tracer tracer = GlobalOpenTelemetry.get().getTracer("my-app"); Message<String> msg = consumer.receive(); // Create a custom span for processing Span span = tracer.spanBuilder("process-message") .setSpanKind(SpanKind.INTERNAL) .startSpan(); try (Scope scope = span.makeCurrent()) { // Your processing logic processMessage(msg.getValue()); span.setStatus(StatusCode.OK); } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR); throw e; } finally { span.end(); consumer.acknowledge(msg); }
This implementation is fully compatible with the OpenTelemetry Java Instrumentation for Pulsar:
The easiest way to enable tracing is using the OpenTelemetry Java Agent (automatic instrumentation):
java -javaagent:path/to/opentelemetry-javaagent.jar \ -Dotel.service.name=my-service \ -Dotel.exporter.otlp.endpoint=http://localhost:4317 \ -jar your-application.jar
Note: When using the Java Agent, you don‘t need to call .openTelemetry(otel, true) as the agent automatically instruments Pulsar. However, calling it won’t cause conflicts.
You can also configure OpenTelemetry programmatically:
import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; OtlpGrpcSpanExporter spanExporter = OtlpGrpcSpanExporter.builder() .setEndpoint("http://localhost:4317") .build(); SdkTracerProvider tracerProvider = SdkTracerProvider.builder() .addSpanProcessor(BatchSpanProcessor.builder(spanExporter).build()) .build(); OpenTelemetrySdk openTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(tracerProvider) .buildAndRegisterGlobal();
Configure via environment variables:
export OTEL_SERVICE_NAME=my-service export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 export OTEL_TRACES_EXPORTER=otlp export OTEL_METRICS_EXPORTER=otlp
The tracing implementation adds the following attributes to spans following the OpenTelemetry messaging semantic conventions:
messaging.system: “pulsar”messaging.destination.name: Topic namemessaging.operation.name: “send”messaging.message.id: Message ID (added when broker confirms)Span naming: send {topic} (e.g., “send my-topic”)
messaging.system: “pulsar”messaging.destination.name: Topic namemessaging.destination.subscription.name: Subscription namemessaging.operation.name: “process”messaging.message.id: Message IDmessaging.pulsar.acknowledgment.type: How the message was acknowledged"acknowledge": Normal individual acknowledgment"cumulative_acknowledge": Cumulative acknowledgment"negative_acknowledge": Message negatively acknowledged (will retry)"ack_timeout": Acknowledgment timeout occurred (will retry)Span naming: process {topic} (e.g., “process my-topic”)
Understanding how spans are handled for different acknowledgment scenarios. Every consumer span includes a messaging.pulsar.acknowledgment.type attribute indicating how it was completed:
messaging.pulsar.acknowledgment.type = "acknowledge"messaging.pulsar.acknowledgment.type = "cumulative_acknowledge"messaging.pulsar.acknowledgment.type = "negative_acknowledge"messaging.pulsar.acknowledgment.type = "ack_timeout"ackTimeout is configured - the message will be redelivered and a new span will be creatednegativeAcknowledge()Example - Separating messaging and application errors:
Message<String> msg = consumer.receive(); Span processingSpan = tracer.spanBuilder("business-logic").startSpan(); try (Scope scope = processingSpan.makeCurrent()) { processMessage(msg.getValue()); processingSpan.setStatus(StatusCode.OK); consumer.acknowledge(msg); // Consumer span ends with acknowledgment.type="acknowledge" } catch (Exception e) { processingSpan.recordException(e); processingSpan.setStatus(StatusCode.ERROR); // Business logic failed consumer.negativeAcknowledge(msg); // Consumer span ends with acknowledgment.type="negative_acknowledge" throw e; } finally { processingSpan.end(); }
The messaging.pulsar.acknowledgment.type attribute allows you to filter and analyze spans:
Example queries in your tracing backend:
messaging.pulsar.acknowledgment.type = "negative_acknowledge" OR "ack_timeout"count(negative_acknowledge) / count(acknowledge)messaging.pulsar.acknowledgment.type = "ack_timeout"messaging.pulsar.acknowledgment.typeAlways use interceptors: Add tracing interceptors to both producers and consumers for complete visibility.
Propagate context from HTTP: When publishing from HTTP endpoints, always extract and propagate the trace context.
Handle errors properly: Ensure spans are ended even when exceptions occur.
Distinguish messaging vs. application errors:
Use meaningful span names: The default span names include the topic name for easy identification.
Consider performance: Tracing adds minimal overhead, but in high-throughput scenarios, consider sampling.
Clean up resources: Ensure interceptors and OpenTelemetry SDK are properly closed when shutting down.
-Dio.opentelemetry.javaagent.debug=trueTracingProducerBuilder.injectContext()traceparent header-Dotel.traces.sampler=parentbased_traceidratio -Dotel.traces.sampler.arg=0.1See the following files for complete examples:
TracingExampleTest.java - Comprehensive usage examplesOpenTelemetryTracingTest.java - Unit tests demonstrating API usageOpenTelemetryProducerInterceptor - Producer interceptor for tracingOpenTelemetryConsumerInterceptor - Consumer interceptor for tracingTracingContext - Utility methods for span creation and context propagationTracingProducerBuilder - Helper for injecting trace context into messages