Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for Java: Traces, Metrics & Logs with OpenObserve

Instrument Java with OpenTelemetry — zero-code javaagent and manual SDK setup — and ship traces, metrics, and logs to OpenObserve via OTLP.

OpenTelemetry guide

OpenTelemetry Java is the most widely deployed OpenTelemetry implementation, and it is fully production-ready: traces, metrics, and logs are all stable signals in the Java SDK. This guide shows both ways to instrument a Java application — the zero-code javaagent JAR and the manual SDK with opentelemetry-bom — and how to ship all three signals to OpenObserve over OTLP. If you are new to OpenTelemetry itself, start with What is OpenTelemetry? A Complete Guide.

Why OpenTelemetry for Java

Java applications tend to accumulate observability agents: one vendor agent for APM, a logging shipper, a metrics exporter for Prometheus. OpenTelemetry replaces all of them with a single, vendor-neutral instrumentation layer. Because the wire format is OTLP, you can point the same instrumentation at OpenObserve today and at any other OTLP-compatible backend tomorrow without touching application code.

Signal maturity in OpenTelemetry Java:

SignalStatusNotes
TracesStableAPI and SDK stable; automatic context propagation across threads and libraries
MetricsStableCounter, gauge, histogram instruments; JVM runtime metrics available out of the box with the agent
LogsStableLogs API is a bridge — your Logback/Log4j/JUL statements are routed into the OTel pipeline

Two more reasons Java is a particularly good fit:

  • The Java agent is the gold standard for zero-code instrumentation. It attaches at JVM startup and instruments hundreds of libraries — servlet containers, HTTP clients, JDBC drivers, Kafka, gRPC, and more — via bytecode manipulation. No source changes, no rebuild.
  • It works with any framework. Whether you run a plain HttpServer, a servlet container, or any modern framework, the same agent flag and the same SDK APIs apply. Nothing in this guide is framework-specific.

Prerequisites

  • Java 8+ for the OpenTelemetry Java agent and SDK (Java 17+ recommended for current LTS support).
  • Maven or Gradle for the manual SDK path.
  • An OpenObserve instance:
    • OpenObserve Cloud — sign up at cloud.openobserve.ai and note your organization name and ingestion credentials (available under Ingestion in the UI).
    • Self-hosted — a single binary or container listening on http://localhost:5080 by default. See the OpenObserve quickstart.
  • A Basic auth token for OpenObserve. Generate it from your credentials:
echo -n 'root@example.com:your-password' | base64

Keep the output handy — every example below refers to it as <BASE64_TOKEN>.

Zero-code instrumentation with the Java agent

The fastest path to full telemetry is the OpenTelemetry Java agent — a single JAR that you attach with the standard -javaagent JVM flag. It requires no code changes and no new dependencies in your build.

Download the latest agent (2.29.0 at the time of writing; the latest link always resolves to the current release):

curl -L -o opentelemetry-javaagent.jar \
  https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

Then start your application with the agent attached and OTLP export configured through environment variables:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <BASE64_TOKEN>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"

java -javaagent:./opentelemetry-javaagent.jar -jar my-app.jar

That is the entire setup. The agent automatically:

  • Creates spans for inbound and outbound HTTP calls, database queries (JDBC), messaging operations, and hundreds of other library touchpoints, with W3C traceparent context propagation across service boundaries.
  • Collects JVM runtime metrics (memory pools, GC, threads, CPU) and library-level metrics such as HTTP request duration histograms.
  • Bridges your existing Logback/Log4j/JUL log statements into the OTLP logs pipeline, with trace and span IDs attached so logs correlate to traces.

Since agent version 2.x, http/protobuf is the default OTLP protocol, but setting OTEL_EXPORTER_OTLP_PROTOCOL explicitly makes the configuration self-documenting and protects you against older agent versions that defaulted to gRPC.

If you cannot modify the launch command, you can also set the flag via JAVA_TOOL_OPTIONS:

export JAVA_TOOL_OPTIONS="-javaagent:/path/to/opentelemetry-javaagent.jar"

Even when the agent covers your libraries, you will often want custom spans and metrics for business logic. You can add those with the plain OpenTelemetry API (artifact io.opentelemetry:opentelemetry-api) while the agent supplies the SDK — GlobalOpenTelemetry.get() returns the agent-managed instance. The sections below show the same APIs in the context of a fully manual SDK setup.

Manual instrumentation with the SDK

Use the manual SDK when you need explicit control over exporters, processors, and sampling, or when attaching an agent is not an option. Dependency versions are managed with opentelemetry-bom so all OpenTelemetry artifacts stay in lockstep.

Maven:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.opentelemetry</groupId>
      <artifactId>opentelemetry-bom</artifactId>
      <version>1.63.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
  </dependency>
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-sdk</artifactId>
  </dependency>
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
  </dependency>
</dependencies>

Gradle (Kotlin DSL):

dependencies {
  implementation(platform("io.opentelemetry:opentelemetry-bom:1.63.0"))
  implementation("io.opentelemetry:opentelemetry-api")
  implementation("io.opentelemetry:opentelemetry-sdk")
  implementation("io.opentelemetry:opentelemetry-exporter-otlp")
}

Initializing the SDK

Build one OpenTelemetrySdk at application startup and close it at shutdown. This example wires all three signals to OpenObserve over OTLP/HTTP. Endpoint and credentials are read from the environment so the same build runs against local and cloud instances.

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.context.propagation.ContextPropagators;
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.logs.SdkLoggerProvider;
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;

import java.time.Duration;

public final class Telemetry {

  // e.g. http://localhost:5080/api/default or https://api.openobserve.ai/api/<your-org>
  private static final String BASE = System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT");
  private static final String AUTH = System.getenv("O2_AUTHORIZATION"); // "Basic <BASE64_TOKEN>"

  public static OpenTelemetrySdk initialize() {
    Resource resource = Resource.getDefault().toBuilder()
        .put("service.name", "checkout-service")
        .put("service.version", "1.4.2")
        .put("deployment.environment", "production")
        .build();

    SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
        .setResource(resource)
        .addSpanProcessor(BatchSpanProcessor.builder(
            OtlpHttpSpanExporter.builder()
                .setEndpoint(BASE + "/v1/traces")
                .addHeader("Authorization", AUTH)
                .build()).build())
        .build();

    SdkMeterProvider meterProvider = SdkMeterProvider.builder()
        .setResource(resource)
        .registerMetricReader(PeriodicMetricReader.builder(
            OtlpHttpMetricExporter.builder()
                .setEndpoint(BASE + "/v1/metrics")
                .addHeader("Authorization", AUTH)
                .build())
            .setInterval(Duration.ofSeconds(30))
            .build())
        .build();

    SdkLoggerProvider loggerProvider = SdkLoggerProvider.builder()
        .setResource(resource)
        .addLogRecordProcessor(BatchLogRecordProcessor.builder(
            OtlpHttpLogRecordExporter.builder()
                .setEndpoint(BASE + "/v1/logs")
                .addHeader("Authorization", AUTH)
                .build()).build())
        .build();

    OpenTelemetrySdk sdk = OpenTelemetrySdk.builder()
        .setTracerProvider(tracerProvider)
        .setMeterProvider(meterProvider)
        .setLoggerProvider(loggerProvider)
        .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
        .build();

    Runtime.getRuntime().addShutdownHook(new Thread(sdk::close));
    return sdk;
  }
}

Prefer environment-driven configuration? Add io.opentelemetry:opentelemetry-sdk-extension-autoconfigure and replace the builder code with a single call — the SDK then honors every OTEL_* variable shown later in this guide:

import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk;

OpenTelemetrySdk sdk = AutoConfiguredOpenTelemetrySdk.initialize().getOpenTelemetrySdk();

Traces

Get a Tracer, start spans, and always end them. makeCurrent() puts the span in scope so child spans and log statements pick up its context.

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

public class OrderService {

  private final Tracer tracer;

  public OrderService(OpenTelemetry openTelemetry) {
    this.tracer = openTelemetry.getTracer("com.example.checkout", "1.4.2");
  }

  public void processOrder(String orderId) {
    Span span = tracer.spanBuilder("process-order")
        .setSpanKind(SpanKind.INTERNAL)
        .setAttribute("order.id", orderId)
        .startSpan();

    try (Scope ignored = span.makeCurrent()) {
      chargePayment(orderId);      // child spans created here are parented automatically
      span.addEvent("order.charged");
    } catch (Exception e) {
      span.recordException(e);
      span.setStatus(StatusCode.ERROR, "order processing failed");
      throw e;
    } finally {
      span.end();
    }
  }

  private void chargePayment(String orderId) {
    Span span = tracer.spanBuilder("charge-payment").startSpan();
    try (Scope ignored = span.makeCurrent()) {
      // call payment provider
    } finally {
      span.end();
    }
  }
}

Context propagation across threads

Java’s biggest tracing pitfall is losing context on thread handoffs. OpenTelemetry context lives in a ThreadLocal, so work submitted to an executor does not automatically inherit the current span. Wrap executors once and child spans stay parented correctly:

import io.opentelemetry.context.Context;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ExecutorService executor =
    Context.taskWrapping(Executors.newFixedThreadPool(8));

// Tasks submitted here run with the caller's context;
// spans created inside are children of the active span.
executor.submit(() -> chargePayment(orderId));

The Java agent instruments common executor types automatically; with the manual SDK, Context.taskWrapping(...) (or Context.current().wrap(runnable) for one-off tasks) is your responsibility. If you see spans appearing as separate root traces instead of children, a thread handoff dropped the context somewhere.

Metrics

Instruments are created once from a Meter and reused for every measurement — never create instruments per request. The PeriodicMetricReader configured earlier exports them every 30 seconds.

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;

public class CheckoutMetrics {

  private static final AttributeKey<String> PAYMENT_METHOD =
      AttributeKey.stringKey("payment.method");

  private final LongCounter ordersProcessed;
  private final DoubleHistogram orderValue;

  public CheckoutMetrics(OpenTelemetry openTelemetry) {
    Meter meter = openTelemetry.getMeter("com.example.checkout");

    ordersProcessed = meter.counterBuilder("orders.processed")
        .setDescription("Number of orders processed")
        .setUnit("{order}")
        .build();

    orderValue = meter.histogramBuilder("order.value")
        .setDescription("Order value distribution")
        .setUnit("USD")
        .build();

    // Async gauge example: observe a value on each collection cycle
    meter.gaugeBuilder("checkout.queue.depth")
        .setDescription("Pending orders in the checkout queue")
        .buildWithCallback(m -> m.record(getQueueDepth()));
  }

  public void recordOrder(double amountUsd, String paymentMethod) {
    Attributes attrs = Attributes.of(PAYMENT_METHOD, paymentMethod);
    ordersProcessed.add(1, attrs);
    orderValue.record(amountUsd, attrs);
  }

  private static double getQueueDepth() {
    return 0; // wire to your queue
  }
}

Logs

The Logs API in Java is a bridge: application code keeps logging through Logback, Log4j, or java.util.logging, and an appender routes those records into the OpenTelemetry pipeline. With the manual SDK, add the appender for your framework — for Logback:

<dependency>
  <groupId>io.opentelemetry.instrumentation</groupId>
  <artifactId>opentelemetry-logback-appender-1.0</artifactId>
  <version>2.29.0-alpha</version>
</dependency>

Register it in logback.xml alongside your existing appenders:

<configuration>
  <appender name="OTEL"
      class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender"/>
  <root level="INFO">
    <appender-ref ref="OTEL"/>
  </root>
</configuration>

Then connect the appender to your SDK instance once at startup:

import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender;

OpenTelemetrySdk sdk = Telemetry.initialize();
OpenTelemetryAppender.install(sdk);

// Existing log statements now flow to OpenObserve, correlated with the active span:
org.slf4j.LoggerFactory.getLogger(OrderService.class)
    .info("order processed order_id={}", orderId);

Because log records are emitted inside the span’s scope, they carry the trace_id and span_id, so you can pivot between a trace and its logs in OpenObserve. (If you run the Java agent instead of the manual SDK, this bridge is installed for you automatically.)

Sending data to OpenObserve

OpenObserve ingests OTLP natively — no collector is required in between, although you can add one for fan-out or preprocessing (see A Beginner’s Guide to OTLP Exporters). The HTTP endpoints follow one pattern per organization:

POST /api/<org_name>/v1/traces
POST /api/<org_name>/v1/metrics
POST /api/<org_name>/v1/logs

The OTLP/HTTP exporter appends the /v1/<signal> suffix itself, so OTEL_EXPORTER_OTLP_ENDPOINT should end at the organization segment — no trailing slash.

OpenObserve Cloud:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <BASE64_TOKEN>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"

Self-hosted OpenObserve:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <BASE64_TOKEN>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"

These variables are honored identically by the Java agent and by the autoconfigured SDK. If your tooling rejects the space inside the header value, percent-encode it as Authorization=Basic%20<BASE64_TOKEN> — the SDK decodes it per the OpenTelemetry specification.

A few details worth knowing:

  • Credentials: the token is base64(email:password). In OpenObserve Cloud, copy the ready-made value from the Ingestion page rather than encoding your login password.
  • Per-signal overrides: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and friends take a full URL including the /v1/traces path, unlike the base variable. Only reach for these when signals go to different backends.
  • gRPC: OpenObserve also accepts OTLP/gRPC on port 5081 (configurable via ZO_GRPC_PORT). With gRPC, the endpoint carries no URL path, so you must additionally send an organization: <org_name> header. For most Java deployments, http/protobuf on port 5080 is simpler and firewall-friendly.

Verifying data arrival in OpenObserve

Generate some traffic against your application, wait a few seconds for the batch exporters to flush, then check the OpenObserve UI:

  1. Traces — open the Traces page, pick your organization, and filter by service name (checkout-service in the examples above). You should see traces with their span trees; click one to inspect span attributes, events, and durations.
  2. Logs — open Logs and select the stream your OTLP logs landed in (default unless you set a stream-name header). Log records emitted inside a span carry trace_id, so you can search for a specific trace’s logs.
  3. Metrics — open Metrics (or Streams to see the raw streams) and query the instruments you created, such as orders_processed. JVM runtime metrics from the agent appear alongside your custom instruments.
  4. Streams — the Streams page lists every ingested stream with record counts and sizes; it is the quickest way to confirm that something arrived even before you build queries.

If nothing appears after a minute, jump to the troubleshooting section below — the cause is almost always the endpoint path or the auth header.

Production tips

  • Keep batching on. BatchSpanProcessor and BatchLogRecordProcessor (the defaults with the agent and in the code above) buffer and compress export traffic. Never ship SimpleSpanProcessor to production — it exports synchronously on every span end and will throttle request threads.

  • Sample deliberately. At high traffic, head sampling controls cost while keeping traces statistically representative. The recommended production setting respects upstream decisions and samples a fixed ratio of new traces:

    export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
    export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of traces

    parentbased_* matters in Java service meshes: it ensures a downstream service never drops spans belonging to a trace an upstream service decided to keep.

  • Set resource attributes everywhere. service.name, service.version, and deployment.environment are what you filter by in OpenObserve. An unset service name shows up as unknown_service:java, which is useless at 3 a.m. Set them via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES so they are consistent across agent-based and SDK-based services.

  • Shut down cleanly. The SDK exports on a schedule; a JVM that exits without calling OpenTelemetrySdk.close() (or letting the agent’s shutdown hook run) silently drops the final batch. This bites short-lived jobs and CLI tools hardest — for those, close the SDK explicitly before main returns.

  • Don’t double-instrument. Running the Java agent and a manually configured SDK that both export the same signals produces duplicate spans and double-counted metrics. Pick one owner of the pipeline; with the agent, application code should only use opentelemetry-api.

  • Watch agent startup cost, not runtime cost. The agent adds a few seconds of JVM startup time for bytecode weaving; steady-state overhead is low. If startup latency matters (serverless, fast-scaling pods), measure it, and consider trimming instrumentation with OTEL_INSTRUMENTATION_<NAME>_ENABLED=false for libraries you don’t need.

  • Pin versions in CI. Download a specific agent release (e.g. v2.29.0) in your build pipeline rather than latest, and upgrade intentionally. Keep opentelemetry-bom and the instrumentation artifacts on compatible versions.

Troubleshooting

HTTP 401 responses in exporter logs → The Basic auth header is wrong. Rebuild the token with echo -n 'email:password' | base64 — the -n matters, a trailing newline corrupts the encoding. Verify the header reads Authorization=Basic <token> with exactly one space in the decoded header value, and that the credentials belong to the right organization.

HTTP 404 responses → The endpoint path is malformed. OTEL_EXPORTER_OTLP_ENDPOINT must end at /api/<org_name> with no trailing slash; the exporter appends /v1/traces itself. A trailing slash or a missing org segment produces a path OpenObserve doesn’t serve. If you set per-signal variables like OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, those must instead contain the full path including /v1/traces.

No errors, but nothing arrives → The exporter is probably speaking gRPC to an HTTP port. Older agent versions and the bare SDK default to gRPC on port 4317. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and use port 5080 (HTTP), or switch the endpoint to port 5081 and add the organization header if you genuinely want gRPC.

Traces arrive, logs don’t (manual SDK) → No log bridge is installed. The Logs API does not intercept Logback/Log4j by itself — add the opentelemetry-logback-appender-1.0 (or Log4j equivalent) dependency, register the appender in your logging config, and call OpenTelemetryAppender.install(sdk) after SDK initialization.

Short-lived process exports nothing → The JVM exited before the batch processors flushed. Call sdk.close() (or tracerProvider.forceFlush()) before exit; the shutdown hook in the initialization example above handles this for normal termination but not Runtime.halt() or kill -9.

Service shows as unknown_service:javaOTEL_SERVICE_NAME was not visible to the JVM. Confirm the variable is exported in the same environment the JVM starts in (systemd units, container specs, and CI runners each have their own environment), or set it as a system property: -Dotel.service.name=checkout-service.

Agent seems attached but produces no telemetry → Check the JVM stderr for the agent banner at startup (opentelemetry-javaagent). If it’s absent, the -javaagent flag came after -jar (JVM flags must precede it) or the path is wrong. If the banner is present, enable debug output with -Dotel.javaagent.debug=true to see exporter activity and errors.

Frequently asked questions

Do I need to change my Java code to use OpenTelemetry?

No. The OpenTelemetry Java agent instruments your application at startup via the -javaagent JVM flag, automatically capturing traces, metrics, and logs from hundreds of popular libraries with zero code changes. You only write code against the OpenTelemetry API when you want custom spans, metrics, or attributes on top of what the agent collects.

Are traces, metrics, and logs all stable in OpenTelemetry Java?

Yes. OpenTelemetry Java is one of the most mature OpenTelemetry implementations, and all three signals — traces, metrics, and logs — are marked stable and suitable for production use.

Should I use the Java agent or the manual SDK?

Start with the Java agent. It covers HTTP servers and clients, JDBC, messaging, and most common frameworks automatically. Use the manual SDK when you need full control over the telemetry pipeline, are building a library, or run in an environment where attaching a JVM agent is not possible. You can also combine both: run the agent and add custom spans through the OpenTelemetry API.

Which OTLP protocol should I use to send data to OpenObserve?

OTLP over HTTP with protobuf encoding (http/protobuf) is the simplest option and is the default for the OpenTelemetry Java agent 2.x. Point OTEL_EXPORTER_OTLP_ENDPOINT at your OpenObserve organization base URL and the exporter appends /v1/traces, /v1/metrics, and /v1/logs automatically. OpenObserve also accepts OTLP over gRPC on port 5081 if you prefer gRPC.

How do I authenticate OTLP requests to OpenObserve?

OpenObserve uses HTTP Basic authentication. Base64-encode your user email and password (or an ingestion token) as email:password and send it in the Authorization header. With the OpenTelemetry SDK or agent, set OTEL_EXPORTER_OTLP_HEADERS to Authorization=Basic followed by the encoded value.

Why do my logs not show up in OpenObserve even though traces work?

The OpenTelemetry Logs API in Java is a bridge, not a replacement for your logging framework. If you use the manual SDK, you must install a log appender bridge such as the OpenTelemetry Logback or Log4j appender so that your existing log statements flow into the OpenTelemetry pipeline. The Java agent installs these bridges automatically.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo