This page gathers common patterns: instrumenting a business method, exposing an application metric, propagating baggage across an outbound call, tuning sampling, configuring an OTLP exporter to a Collector. All examples assume a runtime auto-configured through HumboldtAutoConfigure.configure() (cf. Getting started).

Instrument a method with @WithSpan

The standard OpenTelemetry annotation @WithSpan (from opentelemetry-instrumentation-annotations) is intercepted by humboldt-cdi through HumboldtBuildCompatibleExtension (CDI 4.1 Lite, Vauban-compatible).

import io.opentelemetry.instrumentation.annotations.WithSpan;
import io.opentelemetry.api.trace.SpanKind;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class PaymentService {

    @WithSpan(value = "payment.charge", kind = SpanKind.INTERNAL)
    public Receipt charge(Order order) {
        // span opened before entry, status ERROR + recordException on throw
        return processor.process(order);
    }
}

Behavior of WithSpanInterceptor:

  • Annotation resolution: method overrides class.

  • Default name (@WithSpan without value): Class.method.

  • Tracer resolved via GlobalOpenTelemetry.get() (overridable for tests).

  • On exception: span.recordException(ex) + span.setStatus(ERROR) then re-throw.

  • Priority: Interceptor.Priority.APPLICATION + 1.

CDI injection: Tracer, Meter, Logger

With humboldt-cdi active, OTel types are injectable through standard @Inject. Producers provided by HumboldtTelemetryProducers:

import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.metrics.LongCounter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class CheckoutMetrics {

    private final LongCounter checkouts;

    @Inject
    public CheckoutMetrics(Meter meter) {
        this.checkouts = meter
            .counterBuilder("checkout.total")
            .setDescription("Number of processed checkouts")
            .setUnit("1")
            .build();
    }

    public void recordSuccess() {
        checkouts.add(1);
    }
}

Manual instrumentation of a child span

When @WithSpan is not enough (fine granularity, dynamic span):

Tracer tracer = GlobalOpenTelemetry.getTracer("my.application");
Span parent = Span.current();

Span child = tracer.spanBuilder("db.query")
    .setSpanKind(SpanKind.CLIENT)
    .setAttribute("db.system", "postgresql")
    .setAttribute("db.statement", "SELECT * FROM orders WHERE id = ?")
    .startSpan();

try (Scope ignored = child.makeCurrent()) {
    return executeQuery();
} catch (SQLException ex) {
    child.recordException(ex);
    child.setStatus(StatusCode.ERROR, ex.getMessage());
    throw ex;
} finally {
    child.end();
}

Automatic HTTP propagation (inbound)

With humboldt-rest active, every JAX-RS endpoint is automatically instrumented. Behavior of HumboldtServerRequestFilter (@Provider):

  1. Reads headers through TextMapGetter<ContainerRequestContext>.

  2. Extracts the span context via W3CPropagators.textMap().

  3. Starts a SERVER span with parent = extracted span, name = {method} {path}.

  4. OTel attributes: http.request.method, url.path (normalized /), url.scheme.

  5. Stores Span + Scope via ContainerRequestContext.setProperty().

HumboldtServerResponseFilter closes the span: http.response.status_code, status ERROR if ≥ 500, and records the http.server.request.duration histogram — with an error.type attribute (the status code as a string) whenever the status is ≥ 400 — then Scope.close() and span.end() in finally.

No application code is required: it is enough to have humboldt-rest on the classpath and Cassini as the JAX-RS runtime.

Manual HTTP propagation (outbound)

For an outbound HTTP call with a client other than cassini-client:

HttpRequest.Builder builder = HttpRequest.newBuilder(uri);

GlobalOpenTelemetry.getPropagators()
    .getTextMapPropagator()
    .inject(Context.current(), builder, (b, key, value) -> b.header(key, value));

HttpRequest request = builder.build();
HttpResponse<String> resp = client.send(request, BodyHandlers.ofString());

The inject() adds traceparent, tracestate and baggage to the headers.

Baggage — propagated application values

Baggage carries key/value pairs (user, tenant, feature flag) across service boundaries.

import io.opentelemetry.api.baggage.Baggage;

Baggage baggage = Baggage.builder()
    .put("tenant.id", "acme")
    .put("feature.new_checkout", "true")
    .build();

try (Scope ignored = baggage.makeCurrent()) {
    callService(); // outbound HTTP headers will carry baggage: tenant.id=acme,...
}

Reading on the server side: Baggage.current().getEntryValue("tenant.id").

Application metrics

Three families of OTel instruments are supported by humboldt-sdk-metric:

Meter meter = GlobalOpenTelemetry.getMeter("my.application");

// Counter — monotonic, sum
LongCounter requests = meter.counterBuilder("http.server.requests")
    .setUnit("1").build();

// Histogram — distribution (latencies, sizes)
DoubleHistogram latency = meter.histogramBuilder("http.server.duration")
    .setUnit("ms").build();

// Async gauge — value polled at every collection
meter.gaugeBuilder("queue.size")
    .ofLongs()
    .buildWithCallback(measurement -> measurement.record(queue.size()));

requests.add(1, Attributes.of(
    AttributeKey.stringKey("http.method"), "GET",
    AttributeKey.longKey("http.status_code"), 200L));
latency.record(durationMs);

PeriodicMetricReader collects every 60 s (configurable via OTEL_METRIC_EXPORT_INTERVAL).

OTel logs

humboldt-sdk-log exposes SdkLoggerProvider. The JUL bridge (HumboldtJulHandler, shipped in M5b) is installed automatically on the root JUL logger by HumboldtAutoConfigure whenever the log pipeline is active, so java.util.logging records flow to the OTel pipeline out of the box. There is no SLF4J bridge. Direct emission is also possible:

io.opentelemetry.api.logs.Logger logger =
    GlobalOpenTelemetry.getLogsBridge().get("my.application");

logger.logRecordBuilder()
    .setSeverity(Severity.INFO)
    .setSeverityText("INFO")
    .setBody("checkout completed")
    .setAttribute(AttributeKey.stringKey("order.id"), order.id())
    .emit();

LogRecordBuilder automatically captures the current Span (correlation trace_id ↔ span_id).

Sampling — tuning throughput

Samplers are configured through env vars:

# Trace everything (dev)
export OTEL_TRACES_SAMPLER=always_on

# Deterministic 5 % sample (typical production)
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.05

# Drop all traces (perf incident, isolation debug)
export OTEL_TRACES_SAMPLER=always_off

ParentBased is recommended in production: follow the upstream service’s decision when present (per-trace consistency), otherwise apply the ratio.

OTLP exporter — endpoint and headers

# Common endpoint (suffix /v1/traces, /v1/metrics, /v1/logs added automatically)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.observability:4318

# Per-signal override
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://traces.example/v1/traces

# Auth (API key, basic, etc.)
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer abcdef123,x-tenant=acme"

The humboldt-exporter-otlp-http exporter uses java.net.http.HttpClient with a virtual-thread executor, bounded exponential retry (100ms × 2^attempt, capped at 5 s) on 5xx and network errors, up to 3 retries by default (setMaxRetries). The payload is always OTLP/HTTP JSON — there is no protobuf encoding.

Batching and flush

BatchSpanProcessor (virtual-thread worker) accumulates spans and exports them in batches:

  • Bounded queue — drops spans on overflow (reported through a System.Logger WARNING).

  • Trigger: batch threshold reached, scheduled delay elapsed (scheduleDelay), or explicit flush()/shutdown().

// Synchronous flush before shutdown (e.g. application shutdown hook)
sdk.flush().join(10, TimeUnit.SECONDS);

Tests: in-memory exporter

For unit tests without a Collector:

export OTEL_SDK_DISABLED=false
export OTEL_TRACES_EXPORTER=in-memory
export OTEL_METRICS_EXPORTER=in-memory
export OTEL_LOGS_EXPORTER=in-memory
try (AutoConfiguredHumboldt sdk = HumboldtAutoConfigure.configure()) {
    GlobalOpenTelemetry.set(sdk);
    runCodeUnderTest();
    sdk.flush().join(5, TimeUnit.SECONDS);

    List<SpanData> spans = sdk.inMemorySpanExporter().getFinishedSpanItems();
    assertThat(spans).extracting(SpanData::getName).contains("payment.charge");
}

Next: Internals.