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

Source: https://openobserve.ai/opentelemetry/java/
Published: 2026-07-08
Language: Java

---

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](https://openobserve.ai/) over OTLP. If you are new to OpenTelemetry itself, start with [What is OpenTelemetry? A Complete Guide](/blog/what-is-opentelemetry/).

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

| Signal  | Status | Notes |
|---------|--------|-------|
| Traces  | Stable | API and SDK stable; automatic context propagation across threads and libraries |
| Metrics | Stable | Counter, gauge, histogram instruments; JVM runtime metrics available out of the box with the agent |
| Logs    | Stable | Logs 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](https://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](https://openobserve.ai/docs/quickstart/).
- **A Basic auth token** for OpenObserve. Generate it from your credentials:

```bash
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):

```bash
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:

```bash
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`:

```bash
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:**

```xml
<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):**

```kotlin
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.

```java
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:

```java
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.

```java
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:

```java
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.

```java
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:

```xml
<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:

```xml
<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:

```java
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](/blog/otel-exporters-introduction/)). The HTTP endpoints follow one pattern per organization:

```text
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:**

```bash
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:**

```bash
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:

  ```bash
  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:java`** → `OTEL_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.
