ROLL_VALUE = AttributeKey.longKey("roll.value");
+
+ // One Attributes object per die face, constructed once and reused across all recordings.
+ // With unbound instruments each call would look these up on every add().
+ private static final Attributes ROLL_1 = Attributes.of(ROLL_VALUE, 1L);
+ private static final Attributes ROLL_2 = Attributes.of(ROLL_VALUE, 2L);
+ private static final Attributes ROLL_3 = Attributes.of(ROLL_VALUE, 3L);
+ private static final Attributes ROLL_4 = Attributes.of(ROLL_VALUE, 4L);
+ private static final Attributes ROLL_5 = Attributes.of(ROLL_VALUE, 5L);
+ private static final Attributes ROLL_6 = Attributes.of(ROLL_VALUE, 6L);
+
+ @Test
+ void rollTheDice() {
+ InMemoryMetricReader reader = InMemoryMetricReader.create();
+ try (SdkMeterProvider meterProvider =
+ SdkMeterProvider.builder().registerMetricReader(reader).build()) {
+
+ Meter meter = meterProvider.get("io.opentelemetry.example.dice");
+
+ // The bind() API lives on the incubating ExtendedLongCounter, reached by casting the
+ // instrument returned from the (stable) builder.
+ ExtendedLongCounter rolls =
+ (ExtendedLongCounter)
+ meter
+ .counterBuilder("dice.rolls")
+ .setDescription("The number of times each side of the die was rolled")
+ .setUnit("{roll}")
+ .build();
+
+ // Bind one BoundLongCounter per die face. Each bind() call resolves the underlying timeseries
+ // once, so subsequent add() calls record directly without any attribute lookup.
+ //
+ // Equivalent unbound setup (no bind calls needed, but per-recording overhead is higher):
+ // // no setup — just call rolls.add(1, ROLL_N) inline below
+ BoundLongCounter face1 = rolls.bind(ROLL_1);
+ BoundLongCounter face2 = rolls.bind(ROLL_2);
+ BoundLongCounter face3 = rolls.bind(ROLL_3);
+ BoundLongCounter face4 = rolls.bind(ROLL_4);
+ BoundLongCounter face5 = rolls.bind(ROLL_5);
+ BoundLongCounter face6 = rolls.bind(ROLL_6);
+
+ // Simulate 600 rolls with a fixed seed for a reproducible distribution.
+ Random random = new Random(42);
+ long[] counts = new long[7]; // indexed 1..6; index 0 unused
+
+ for (int i = 0; i < 600; i++) {
+ int result = random.nextInt(6) + 1;
+ counts[result]++;
+ switch (result) {
+ case 1:
+ face1.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_1);
+ break;
+ case 2:
+ face2.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_2);
+ break;
+ case 3:
+ face3.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_3);
+ break;
+ case 4:
+ face4.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_4);
+ break;
+ case 5:
+ face5.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_5);
+ break;
+ case 6:
+ face6.add(1);
+ // Equivalent unbound: rolls.add(1, ROLL_6);
+ break;
+ default:
+ break;
+ }
+ }
+
+ // A bound counter and the unbound instrument it came from record to the same timeseries; the
+ // two styles are interchangeable and can be mixed.
+ LongCounter unbound = rolls;
+ unbound.add(0, ROLL_1);
+
+ // One cumulative data point per die face, each with the exact roll count recorded above.
+ assertThat(reader.collectAllMetrics())
+ .satisfiesExactly(
+ metric ->
+ assertThat(metric)
+ .hasName("dice.rolls")
+ .hasDescription("The number of times each side of the die was rolled")
+ .hasUnit("{roll}")
+ .hasLongSumSatisfying(
+ sum ->
+ sum.isMonotonic()
+ .hasPointsSatisfying(
+ point -> point.hasAttributes(ROLL_1).hasValue(counts[1]),
+ point -> point.hasAttributes(ROLL_2).hasValue(counts[2]),
+ point -> point.hasAttributes(ROLL_3).hasValue(counts[3]),
+ point -> point.hasAttributes(ROLL_4).hasValue(counts[4]),
+ point -> point.hasAttributes(ROLL_5).hasValue(counts[5]),
+ point -> point.hasAttributes(ROLL_6).hasValue(counts[6]))));
+ }
+ }
+}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
index eede83d2432..9ec03672b86 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundLongCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounterBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedLongCounterBuilder;
@@ -20,6 +22,13 @@ private ExtendedSdkLongCounter(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundLongCounter bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkLongCounterBuilder extends SdkLongCounterBuilder
implements ExtendedLongCounterBuilder {
From db3110e7a31a99dc6b725a4609530d08f04bc5af Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:16:49 -0500
Subject: [PATCH 02/12] Add other bound instrument interfaces
---
.../incubator/metrics/BoundDoubleCounter.java | 42 ++++++++
.../incubator/metrics/BoundDoubleGauge.java | 42 ++++++++
.../metrics/BoundDoubleHistogram.java | 42 ++++++++
.../metrics/BoundDoubleUpDownCounter.java | 42 ++++++++
.../api/incubator/metrics/BoundLongGauge.java | 42 ++++++++
.../incubator/metrics/BoundLongHistogram.java | 42 ++++++++
.../metrics/BoundLongUpDownCounter.java | 42 ++++++++
.../metrics/ExtendedDefaultMeter.java | 98 +++++++++++++++++++
.../metrics/ExtendedDoubleCounter.java | 12 +++
.../metrics/ExtendedDoubleGauge.java | 12 +++
.../metrics/ExtendedDoubleHistogram.java | 12 +++
.../metrics/ExtendedDoubleUpDownCounter.java | 12 +++
.../metrics/ExtendedLongCounter.java | 2 +
.../incubator/metrics/ExtendedLongGauge.java | 11 +++
.../metrics/ExtendedLongHistogram.java | 12 +++
.../metrics/ExtendedLongUpDownCounter.java | 11 +++
.../sdk/metrics/ExtendedSdkDoubleCounter.java | 9 ++
.../sdk/metrics/ExtendedSdkDoubleGauge.java | 9 ++
.../metrics/ExtendedSdkDoubleHistogram.java | 9 ++
.../ExtendedSdkDoubleUpDownCounter.java | 9 ++
.../sdk/metrics/ExtendedSdkLongGauge.java | 9 ++
.../sdk/metrics/ExtendedSdkLongHistogram.java | 9 ++
.../metrics/ExtendedSdkLongUpDownCounter.java | 9 ++
23 files changed, 539 insertions(+)
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleCounter.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleGauge.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleHistogram.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleUpDownCounter.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongGauge.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongHistogram.java
create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongUpDownCounter.java
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleCounter.java
new file mode 100644
index 00000000000..624285e9758
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleCounter.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleCounter;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link DoubleCounter} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedDoubleCounter#bind(Attributes)}.
+ *
+ * Binding resolves the underlying timeseries once, so subsequent {@link #add(double)} calls
+ * record directly without the per-recording attribute processing and map lookup that {@link
+ * DoubleCounter#add(double, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundDoubleCounter {
+
+ /**
+ * Records a value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The increment amount. MUST be non-negative.
+ */
+ void add(double value);
+
+ /**
+ * Records a value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The increment amount. MUST be non-negative.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void add(double value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleGauge.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleGauge.java
new file mode 100644
index 00000000000..b2c1d20c142
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleGauge.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleGauge;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link DoubleGauge} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedDoubleGauge#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #set(double)} calls
+ * record directly without the per-recording attribute processing and map lookup that {@link
+ * DoubleGauge#set(double, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundDoubleGauge {
+
+ /**
+ * Records the gauge value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The current gauge value.
+ */
+ void set(double value);
+
+ /**
+ * Records the gauge value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The current gauge value.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void set(double value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleHistogram.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleHistogram.java
new file mode 100644
index 00000000000..ae3d0d24b1d
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleHistogram.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleHistogram;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link DoubleHistogram} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedDoubleHistogram#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #record(double)} calls
+ * record directly without the per-recording attribute processing and map lookup that {@link
+ * DoubleHistogram#record(double, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundDoubleHistogram {
+
+ /**
+ * Records a value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The amount of the measurement. MUST be non-negative.
+ */
+ void record(double value);
+
+ /**
+ * Records a value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The amount of the measurement. MUST be non-negative.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void record(double value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleUpDownCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleUpDownCounter.java
new file mode 100644
index 00000000000..44ac044a300
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundDoubleUpDownCounter.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleUpDownCounter;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link DoubleUpDownCounter} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedDoubleUpDownCounter#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #add(double)} calls
+ * record directly without the per-recording attribute processing and map lookup that {@link
+ * DoubleUpDownCounter#add(double, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundDoubleUpDownCounter {
+
+ /**
+ * Records a value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The increment amount. May be positive, negative or zero.
+ */
+ void add(double value);
+
+ /**
+ * Records a value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The increment amount. May be positive, negative or zero.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void add(double value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongGauge.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongGauge.java
new file mode 100644
index 00000000000..6e8fab07339
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongGauge.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.LongGauge;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link LongGauge} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedLongGauge#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #set(long)} calls record
+ * directly without the per-recording attribute processing and map lookup that {@link
+ * LongGauge#set(long, Attributes)} performs. This is useful when the set of attribute combinations
+ * is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundLongGauge {
+
+ /**
+ * Records the gauge value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The current gauge value.
+ */
+ void set(long value);
+
+ /**
+ * Records the gauge value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The current gauge value.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void set(long value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongHistogram.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongHistogram.java
new file mode 100644
index 00000000000..63a77a0bde7
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongHistogram.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.LongHistogram;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link LongHistogram} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedLongHistogram#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #record(long)} calls
+ * record directly without the per-recording attribute processing and map lookup that {@link
+ * LongHistogram#record(long, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundLongHistogram {
+
+ /**
+ * Records a value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The amount of the measurement. MUST be non-negative.
+ */
+ void record(long value);
+
+ /**
+ * Records a value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The amount of the measurement. MUST be non-negative.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void record(long value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongUpDownCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongUpDownCounter.java
new file mode 100644
index 00000000000..85c2e5fbbb6
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongUpDownCounter.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.metrics;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.LongUpDownCounter;
+import io.opentelemetry.context.Context;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link LongUpDownCounter} bound to a fixed set of {@link Attributes}, obtained via {@link
+ * ExtendedLongUpDownCounter#bind(Attributes)}.
+ *
+ *
Binding resolves the underlying timeseries once, so subsequent {@link #add(long)} calls record
+ * directly without the per-recording attribute processing and map lookup that {@link
+ * LongUpDownCounter#add(long, Attributes)} performs. This is useful when the set of attribute
+ * combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+@ThreadSafe
+public interface BoundLongUpDownCounter {
+
+ /**
+ * Records a value against the bound attributes.
+ *
+ *
Note: This may use {@code Context.current()} to pull the context associated with this
+ * measurement.
+ *
+ * @param value The increment amount. May be positive, negative or zero.
+ */
+ void add(long value);
+
+ /**
+ * Records a value against the bound attributes, with an explicit {@link Context}.
+ *
+ * @param value The increment amount. May be positive, negative or zero.
+ * @param context The explicit context to associate with this measurement.
+ */
+ void add(long value, Context context);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDefaultMeter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDefaultMeter.java
index c8da4835924..e8ef921b202 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDefaultMeter.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDefaultMeter.java
@@ -123,6 +123,69 @@ public void add(long value) {}
public void add(long value, Context context) {}
};
+ private static final BoundDoubleCounter NOOP_BOUND_DOUBLE_COUNTER =
+ new BoundDoubleCounter() {
+ @Override
+ public void add(double value) {}
+
+ @Override
+ public void add(double value, Context context) {}
+ };
+
+ private static final BoundLongUpDownCounter NOOP_BOUND_LONG_UP_DOWN_COUNTER =
+ new BoundLongUpDownCounter() {
+ @Override
+ public void add(long value) {}
+
+ @Override
+ public void add(long value, Context context) {}
+ };
+
+ private static final BoundDoubleUpDownCounter NOOP_BOUND_DOUBLE_UP_DOWN_COUNTER =
+ new BoundDoubleUpDownCounter() {
+ @Override
+ public void add(double value) {}
+
+ @Override
+ public void add(double value, Context context) {}
+ };
+
+ private static final BoundLongHistogram NOOP_BOUND_LONG_HISTOGRAM =
+ new BoundLongHistogram() {
+ @Override
+ public void record(long value) {}
+
+ @Override
+ public void record(long value, Context context) {}
+ };
+
+ private static final BoundDoubleHistogram NOOP_BOUND_DOUBLE_HISTOGRAM =
+ new BoundDoubleHistogram() {
+ @Override
+ public void record(double value) {}
+
+ @Override
+ public void record(double value, Context context) {}
+ };
+
+ private static final BoundLongGauge NOOP_BOUND_LONG_GAUGE =
+ new BoundLongGauge() {
+ @Override
+ public void set(long value) {}
+
+ @Override
+ public void set(long value, Context context) {}
+ };
+
+ private static final BoundDoubleGauge NOOP_BOUND_DOUBLE_GAUGE =
+ new BoundDoubleGauge() {
+ @Override
+ public void set(double value) {}
+
+ @Override
+ public void set(double value, Context context) {}
+ };
+
private static class NoopDoubleCounter implements ExtendedDoubleCounter {
@Override
public boolean isEnabled() {
@@ -137,6 +200,11 @@ public void add(double value, Attributes attributes) {}
@Override
public void add(double value) {}
+
+ @Override
+ public BoundDoubleCounter bind(Attributes attributes) {
+ return NOOP_BOUND_DOUBLE_COUNTER;
+ }
}
private static class NoopLongCounterBuilder implements ExtendedLongCounterBuilder {
@@ -223,6 +291,11 @@ public void add(long value, Attributes attributes) {}
@Override
public void add(long value) {}
+
+ @Override
+ public BoundLongUpDownCounter bind(Attributes attributes) {
+ return NOOP_BOUND_LONG_UP_DOWN_COUNTER;
+ }
}
private static class NoopDoubleUpDownCounter implements ExtendedDoubleUpDownCounter {
@@ -239,6 +312,11 @@ public void add(double value, Attributes attributes) {}
@Override
public void add(double value) {}
+
+ @Override
+ public BoundDoubleUpDownCounter bind(Attributes attributes) {
+ return NOOP_BOUND_DOUBLE_UP_DOWN_COUNTER;
+ }
}
private static class NoopLongUpDownCounterBuilder implements ExtendedLongUpDownCounterBuilder {
@@ -328,6 +406,11 @@ public void record(double value, Attributes attributes) {}
@Override
public void record(double value) {}
+
+ @Override
+ public BoundDoubleHistogram bind(Attributes attributes) {
+ return NOOP_BOUND_DOUBLE_HISTOGRAM;
+ }
}
private static class NoopLongHistogram implements ExtendedLongHistogram {
@@ -344,6 +427,11 @@ public void record(long value, Attributes attributes) {}
@Override
public void record(long value) {}
+
+ @Override
+ public BoundLongHistogram bind(Attributes attributes) {
+ return NOOP_BOUND_LONG_HISTOGRAM;
+ }
}
private static class NoopDoubleHistogramBuilder implements ExtendedDoubleHistogramBuilder {
@@ -442,6 +530,11 @@ public void set(double value, Attributes attributes) {}
@Override
public void set(double value, Attributes attributes, Context context) {}
+
+ @Override
+ public BoundDoubleGauge bind(Attributes attributes) {
+ return NOOP_BOUND_DOUBLE_GAUGE;
+ }
}
private static class NoopLongGaugeBuilder implements ExtendedLongGaugeBuilder {
@@ -488,6 +581,11 @@ public void set(long value, Attributes attributes) {}
@Override
public void set(long value, Attributes attributes, Context context) {}
+
+ @Override
+ public BoundLongGauge bind(Attributes attributes) {
+ return NOOP_BOUND_LONG_GAUGE;
+ }
}
private static class NoopObservableDoubleMeasurement implements ObservableDoubleMeasurement {
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleCounter.java
index 60043af4df8..7853163c737 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleCounter.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleCounter.java
@@ -5,10 +5,22 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleCounter;
/** Extended {@link DoubleCounter} with experimental APIs. */
public interface ExtendedDoubleCounter extends DoubleCounter {
+ /**
+ * Binds this counter to the given {@code attributes}, returning a {@link BoundDoubleCounter} that
+ * records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #add(double, Attributes)}. Prefer this when the
+ * set of attribute combinations is known ahead of time and the same series is recorded to
+ * repeatedly.
+ */
+ BoundDoubleCounter bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleGauge.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleGauge.java
index 7297f6af3be..17fe751a3dc 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleGauge.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleGauge.java
@@ -5,10 +5,22 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleGauge;
/** Extended {@link DoubleGauge} with experimental APIs. */
public interface ExtendedDoubleGauge extends DoubleGauge {
+ /**
+ * Binds this gauge to the given {@code attributes}, returning a {@link BoundDoubleGauge} that
+ * records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #set(double, Attributes)}. Prefer this when the
+ * set of attribute combinations is known ahead of time and the same series is recorded to
+ * repeatedly.
+ */
+ BoundDoubleGauge bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleHistogram.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleHistogram.java
index 391d829a73f..b717a835970 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleHistogram.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleHistogram.java
@@ -5,10 +5,22 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
/** Extended {@link DoubleHistogram} with experimental APIs. */
public interface ExtendedDoubleHistogram extends DoubleHistogram {
+ /**
+ * Binds this histogram to the given {@code attributes}, returning a {@link BoundDoubleHistogram}
+ * that records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #record(double, Attributes)}. Prefer this when
+ * the set of attribute combinations is known ahead of time and the same series is recorded to
+ * repeatedly.
+ */
+ BoundDoubleHistogram bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleUpDownCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleUpDownCounter.java
index 0337e76483d..4c024d9aca2 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleUpDownCounter.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedDoubleUpDownCounter.java
@@ -5,10 +5,22 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleUpDownCounter;
/** Extended {@link DoubleUpDownCounter} with experimental APIs. */
public interface ExtendedDoubleUpDownCounter extends DoubleUpDownCounter {
+ /**
+ * Binds this counter to the given {@code attributes}, returning a {@link
+ * BoundDoubleUpDownCounter} that records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #add(double, Attributes)}. Prefer this when the
+ * set of attribute combinations is known ahead of time and the same series is recorded to
+ * repeatedly.
+ */
+ BoundDoubleUpDownCounter bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongCounter.java
index bfbc0be78df..c4fb903795c 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongCounter.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongCounter.java
@@ -21,4 +21,6 @@ public interface ExtendedLongCounter extends LongCounter {
* of attribute combinations is known ahead of time and the same series is recorded to repeatedly.
*/
BoundLongCounter bind(Attributes attributes);
+
+ // keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongGauge.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongGauge.java
index e2f29146e08..f283477bdd7 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongGauge.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongGauge.java
@@ -5,10 +5,21 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongGauge;
/** Extended {@link LongGauge} with experimental APIs. */
public interface ExtendedLongGauge extends LongGauge {
+ /**
+ * Binds this gauge to the given {@code attributes}, returning a {@link BoundLongGauge} that
+ * records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #set(long, Attributes)}. Prefer this when the set
+ * of attribute combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+ BoundLongGauge bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongHistogram.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongHistogram.java
index 888f0b68b31..4c932126099 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongHistogram.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongHistogram.java
@@ -5,10 +5,22 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongHistogram;
/** Extended {@link LongHistogram} with experimental APIs. */
public interface ExtendedLongHistogram extends LongHistogram {
+ /**
+ * Binds this histogram to the given {@code attributes}, returning a {@link BoundLongHistogram}
+ * that records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #record(long, Attributes)}. Prefer this when the
+ * set of attribute combinations is known ahead of time and the same series is recorded to
+ * repeatedly.
+ */
+ BoundLongHistogram bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongUpDownCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongUpDownCounter.java
index 24552d6c5b7..666c9f9b07f 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongUpDownCounter.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/ExtendedLongUpDownCounter.java
@@ -5,10 +5,21 @@
package io.opentelemetry.api.incubator.metrics;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongUpDownCounter;
/** Extended {@link LongUpDownCounter} with experimental APIs. */
public interface ExtendedLongUpDownCounter extends LongUpDownCounter {
+ /**
+ * Binds this counter to the given {@code attributes}, returning a {@link BoundLongUpDownCounter}
+ * that records to the corresponding timeseries directly.
+ *
+ *
Binding resolves the underlying timeseries once, eliminating the per-recording attribute
+ * processing and map lookup performed by {@link #add(long, Attributes)}. Prefer this when the set
+ * of attribute combinations is known ahead of time and the same series is recorded to repeatedly.
+ */
+ BoundLongUpDownCounter bind(Attributes attributes);
+
// keep this class even if it is empty, since experimental methods may be added in the future.
}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
index 5aefcb96d25..eb022d21805 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounterBuilder;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
@@ -20,6 +22,13 @@ private ExtendedSdkDoubleCounter(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundDoubleCounter bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkDoubleCounterBuilder extends SdkDoubleCounterBuilder
implements ExtendedDoubleCounterBuilder {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
index f65153f4a0b..59ec6c97342 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGaugeBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGaugeBuilder;
@@ -20,6 +22,13 @@ private ExtendedSdkDoubleGauge(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundDoubleGauge bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkDoubleGaugeBuilder extends SdkDoubleGaugeBuilder
implements ExtendedDoubleGaugeBuilder {
ExtendedSdkDoubleGaugeBuilder(SdkMeter sdkMeter, String name) {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
index 198b4160043..27b04dd90b6 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogramBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogramBuilder;
@@ -21,6 +23,13 @@ final class ExtendedSdkDoubleHistogram extends SdkDoubleHistogram
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundDoubleHistogram bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkDoubleHistogramBuilder extends SdkDoubleHistogramBuilder
implements ExtendedDoubleHistogramBuilder {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
index aa991c2fcdd..3597ab2eda8 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounterBuilder;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
@@ -21,6 +23,13 @@ private ExtendedSdkDoubleUpDownCounter(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundDoubleUpDownCounter bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkDoubleUpDownCounterBuilder extends SdkDoubleUpDownCounterBuilder
implements ExtendedDoubleUpDownCounterBuilder {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
index 970046d814b..0d087b2f4bb 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundLongGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGaugeBuilder;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
@@ -20,6 +22,13 @@ private ExtendedSdkLongGauge(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundLongGauge bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkLongGaugeBuilder extends SdkLongGaugeBuilder
implements ExtendedLongGaugeBuilder {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
index 051f089184b..d02521729c5 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundLongHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogramBuilder;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
@@ -20,6 +22,13 @@ private ExtendedSdkLongHistogram(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundLongHistogram bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkLongHistogramBuilder extends SdkLongHistogramBuilder
implements ExtendedLongHistogramBuilder {
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
index 15b6f96790e..f1648419027 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.metrics;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundLongUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounterBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounterBuilder;
@@ -21,6 +23,13 @@ private ExtendedSdkLongUpDownCounter(
super(descriptor, sdkMeter, storage);
}
+ @Override
+ public BoundLongUpDownCounter bind(Attributes attributes) {
+ // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
+ // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
+ throw new UnsupportedOperationException("bind is not yet implemented");
+ }
+
static final class ExtendedSdkLongUpDownCounterBuilder extends SdkLongUpDownCounterBuilder
implements ExtendedLongUpDownCounterBuilder {
From 6502e3bebde851679bb30295916b2d7937ce60b6 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:45:19 -0500
Subject: [PATCH 03/12] Move / extend SynchronousInstrumentStressTest
---
.../SynchronousInstrumentStressTest.java | 194 +++++++++++++++---
1 file changed, 171 insertions(+), 23 deletions(-)
rename sdk/metrics/src/{test => testIncubating}/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java (78%)
diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
similarity index 78%
rename from sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
rename to sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
index 62d4cfa1e75..ce135d4d7f1 100644
--- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
+++ b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
@@ -18,6 +18,22 @@
import com.google.common.util.concurrent.Uninterruptibles;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleGauge;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleHistogram;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.BoundLongCounter;
+import io.opentelemetry.api.incubator.metrics.BoundLongGauge;
+import io.opentelemetry.api.incubator.metrics.BoundLongHistogram;
+import io.opentelemetry.api.incubator.metrics.BoundLongUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGauge;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogram;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongGauge;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogram;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.internal.testing.CleanupExtension;
import io.opentelemetry.sdk.common.export.MemoryMode;
@@ -44,7 +60,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.LongStream;
@@ -56,10 +74,14 @@
/**
* {@link #stressTest(AggregationTemporality, InstrumentType, Aggregation, MemoryMode,
- * InstrumentValueType)} performs a stress test to confirm simultaneous record and collections do
- * not have concurrency issues like lost writes, partial writes, duplicate writes, etc. All
- * combinations of the following dimensions are tested: aggregation temporality, instrument type
- * (synchronous), memory mode, instrument value type.
+ * InstrumentValueType, boolean)} performs a stress test to confirm simultaneous record and
+ * collections do not have concurrency issues like lost writes, partial writes, duplicate writes,
+ * etc. All combinations of the following dimensions are tested: aggregation temporality, instrument
+ * type (synchronous), memory mode, instrument value type, and whether recording goes through bound
+ * instruments ({@code Extended*#bind(Attributes)}) or unbound instruments.
+ *
+ *
Lives in {@code testIncubating} because the bound dimension exercises {@code
+ * opentelemetry-api-incubator}, which the base {@code test} source set is not allowed to depend on.
*/
class SynchronousInstrumentStressTest {
@@ -88,10 +110,16 @@ void stressTest(
InstrumentType instrumentType,
Aggregation aggregation,
MemoryMode memoryMode,
- InstrumentValueType instrumentValueType) {
+ InstrumentValueType instrumentValueType,
+ boolean bound) {
for (int repetition = 0; repetition < STRESS_TEST_REPETITIONS; repetition++) {
stressTestOnce(
- aggregationTemporality, instrumentType, aggregation, memoryMode, instrumentValueType);
+ aggregationTemporality,
+ instrumentType,
+ aggregation,
+ memoryMode,
+ instrumentValueType,
+ bound);
}
}
@@ -101,7 +129,8 @@ private void stressTestOnce(
InstrumentType instrumentType,
Aggregation aggregation,
MemoryMode memoryMode,
- InstrumentValueType instrumentValueType) {
+ InstrumentValueType instrumentValueType,
+ boolean bound) {
// Initialize metric SDK
DefaultAggregationSelector aggregationSelector =
DefaultAggregationSelector.getDefault().with(instrumentType, aggregation);
@@ -117,7 +146,8 @@ private void stressTestOnce(
Meter meter = meterProvider.get("test");
List attributes = Arrays.asList(ATTR_1, ATTR_2, ATTR_3, ATTR_4);
Collections.shuffle(attributes);
- Instrument instrument = getInstrument(meter, instrumentType, instrumentValueType);
+ Instrument instrument =
+ getInstrument(meter, instrumentType, instrumentValueType, bound, attributes);
// Define list of measurements to record
// Later, we'll assert that the data collected matches these measurements, with no lost writes,
@@ -298,20 +328,24 @@ private static Stream stressTestArgs() {
InstrumentTypeAndAggregation.values()) {
for (MemoryMode memoryMode : MemoryMode.values()) {
for (InstrumentValueType instrumentValueType : InstrumentValueType.values()) {
- argumentsList.add(
- Arguments.argumentSet(
- aggregationTemporality
- + " "
- + instrumentTypeAndAggregation.instrumentType
- + " "
- + memoryMode
- + " "
- + instrumentValueType,
- aggregationTemporality,
- instrumentTypeAndAggregation.instrumentType,
- instrumentTypeAndAggregation.aggregation,
- memoryMode,
- instrumentValueType));
+ for (boolean bound : new boolean[] {false, true}) {
+ argumentsList.add(
+ Arguments.argumentSet(
+ aggregationTemporality
+ + " "
+ + instrumentTypeAndAggregation.instrumentType
+ + " "
+ + memoryMode
+ + " "
+ + instrumentValueType
+ + (bound ? " bound" : " unbound"),
+ aggregationTemporality,
+ instrumentTypeAndAggregation.instrumentType,
+ instrumentTypeAndAggregation.aggregation,
+ memoryMode,
+ instrumentValueType,
+ bound));
+ }
}
}
}
@@ -320,7 +354,19 @@ private static Stream stressTestArgs() {
}
private static Instrument getInstrument(
- Meter meter, InstrumentType instrumentType, InstrumentValueType instrumentValueType) {
+ Meter meter,
+ InstrumentType instrumentType,
+ InstrumentValueType instrumentValueType,
+ boolean bound,
+ List attributesList) {
+ if (bound) {
+ // Bind one bound instrument per attribute set up front, then dispatch records by attributes.
+ // The bound record path is the one under test; the per-record map lookup here is just test
+ // plumbing to reuse the unbound recording loop.
+ Map boundInstruments =
+ bindInstruments(meter, instrumentType, instrumentValueType, attributesList);
+ return (value, attributes) -> boundInstruments.get(attributes).record(value);
+ }
switch (instrumentType) {
case COUNTER:
return instrumentValueType == InstrumentValueType.DOUBLE
@@ -354,10 +400,112 @@ private static Instrument getInstrument(
throw new IllegalArgumentException();
}
+ /**
+ * Builds the instrument for the given type / value type, then binds one {@link BoundInstrument}
+ * per attribute set in {@code attributesList}.
+ */
+ private static Map bindInstruments(
+ Meter meter,
+ InstrumentType instrumentType,
+ InstrumentValueType instrumentValueType,
+ List attributesList) {
+ Map result = new HashMap<>();
+ boolean isDouble = instrumentValueType == InstrumentValueType.DOUBLE;
+ switch (instrumentType) {
+ case COUNTER:
+ if (isDouble) {
+ ExtendedDoubleCounter instrument =
+ (ExtendedDoubleCounter) meter.counterBuilder(INSTRUMENT_NAME).ofDoubles().build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleCounter bound = instrument.bind(attributes);
+ result.put(attributes, bound::add);
+ }
+ } else {
+ ExtendedLongCounter instrument =
+ (ExtendedLongCounter) meter.counterBuilder(INSTRUMENT_NAME).build();
+ for (Attributes attributes : attributesList) {
+ BoundLongCounter bound = instrument.bind(attributes);
+ result.put(attributes, bound::add);
+ }
+ }
+ return result;
+ case UP_DOWN_COUNTER:
+ if (isDouble) {
+ ExtendedDoubleUpDownCounter instrument =
+ (ExtendedDoubleUpDownCounter)
+ meter.upDownCounterBuilder(INSTRUMENT_NAME).ofDoubles().build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleUpDownCounter bound = instrument.bind(attributes);
+ result.put(attributes, bound::add);
+ }
+ } else {
+ ExtendedLongUpDownCounter instrument =
+ (ExtendedLongUpDownCounter) meter.upDownCounterBuilder(INSTRUMENT_NAME).build();
+ for (Attributes attributes : attributesList) {
+ BoundLongUpDownCounter bound = instrument.bind(attributes);
+ result.put(attributes, bound::add);
+ }
+ }
+ return result;
+ case HISTOGRAM:
+ if (isDouble) {
+ ExtendedDoubleHistogram instrument =
+ (ExtendedDoubleHistogram)
+ meter
+ .histogramBuilder(INSTRUMENT_NAME)
+ .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
+ .build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleHistogram bound = instrument.bind(attributes);
+ result.put(attributes, bound::record);
+ }
+ } else {
+ ExtendedLongHistogram instrument =
+ (ExtendedLongHistogram)
+ meter
+ .histogramBuilder(INSTRUMENT_NAME)
+ .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
+ .ofLongs()
+ .build();
+ for (Attributes attributes : attributesList) {
+ BoundLongHistogram bound = instrument.bind(attributes);
+ result.put(attributes, bound::record);
+ }
+ }
+ return result;
+ case GAUGE:
+ if (isDouble) {
+ ExtendedDoubleGauge instrument =
+ (ExtendedDoubleGauge) meter.gaugeBuilder(INSTRUMENT_NAME).build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleGauge bound = instrument.bind(attributes);
+ result.put(attributes, bound::set);
+ }
+ } else {
+ ExtendedLongGauge instrument =
+ (ExtendedLongGauge) meter.gaugeBuilder(INSTRUMENT_NAME).ofLongs().build();
+ for (Attributes attributes : attributesList) {
+ BoundLongGauge bound = instrument.bind(attributes);
+ result.put(attributes, bound::set);
+ }
+ }
+ return result;
+ case OBSERVABLE_COUNTER:
+ case OBSERVABLE_UP_DOWN_COUNTER:
+ case OBSERVABLE_GAUGE:
+ }
+ throw new IllegalArgumentException();
+ }
+
private interface Instrument {
void record(long value, Attributes attributes);
}
+ @FunctionalInterface
+ private interface BoundInstrument {
+ void record(long value);
+ }
+
private static MetricData copy(MetricData m) {
switch (m.getType()) {
case LONG_GAUGE:
From 71d75b2cf89492de8d201dfc1b32670ca64a48b3 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Mon, 22 Jun 2026 16:27:01 -0500
Subject: [PATCH 04/12] initial impl complete
---
.../sdk/MetricRecordBenchmark.java | 166 +++++++++--
.../sdk/metrics/ExtendedSdkDoubleCounter.java | 42 ++-
.../sdk/metrics/ExtendedSdkDoubleGauge.java | 38 ++-
.../metrics/ExtendedSdkDoubleHistogram.java | 41 ++-
.../ExtendedSdkDoubleUpDownCounter.java | 38 ++-
.../sdk/metrics/ExtendedSdkLongCounter.java | 41 ++-
.../sdk/metrics/ExtendedSdkLongGauge.java | 37 ++-
.../sdk/metrics/ExtendedSdkLongHistogram.java | 42 ++-
.../metrics/ExtendedSdkLongUpDownCounter.java | 38 ++-
.../sdk/metrics/SdkDoubleCounter.java | 23 +-
.../sdk/metrics/SdkDoubleHistogram.java | 23 +-
.../sdk/metrics/SdkLongCounter.java | 23 +-
.../sdk/metrics/SdkLongHistogram.java | 23 +-
.../opentelemetry/sdk/metrics/SdkMeter.java | 32 +++
.../internal/state/BoundStorageHandle.java | 28 ++
.../CumulativeSynchronousMetricStorage.java | 36 +++
.../DefaultSynchronousMetricStorage.java | 23 +-
.../state/DeltaSynchronousMetricStorage.java | 257 +++++++++++++++++-
.../internal/state/EmptyMetricStorage.java | 14 +
.../state/WriteableMetricStorage.java | 7 +
.../state/MetricStorageRegistryTest.java | 11 +
.../SynchronousInstrumentStressTest.java | 175 +++++-------
22 files changed, 959 insertions(+), 199 deletions(-)
create mode 100644 sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index d3e8d50f435..2a753e60087 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -12,6 +12,22 @@
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleGauge;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleHistogram;
+import io.opentelemetry.api.incubator.metrics.BoundDoubleUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.BoundLongCounter;
+import io.opentelemetry.api.incubator.metrics.BoundLongGauge;
+import io.opentelemetry.api.incubator.metrics.BoundLongHistogram;
+import io.opentelemetry.api.incubator.metrics.BoundLongUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGauge;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogram;
+import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongCounter;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongGauge;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogram;
+import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
@@ -52,6 +68,8 @@
* {@link Aggregation}, including all relevant combinations for synchronous instruments.
* {@link BenchmarkState#aggregationTemporality}
* {@link BenchmarkState#cardinality}
+ * {@link BenchmarkState#bound} whether recording goes through bound instruments ({@code
+ * Extended*#bind(Attributes)}) or unbound instruments.
* thread count
* {@link BenchmarkState#instrumentValueType}, {@link BenchmarkState#memoryMode}, and {@link
* BenchmarkState#exemplars} are disabled to reduce combinatorial explosion.
@@ -62,16 +80,16 @@
*
* The cardinality and thread count dimensions partially overlap. Cardinality dictates how many
* unique attribute sets (i.e. series) are recorded to, and thread count dictates how many threads
- * are simultaneously recording to those series. In all cases, the record path needs to look up an
- * aggregation handle for the series corresponding to the measurement's {@link Attributes} in a
- * {@link java.util.concurrent.ConcurrentHashMap}. That will be the case until otel adds support for
- * bound
- * instruments. The cardinality dictates the size of this map, which has some impact on
- * performance. However, by far the dominant bottleneck is contention. That is, the number of
- * threads simultaneously trying to record to the same series. Increasing the threads increases
- * contention. Increasing cardinality decreases contention, as the threads are now spreading their
- * record activities over more distinct series. The highest contention scenario is cardinality=1,
- * threads=4. Any scenario with threads=1 has zero contention.
+ * are simultaneously recording to those series. For unbound instruments, the record path looks up
+ * an aggregation handle for the series corresponding to the measurement's {@link Attributes} in a
+ * {@link java.util.concurrent.ConcurrentHashMap}; for bound instruments ({@link
+ * BenchmarkState#bound}) that handle is resolved once at bind time, so the record path skips the
+ * lookup and attribute processing entirely. The cardinality dictates the size of this map, which
+ * has some impact on performance. However, by far the dominant bottleneck is contention. That is,
+ * the number of threads simultaneously trying to record to the same series. Increasing the threads
+ * increases contention. Increasing cardinality decreases contention, as the threads are now
+ * spreading their record activities over more distinct series. The highest contention scenario is
+ * cardinality=1, threads=4. Any scenario with threads=1 has zero contention.
*
*
It's useful to characterize the performance of the metrics system under contention, as some
* high-performance applications may have many threads trying to record to the same series. It's
@@ -99,6 +117,12 @@ public static class BenchmarkState {
@Param({"1", "128"})
int cardinality;
+ // Whether to record through bound instruments (Extended*#bind(Attributes)), which resolve the
+ // timeseries once up front, or unbound instruments, which look up the timeseries by Attributes
+ // on every record.
+ @Param({"false", "true"})
+ boolean bound;
+
// The following parameters are excluded from the benchmark to reduce combinatorial explosion
// but can optionally be enabled for adhoc evaluation.
@@ -120,7 +144,10 @@ public static class BenchmarkState {
boolean exemplars = false;
OpenTelemetrySdk openTelemetry;
+ // Populated when bound == false.
private Instrument instrument;
+ // Populated when bound == true; parallel to attributesList (one bound instrument per series).
+ private List boundInstruments;
List measurements;
List attributesList;
Span span;
@@ -151,7 +178,6 @@ public void setup() {
.build();
Meter meter = openTelemetry.getMeter("benchmark");
- instrument = getInstrument(meter, instrumentType, instrumentValueType);
Tracer tracer = openTelemetry.getTracer("benchmark");
span = tracer.spanBuilder("benchmark").startSpan();
// We suppress warnings on closing here, as we rely on tests to make sure context is closed.
@@ -169,6 +195,13 @@ public void setup() {
}
Collections.shuffle(attributesList);
+ if (bound) {
+ boundInstruments =
+ bindInstruments(meter, instrumentType, instrumentValueType, attributesList);
+ } else {
+ instrument = getInstrument(meter, instrumentType, instrumentValueType);
+ }
+
measurements = new ArrayList<>(RECORDS_PER_INVOCATION);
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
measurements.add((long) random.nextInt(2000));
@@ -207,11 +240,21 @@ public void record_MultipleThreads(BenchmarkState benchmarkState) {
}
private static void record(BenchmarkState benchmarkState) {
- for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
- Attributes attributes =
- benchmarkState.attributesList.get(i % benchmarkState.attributesList.size());
- long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
- benchmarkState.instrument.record(value, attributes);
+ if (benchmarkState.bound) {
+ // Bound: record straight to the pre-resolved bound instrument for the series, indexed in
+ // lockstep with attributesList — no per-record Attributes lookup.
+ List boundInstruments = benchmarkState.boundInstruments;
+ for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
+ long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
+ boundInstruments.get(i % boundInstruments.size()).record(value);
+ }
+ } else {
+ for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
+ Attributes attributes =
+ benchmarkState.attributesList.get(i % benchmarkState.attributesList.size());
+ long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
+ benchmarkState.instrument.record(value, attributes);
+ }
}
}
@@ -262,4 +305,95 @@ private static Instrument getInstrument(
}
throw new IllegalArgumentException();
}
+
+ @FunctionalInterface
+ private interface BoundInstrument {
+ void record(long value);
+ }
+
+ /**
+ * Builds the instrument, then binds one {@link BoundInstrument} per series in {@code
+ * attributesList}, returned in the same order so the record loop can index it in lockstep.
+ */
+ private static List bindInstruments(
+ Meter meter,
+ InstrumentType instrumentType,
+ InstrumentValueType instrumentValueType,
+ List attributesList) {
+ boolean isDouble = instrumentValueType == InstrumentValueType.DOUBLE;
+ String name = "instrument";
+ List result = new ArrayList<>(attributesList.size());
+ switch (instrumentType) {
+ case COUNTER:
+ if (isDouble) {
+ ExtendedDoubleCounter instrument =
+ (ExtendedDoubleCounter) meter.counterBuilder(name).ofDoubles().build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleCounter bound = instrument.bind(attributes);
+ result.add(bound::add);
+ }
+ } else {
+ ExtendedLongCounter instrument = (ExtendedLongCounter) meter.counterBuilder(name).build();
+ for (Attributes attributes : attributesList) {
+ BoundLongCounter bound = instrument.bind(attributes);
+ result.add(bound::add);
+ }
+ }
+ return result;
+ case UP_DOWN_COUNTER:
+ if (isDouble) {
+ ExtendedDoubleUpDownCounter instrument =
+ (ExtendedDoubleUpDownCounter) meter.upDownCounterBuilder(name).ofDoubles().build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleUpDownCounter bound = instrument.bind(attributes);
+ result.add(bound::add);
+ }
+ } else {
+ ExtendedLongUpDownCounter instrument =
+ (ExtendedLongUpDownCounter) meter.upDownCounterBuilder(name).build();
+ for (Attributes attributes : attributesList) {
+ BoundLongUpDownCounter bound = instrument.bind(attributes);
+ result.add(bound::add);
+ }
+ }
+ return result;
+ case HISTOGRAM:
+ if (isDouble) {
+ ExtendedDoubleHistogram instrument =
+ (ExtendedDoubleHistogram) meter.histogramBuilder(name).build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleHistogram bound = instrument.bind(attributes);
+ result.add(bound::record);
+ }
+ } else {
+ ExtendedLongHistogram instrument =
+ (ExtendedLongHistogram) meter.histogramBuilder(name).ofLongs().build();
+ for (Attributes attributes : attributesList) {
+ BoundLongHistogram bound = instrument.bind(attributes);
+ result.add(bound::record);
+ }
+ }
+ return result;
+ case GAUGE:
+ if (isDouble) {
+ ExtendedDoubleGauge instrument = (ExtendedDoubleGauge) meter.gaugeBuilder(name).build();
+ for (Attributes attributes : attributesList) {
+ BoundDoubleGauge bound = instrument.bind(attributes);
+ result.add(bound::set);
+ }
+ } else {
+ ExtendedLongGauge instrument =
+ (ExtendedLongGauge) meter.gaugeBuilder(name).ofLongs().build();
+ for (Attributes attributes : attributesList) {
+ BoundLongGauge bound = instrument.bind(attributes);
+ result.add(bound::set);
+ }
+ }
+ return result;
+ case OBSERVABLE_COUNTER:
+ case OBSERVABLE_UP_DOWN_COUNTER:
+ case OBSERVABLE_GAUGE:
+ }
+ throw new IllegalArgumentException();
+ }
}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
index eb022d21805..90b4749cc99 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleCounter.java
@@ -10,23 +10,57 @@
import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounterBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
-final class ExtendedSdkDoubleCounter extends SdkDoubleCounter implements ExtendedDoubleCounter {
+final class ExtendedSdkDoubleCounter extends SdkDoubleCounter
+ implements ExtendedDoubleCounter, BoundDoubleCounter {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the add() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkDoubleCounter(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkDoubleCounter(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundDoubleCounter bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkDoubleCounter(
+ getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void add(double value) {
+ add(value, Context.current());
+ }
+
+ @Override
+ public void add(double value, Context context) {
+ if (!validateNonNegative(value)) {
+ return;
+ }
+ if (boundHandle != null) {
+ boundHandle.recordDouble(value, context);
+ } else {
+ storage.recordDouble(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkDoubleCounterBuilder extends SdkDoubleCounterBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
index 59ec6c97342..d82b3756475 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleGauge.java
@@ -11,22 +11,52 @@
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGaugeBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGaugeBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
-final class ExtendedSdkDoubleGauge extends SdkDoubleGauge implements ExtendedDoubleGauge {
+final class ExtendedSdkDoubleGauge extends SdkDoubleGauge
+ implements ExtendedDoubleGauge, BoundDoubleGauge {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the set() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkDoubleGauge(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkDoubleGauge(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundDoubleGauge bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkDoubleGauge(getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void set(double value) {
+ set(value, Context.current());
+ }
+
+ @Override
+ public void set(double value, Context context) {
+ if (boundHandle != null) {
+ boundHandle.recordDouble(value, context);
+ } else {
+ storage.recordDouble(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkDoubleGaugeBuilder extends SdkDoubleGaugeBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
index 27b04dd90b6..2a1286afbce 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleHistogram.java
@@ -11,23 +11,56 @@
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogramBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogramBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
final class ExtendedSdkDoubleHistogram extends SdkDoubleHistogram
- implements ExtendedDoubleHistogram {
+ implements ExtendedDoubleHistogram, BoundDoubleHistogram {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the record() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
ExtendedSdkDoubleHistogram(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkDoubleHistogram(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundDoubleHistogram bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkDoubleHistogram(
+ getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void record(double value) {
+ record(value, Context.current());
+ }
+
+ @Override
+ public void record(double value, Context context) {
+ if (!validateNonNegative(value)) {
+ return;
+ }
+ if (boundHandle != null) {
+ boundHandle.recordDouble(value, context);
+ } else {
+ storage.recordDouble(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkDoubleHistogramBuilder extends SdkDoubleHistogramBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
index 3597ab2eda8..b304eae6dc5 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkDoubleUpDownCounter.java
@@ -10,24 +10,54 @@
import io.opentelemetry.api.incubator.metrics.BoundDoubleUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounterBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
final class ExtendedSdkDoubleUpDownCounter extends SdkDoubleUpDownCounter
- implements ExtendedDoubleUpDownCounter {
+ implements ExtendedDoubleUpDownCounter, BoundDoubleUpDownCounter {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the add() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkDoubleUpDownCounter(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkDoubleUpDownCounter(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundDoubleUpDownCounter bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkDoubleUpDownCounter(
+ getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void add(double value) {
+ add(value, Context.current());
+ }
+
+ @Override
+ public void add(double value, Context context) {
+ if (boundHandle != null) {
+ boundHandle.recordDouble(value, context);
+ } else {
+ storage.recordDouble(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkDoubleUpDownCounterBuilder extends SdkDoubleUpDownCounterBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
index 9ec03672b86..84d8934a034 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongCounter.java
@@ -11,22 +11,55 @@
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounterBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedLongCounterBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
-final class ExtendedSdkLongCounter extends SdkLongCounter implements ExtendedLongCounter {
+final class ExtendedSdkLongCounter extends SdkLongCounter
+ implements ExtendedLongCounter, BoundLongCounter {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the add() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkLongCounter(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkLongCounter(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundLongCounter bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkLongCounter(getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void add(long value) {
+ add(value, Context.current());
+ }
+
+ @Override
+ public void add(long value, Context context) {
+ if (!validateNonNegative(value)) {
+ return;
+ }
+ if (boundHandle != null) {
+ boundHandle.recordLong(value, context);
+ } else {
+ storage.recordLong(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkLongCounterBuilder extends SdkLongCounterBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
index 0d087b2f4bb..4f97fe8e33f 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongGauge.java
@@ -10,23 +10,52 @@
import io.opentelemetry.api.incubator.metrics.BoundLongGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedLongGaugeBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
-final class ExtendedSdkLongGauge extends SdkLongGauge implements ExtendedLongGauge {
+final class ExtendedSdkLongGauge extends SdkLongGauge implements ExtendedLongGauge, BoundLongGauge {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the set() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkLongGauge(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkLongGauge(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundLongGauge bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkLongGauge(getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void set(long value) {
+ set(value, Context.current());
+ }
+
+ @Override
+ public void set(long value, Context context) {
+ if (boundHandle != null) {
+ boundHandle.recordLong(value, context);
+ } else {
+ storage.recordLong(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkLongGaugeBuilder extends SdkLongGaugeBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
index d02521729c5..c620f58356a 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongHistogram.java
@@ -10,23 +10,57 @@
import io.opentelemetry.api.incubator.metrics.BoundLongHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogram;
import io.opentelemetry.api.incubator.metrics.ExtendedLongHistogramBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.Advice;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
-final class ExtendedSdkLongHistogram extends SdkLongHistogram implements ExtendedLongHistogram {
+final class ExtendedSdkLongHistogram extends SdkLongHistogram
+ implements ExtendedLongHistogram, BoundLongHistogram {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the record() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkLongHistogram(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkLongHistogram(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundLongHistogram bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkLongHistogram(
+ getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void record(long value) {
+ record(value, Context.current());
+ }
+
+ @Override
+ public void record(long value, Context context) {
+ if (!validateNonNegative(value)) {
+ return;
+ }
+ if (boundHandle != null) {
+ boundHandle.recordLong(value, context);
+ } else {
+ storage.recordLong(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkLongHistogramBuilder extends SdkLongHistogramBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
index f1648419027..7abe4cf1416 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ExtendedSdkLongUpDownCounter.java
@@ -11,23 +11,53 @@
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleUpDownCounterBuilder;
import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedLongUpDownCounterBuilder;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.WriteableMetricStorage;
import java.util.List;
+import javax.annotation.Nullable;
final class ExtendedSdkLongUpDownCounter extends SdkLongUpDownCounter
- implements ExtendedLongUpDownCounter {
+ implements ExtendedLongUpDownCounter, BoundLongUpDownCounter {
+
+ // Non-null only when this is a bound instance returned from bind(); null for the instrument
+ // itself. When set, the add() methods record straight to this handle instead of resolving the
+ // series from the storage on each call.
+ @Nullable private final BoundStorageHandle boundHandle;
private ExtendedSdkLongUpDownCounter(
InstrumentDescriptor descriptor, SdkMeter sdkMeter, WriteableMetricStorage storage) {
+ this(descriptor, sdkMeter, storage, null);
+ }
+
+ private ExtendedSdkLongUpDownCounter(
+ InstrumentDescriptor descriptor,
+ SdkMeter sdkMeter,
+ WriteableMetricStorage storage,
+ @Nullable BoundStorageHandle boundHandle) {
super(descriptor, sdkMeter, storage);
+ this.boundHandle = boundHandle;
}
@Override
public BoundLongUpDownCounter bind(Attributes attributes) {
- // TODO(bound-instruments): implement against WriteableMetricStorage in the implementation phase
- // (includes fresh-eyes work on DeltaSynchronousMetricStorage).
- throw new UnsupportedOperationException("bind is not yet implemented");
+ return new ExtendedSdkLongUpDownCounter(
+ getDescriptor(), sdkMeter, storage, storage.bind(attributes));
+ }
+
+ @Override
+ public void add(long value) {
+ add(value, Context.current());
+ }
+
+ @Override
+ public void add(long value, Context context) {
+ if (boundHandle != null) {
+ boundHandle.recordLong(value, context);
+ } else {
+ storage.recordLong(value, Attributes.empty(), context);
+ }
}
static final class ExtendedSdkLongUpDownCounterBuilder extends SdkLongUpDownCounterBuilder
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleCounter.java
index d3ffd20f5c6..72764257d2a 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleCounter.java
@@ -40,12 +40,7 @@ public boolean isEnabled() {
@Override
public void add(double increment, Attributes attributes, Context context) {
- if (increment < 0) {
- throttlingLogger.log(
- Level.WARNING,
- "Counters can only increase. Instrument "
- + getDescriptor().getName()
- + " has recorded a negative value.");
+ if (!validateNonNegative(increment)) {
return;
}
storage.recordDouble(increment, attributes, context);
@@ -61,6 +56,22 @@ public void add(double increment) {
add(increment, Attributes.empty());
}
+ /**
+ * Returns true if {@code increment} is non-negative, otherwise logs a warning and returns false.
+ * Shared by the unbound and bound ({@link ExtendedSdkDoubleCounter}) record paths.
+ */
+ boolean validateNonNegative(double increment) {
+ if (increment < 0) {
+ throttlingLogger.log(
+ Level.WARNING,
+ "Counters can only increase. Instrument "
+ + getDescriptor().getName()
+ + " has recorded a negative value.");
+ return false;
+ }
+ return true;
+ }
+
static class SdkDoubleCounterBuilder implements DoubleCounterBuilder {
final InstrumentBuilder builder;
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleHistogram.java
index 56c1dd20bd9..a4ef7ebc224 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkDoubleHistogram.java
@@ -40,12 +40,7 @@ public boolean isEnabled() {
@Override
public void record(double value, Attributes attributes, Context context) {
- if (value < 0) {
- throttlingLogger.log(
- Level.WARNING,
- "Histograms can only record non-negative values. Instrument "
- + getDescriptor().getName()
- + " has recorded a negative value.");
+ if (!validateNonNegative(value)) {
return;
}
storage.recordDouble(value, attributes, context);
@@ -61,6 +56,22 @@ public void record(double value) {
record(value, Attributes.empty());
}
+ /**
+ * Returns true if {@code value} is non-negative, otherwise logs a warning and returns false.
+ * Shared by the unbound and bound ({@link ExtendedSdkDoubleHistogram}) record paths.
+ */
+ boolean validateNonNegative(double value) {
+ if (value < 0) {
+ throttlingLogger.log(
+ Level.WARNING,
+ "Histograms can only record non-negative values. Instrument "
+ + getDescriptor().getName()
+ + " has recorded a negative value.");
+ return false;
+ }
+ return true;
+ }
+
static class SdkDoubleHistogramBuilder implements DoubleHistogramBuilder {
final InstrumentBuilder builder;
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongCounter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongCounter.java
index d1834272c14..e03aa42b61c 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongCounter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongCounter.java
@@ -41,12 +41,7 @@ public boolean isEnabled() {
@Override
public void add(long increment, Attributes attributes, Context context) {
- if (increment < 0) {
- throttlingLogger.log(
- Level.WARNING,
- "Counters can only increase. Instrument "
- + getDescriptor().getName()
- + " has recorded a negative value.");
+ if (!validateNonNegative(increment)) {
return;
}
storage.recordLong(increment, attributes, context);
@@ -62,6 +57,22 @@ public void add(long increment) {
add(increment, Attributes.empty());
}
+ /**
+ * Returns true if {@code increment} is non-negative, otherwise logs a warning and returns false.
+ * Shared by the unbound and bound ({@link ExtendedSdkLongCounter}) record paths.
+ */
+ boolean validateNonNegative(long increment) {
+ if (increment < 0) {
+ throttlingLogger.log(
+ Level.WARNING,
+ "Counters can only increase. Instrument "
+ + getDescriptor().getName()
+ + " has recorded a negative value.");
+ return false;
+ }
+ return true;
+ }
+
static class SdkLongCounterBuilder implements LongCounterBuilder {
final InstrumentBuilder builder;
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongHistogram.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongHistogram.java
index 7ddcce06d8e..cb4835b571f 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongHistogram.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkLongHistogram.java
@@ -41,12 +41,7 @@ public boolean isEnabled() {
@Override
public void record(long value, Attributes attributes, Context context) {
- if (value < 0) {
- throttlingLogger.log(
- Level.WARNING,
- "Histograms can only record non-negative values. Instrument "
- + getDescriptor().getName()
- + " has recorded a negative value.");
+ if (!validateNonNegative(value)) {
return;
}
storage.recordLong(value, attributes, context);
@@ -62,6 +57,22 @@ public void record(long value) {
record(value, Attributes.empty());
}
+ /**
+ * Returns true if {@code value} is non-negative, otherwise logs a warning and returns false.
+ * Shared by the unbound and bound ({@link ExtendedSdkLongHistogram}) record paths.
+ */
+ boolean validateNonNegative(long value) {
+ if (value < 0) {
+ throttlingLogger.log(
+ Level.WARNING,
+ "Histograms can only record non-negative values. Instrument "
+ + getDescriptor().getName()
+ + " has recorded a negative value.");
+ return false;
+ }
+ return true;
+ }
+
static class SdkLongHistogramBuilder implements LongHistogramBuilder {
final InstrumentBuilder builder;
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java
index 913be6e7093..d3498408c75 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java
@@ -24,6 +24,7 @@
import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor;
import io.opentelemetry.sdk.metrics.internal.export.RegisteredReader;
import io.opentelemetry.sdk.metrics.internal.state.AsynchronousMetricStorage;
+import io.opentelemetry.sdk.metrics.internal.state.BoundStorageHandle;
import io.opentelemetry.sdk.metrics.internal.state.CallbackRegistration;
import io.opentelemetry.sdk.metrics.internal.state.MeterProviderSharedState;
import io.opentelemetry.sdk.metrics.internal.state.MetricStorage;
@@ -373,6 +374,15 @@ public void recordDouble(double value, Attributes attributes, Context context) {
}
}
+ @Override
+ public BoundStorageHandle bind(Attributes attributes) {
+ List handles = new ArrayList<>(storages.size());
+ for (WriteableMetricStorage storage : storages) {
+ handles.add(storage.bind(attributes));
+ }
+ return new MultiBoundStorageHandle(handles);
+ }
+
@Override
public boolean isEnabled() {
for (WriteableMetricStorage storage : storages) {
@@ -383,4 +393,26 @@ public boolean isEnabled() {
return false;
}
}
+
+ private static class MultiBoundStorageHandle implements BoundStorageHandle {
+ private final List handles;
+
+ private MultiBoundStorageHandle(List handles) {
+ this.handles = handles;
+ }
+
+ @Override
+ public void recordLong(long value, Context context) {
+ for (BoundStorageHandle handle : handles) {
+ handle.recordLong(value, context);
+ }
+ }
+
+ @Override
+ public void recordDouble(double value, Context context) {
+ for (BoundStorageHandle handle : handles) {
+ handle.recordDouble(value, context);
+ }
+ }
+ }
}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java
new file mode 100644
index 00000000000..18925109db7
--- /dev/null
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.metrics.internal.state;
+
+import io.opentelemetry.context.Context;
+
+/**
+ * A record target for a single timeseries, resolved once via {@link
+ * WriteableMetricStorage#bind(io.opentelemetry.api.common.Attributes)}.
+ *
+ * Records bypass the per-recording attribute processing and series lookup that {@link
+ * WriteableMetricStorage#recordLong} / {@link WriteableMetricStorage#recordDouble} perform, since
+ * the series is resolved at bind time. Backs bound instruments.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public interface BoundStorageHandle {
+
+ /** Records a long measurement against the bound series. */
+ void recordLong(long value, Context context);
+
+ /** Records a double measurement against the bound series. */
+ void recordDouble(double value, Context context);
+}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
index 026eae1069e..b323fcd2ebf 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
@@ -57,6 +57,42 @@ void doRecordDouble(double value, Attributes attributes, Context context) {
getAggregatorHandle(attributes, context).recordDouble(value, attributes, context);
}
+ @Override
+ public BoundStorageHandle bind(Attributes attributes) {
+ // Cumulative handles are stable for the instrument's lifetime (the map is never swapped and
+ // handles are never reset/pooled), so resolve the handle once here and record straight onto it.
+ AggregatorHandle handle = getAggregatorHandle(attributes, Context.current());
+ return new CumulativeBoundHandle(handle, attributes);
+ }
+
+ private final class CumulativeBoundHandle implements BoundStorageHandle {
+ private final AggregatorHandle handle;
+ // Original (unprocessed) attributes, passed to the handle for exemplar sampling, matching the
+ // unbound record path.
+ private final Attributes attributes;
+
+ CumulativeBoundHandle(AggregatorHandle handle, Attributes attributes) {
+ this.handle = handle;
+ this.attributes = attributes;
+ }
+
+ @Override
+ public void recordLong(long value, Context context) {
+ if (!recordingEnabled()) {
+ return;
+ }
+ handle.recordLong(value, attributes, context);
+ }
+
+ @Override
+ public void recordDouble(double value, Context context) {
+ if (!shouldRecordDouble(value, attributes)) {
+ return;
+ }
+ handle.recordDouble(value, attributes, context);
+ }
+ }
+
private AggregatorHandle getAggregatorHandle(Attributes attributes, Context context) {
Objects.requireNonNull(attributes, "attributes");
attributes = attributesProcessor.process(attributes, context);
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
index 6fd960d6bd4..f99eb7fc9b8 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
@@ -95,9 +95,26 @@ public void recordLong(long value, Attributes attributes, Context context) {
@Override
public void recordDouble(double value, Attributes attributes, Context context) {
- if (!enabled) {
+ if (!shouldRecordDouble(value, attributes)) {
return;
}
+ doRecordDouble(value, attributes, context);
+ }
+
+ /** Returns whether recording is currently enabled. Used by bound record paths. */
+ final boolean recordingEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Returns true if a double {@code value} should be recorded. Returns false (dropping the
+ * measurement) when recording is disabled, or when {@code value} is NaN, logging in the latter
+ * case. Shared by the unbound and bound record paths.
+ */
+ final boolean shouldRecordDouble(double value, Attributes attributes) {
+ if (!enabled) {
+ return false;
+ }
if (Double.isNaN(value)) {
logger.log(
Level.FINE,
@@ -106,9 +123,9 @@ public void recordDouble(double value, Attributes attributes, Context context) {
+ " has recorded measurement Not-a-Number (NaN) value with attributes "
+ attributes
+ ". Dropping measurement.");
- return;
+ return false;
}
- doRecordDouble(value, attributes, context);
+ return true;
}
abstract void doRecordLong(long value, Attributes attributes, Context context);
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
index 78403b565d6..3b0f88553b7 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
@@ -161,25 +161,120 @@ private DeltaAggregatorHandle tryAcquireHandleForRecord(
}
}
+ @Override
+ public BoundStorageHandle bind(Attributes attributes) {
+ // Resolve (or create) the wrapper for these attributes once and flag it bound. The bound
+ // instrument records straight onto the wrapper from then on, skipping attribute processing and
+ // the map lookup. The bound wrapper is carried across holder swaps and rotated (rather than
+ // pooled / reset-in-place) by collect(), so this reference stays valid for the instrument's
+ // lifetime.
+ Attributes processed = attributesProcessor.process(attributes, Context.current());
+ DeltaAggregatorHandle wrapper = bindHandle(processed);
+ return new DeltaBoundHandle(wrapper, attributes);
+ }
+
+ @SuppressWarnings("ThreadPriorityCheck")
+ private DeltaAggregatorHandle bindHandle(Attributes attributes) {
+ while (true) {
+ AggregatorHolder holder = this.aggregatorHolder;
+ // Always coordinate through the holder gate, even for an existing series: this serializes the
+ // bound flag write with collect()'s bound-handle scan (which runs while the gate is locked),
+ // guaranteeing the handle is carried into the new holder rather than abandoned/pooled.
+ if (!holder.tryAcquireForNewSeries()) {
+ // Holder is locked for collection. Retry; once the collector installs the new holder this
+ // loop re-reads it and succeeds.
+ Thread.yield();
+ continue;
+ }
+ try {
+ ConcurrentHashMap> aggregatorHandles =
+ holder.aggregatorHandles;
+ DeltaAggregatorHandle handle = aggregatorHandles.get(attributes);
+ if (handle == null && aggregatorHandles.size() >= maxCardinality) {
+ logger.log(
+ Level.WARNING,
+ "Instrument "
+ + metricDescriptor.getSourceInstrument().getName()
+ + " has exceeded the maximum allowed cardinality ("
+ + maxCardinality
+ + ").");
+ attributes = MetricStorage.CARDINALITY_OVERFLOW;
+ handle = aggregatorHandles.get(attributes);
+ }
+ if (handle == null) {
+ DeltaAggregatorHandle newDeltaHandle = aggregatorHandlePool.poll();
+ if (newDeltaHandle == null) {
+ newDeltaHandle = new DeltaAggregatorHandle<>(aggregator.createHandle(clock.now()));
+ }
+ DeltaAggregatorHandle existing =
+ aggregatorHandles.putIfAbsent(attributes, newDeltaHandle);
+ handle = existing != null ? existing : newDeltaHandle;
+ }
+ handle.bound = true;
+ return handle;
+ } finally {
+ holder.releaseNewSeries();
+ }
+ }
+ }
+
@Override
public MetricData collect(
Resource resource, InstrumentationScopeInfo instrumentationScopeInfo, long epochNanos) {
- ConcurrentHashMap> aggregatorHandles;
AggregatorHolder holder = this.aggregatorHolder;
- this.aggregatorHolder =
- (memoryMode == REUSABLE_DATA)
- ? new AggregatorHolder<>(previousCollectionAggregatorHandles)
- : new AggregatorHolder<>();
-
- // Lock out new series creation in the old holder and wait for any in-flight new-series
- // operations to complete. This guarantees the per-handle lock pass below sees every handle
- // that will ever be inserted into holder.aggregatorHandles.
+
+ // Lock out new series creation (and bind()) in the old holder and wait for any in-flight
+ // operations to complete. Done before scanning for bound handles and before installing the new
+ // holder, so the bound-handle scan below sees a stable set and no bind() can land in the holder
+ // we are about to stop recording into.
holder.lockForCollectAndAwait();
- // Lock each handle and wait for any in-flight recorders against it to finish.
- holder.aggregatorHandles.values().forEach(DeltaAggregatorHandle::lockForCollect);
- holder.aggregatorHandles.values().forEach(DeltaAggregatorHandle::awaitRecordersAndUnlock);
- aggregatorHandles = holder.aggregatorHandles;
+ // Seed the new holder with the bound handles, so that (a) the bound wrappers survive the swap
+ // (their bound instruments hold direct references), (b) a series recorded both bound and
+ // unbound continues to share one wrapper, and (c) bound series are collected every interval.
+ ConcurrentHashMap> newHolderHandles;
+ if (memoryMode == REUSABLE_DATA) {
+ // Ping-pong between two maps. Copy bound wrappers into the next map so they appear every
+ // interval rather than every other interval.
+ holder.aggregatorHandles.forEach(
+ (attributes, handle) -> {
+ if (handle.bound) {
+ previousCollectionAggregatorHandles.put(attributes, handle);
+ }
+ });
+ newHolderHandles = previousCollectionAggregatorHandles;
+ } else {
+ // IMMUTABLE_DATA: unbound series start fresh each interval (the old map is abandoned), so
+ // seed
+ // the new holder with only the bound wrappers.
+ newHolderHandles = new ConcurrentHashMap<>();
+ holder.aggregatorHandles.forEach(
+ (attributes, handle) -> {
+ if (handle.bound) {
+ newHolderHandles.put(attributes, handle);
+ }
+ });
+ }
+ this.aggregatorHolder = new AggregatorHolder<>(newHolderHandles);
+
+ ConcurrentHashMap> aggregatorHandles =
+ holder.aggregatorHandles;
+
+ // Lock and drain unbound handles (unchanged from the unbound-only path). Bound handles are
+ // handled separately below with a tight per-handle lock window so that recording to a bound
+ // series never blocks for the duration of the whole collection.
+ aggregatorHandles.forEach(
+ (attributes, handle) -> {
+ if (!handle.bound) {
+ handle.lockForCollect();
+ }
+ });
+ aggregatorHandles.forEach(
+ (attributes, handle) -> {
+ if (!handle.bound) {
+ handle.awaitRecordersAndUnlock();
+ }
+ });
List points;
if (memoryMode == REUSABLE_DATA) {
@@ -202,11 +297,12 @@ public MetricData collect(
// aggregator handles, so on next recording cycle using this map, there will be room for newly
// recorded Attributes. This comes at the expanse of memory allocations. This can be avoided
// if the user chooses to increase the maxCardinality.
+ // Bound handles are kept unconditionally since their bound instruments hold direct references.
if (memoryMode == REUSABLE_DATA) {
if (aggregatorHandles.size() >= maxCardinality) {
aggregatorHandles.forEach(
(attribute, handle) -> {
- if (!handle.handle.hasRecordedValues()) {
+ if (!handle.bound && !handle.handle.hasRecordedValues()) {
aggregatorHandles.remove(attribute);
}
});
@@ -221,6 +317,10 @@ public MetricData collect(
// Grab aggregated points.
aggregatorHandles.forEach(
(attributes, handle) -> {
+ if (handle.bound) {
+ collectBound(handle, attributes, startEpochNanos, epochNanos, points);
+ return;
+ }
if (!handle.handle.hasRecordedValues()) {
return;
}
@@ -252,6 +352,72 @@ public MetricData collect(
resource, instrumentationScopeInfo, metricDescriptor, points, DELTA);
}
+ /**
+ * Collects a bound handle by rotation: drain in-flight recorders, swap in a fresh (already-zero)
+ * accumulator while locked, unlock so recorders resume on the fresh accumulator, then aggregate
+ * the drained accumulator off the lock (no recorders reference it anymore). The two accumulators
+ * are ping-ponged via {@link DeltaAggregatorHandle#spare} so steady-state collection allocates
+ * nothing.
+ */
+ private void collectBound(
+ DeltaAggregatorHandle handle,
+ Attributes attributes,
+ long startEpochNanos,
+ long epochNanos,
+ List points) {
+ handle.lockForCollect();
+ handle.awaitRecorders();
+ if (!handle.handle.hasRecordedValues()) {
+ // Nothing recorded this interval; no rotation needed.
+ handle.unlockAfterCollect();
+ return;
+ }
+ AggregatorHandle collected = handle.handle;
+ AggregatorHandle next = handle.spare;
+ if (next == null) {
+ next = aggregator.createHandle(clock.now());
+ }
+ handle.handle = next; // volatile: recorders resuming after unlock see the zero accumulator
+ handle.spare = collected;
+ handle.unlockAfterCollect();
+ // Aggregate (and reset) the drained accumulator off the lock; it becomes the spare for the next
+ // rotation.
+ T point =
+ collected.aggregateThenMaybeReset(
+ startEpochNanos, epochNanos, attributes, /* reset= */ true);
+ if (point != null) {
+ points.add(point);
+ }
+ }
+
+ private final class DeltaBoundHandle implements BoundStorageHandle {
+ private final DeltaAggregatorHandle wrapper;
+ // Original (unprocessed) attributes, passed to the handle for exemplar sampling, matching the
+ // unbound record path.
+ private final Attributes attributes;
+
+ DeltaBoundHandle(DeltaAggregatorHandle wrapper, Attributes attributes) {
+ this.wrapper = wrapper;
+ this.attributes = attributes;
+ }
+
+ @Override
+ public void recordLong(long value, Context context) {
+ if (!recordingEnabled()) {
+ return;
+ }
+ wrapper.recordLong(value, attributes, context);
+ }
+
+ @Override
+ public void recordDouble(double value, Context context) {
+ if (!shouldRecordDouble(value, attributes)) {
+ return;
+ }
+ wrapper.recordDouble(value, attributes, context);
+ }
+ }
+
private static class AggregatorHolder {
private final ConcurrentHashMap> aggregatorHandles;
// Guards new-series creation using an even/odd protocol:
@@ -302,7 +468,21 @@ void lockForCollectAndAwait() {
}
private static final class DeltaAggregatorHandle {
- final AggregatorHandle handle;
+ // Volatile and non-final: bound series rotate this accumulator each collect (a fresh,
+ // already-zero handle is swapped in while recorders are drained), so a bound instrument's
+ // stable reference to this wrapper keeps recording into the current interval without blocking
+ // on aggregation. For unbound series it never changes after construction. Recorders must read
+ // this only after acquiring the per-handle gate, which establishes the happens-before with the
+ // collector's swap.
+ volatile AggregatorHandle handle;
+ // The off-duty accumulator used for bound rotation, ping-ponged with {@link #handle} each
+ // collect. Touched only by the (serialized) collector; volatile to publish across collect
+ // cycles. Always null for unbound handles.
+ @Nullable private volatile AggregatorHandle spare;
+ // True once this series has been bound. Bound wrappers are carried across holder swaps (never
+ // pooled) and rotated rather than reset in place. Written under the holder gate by bind(), read
+ // by the collector.
+ private volatile boolean bound;
// Guards per-handle recording using the same even/odd protocol as
// AggregatorHolder.newSeriesGate,
// but scoped to a single series:
@@ -317,6 +497,36 @@ private static final class DeltaAggregatorHandle {
this.handle = handle;
}
+ /**
+ * Records onto the current accumulator via the per-handle gate. Used by bound instruments,
+ * which reference this wrapper directly — no map lookup and no holder coordination, since the
+ * wrapper already exists and is carried across holders. Spins if the collector currently holds
+ * the lock.
+ */
+ @SuppressWarnings("ThreadPriorityCheck")
+ void recordLong(long value, Attributes attributes, Context context) {
+ while (!tryAcquireForRecord()) {
+ Thread.yield();
+ }
+ try {
+ handle.recordLong(value, attributes, context);
+ } finally {
+ releaseRecord();
+ }
+ }
+
+ @SuppressWarnings("ThreadPriorityCheck")
+ void recordDouble(double value, Attributes attributes, Context context) {
+ while (!tryAcquireForRecord()) {
+ Thread.yield();
+ }
+ try {
+ handle.recordDouble(value, attributes, context);
+ } finally {
+ releaseRecord();
+ }
+ }
+
/**
* Tries to acquire a recording slot. Returns false if the collector has locked this handle (odd
* state); the caller should retry with a fresh holder.
@@ -359,5 +569,22 @@ void awaitRecordersAndUnlock() {
}
state.addAndGet(-1);
}
+
+ /**
+ * Waits for all in-flight recorders to finish but leaves the handle locked. Used by the bound
+ * rotation path, which swaps the accumulator before unlocking via {@link
+ * #unlockAfterCollect()}.
+ */
+ @SuppressWarnings("ThreadPriorityCheck")
+ void awaitRecorders() {
+ while (state.get() > 1) {
+ Thread.yield();
+ }
+ }
+
+ /** Clears the collection lock set by {@link #lockForCollect()}. */
+ void unlockAfterCollect() {
+ state.addAndGet(-1);
+ }
}
}
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/EmptyMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/EmptyMetricStorage.java
index c8580e79f61..857b911119e 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/EmptyMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/EmptyMetricStorage.java
@@ -37,6 +37,20 @@ public void recordLong(long value, Attributes attributes, Context context) {}
@Override
public void recordDouble(double value, Attributes attributes, Context context) {}
+ @Override
+ public BoundStorageHandle bind(Attributes attributes) {
+ return NOOP_BOUND_HANDLE;
+ }
+
+ private static final BoundStorageHandle NOOP_BOUND_HANDLE =
+ new BoundStorageHandle() {
+ @Override
+ public void recordLong(long value, Context context) {}
+
+ @Override
+ public void recordDouble(double value, Context context) {}
+ };
+
@Override
public boolean isEnabled() {
return false;
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/WriteableMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/WriteableMetricStorage.java
index 7191a63f1e0..6e8b42302f5 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/WriteableMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/WriteableMetricStorage.java
@@ -23,6 +23,13 @@ public interface WriteableMetricStorage {
/** Records a measurement. */
void recordDouble(double value, Attributes attributes, Context context);
+ /**
+ * Binds the given {@code attributes}, returning a {@link BoundStorageHandle} that records to the
+ * corresponding timeseries directly. The series is resolved once here, so subsequent records via
+ * the returned handle skip per-recording attribute processing and series lookup.
+ */
+ BoundStorageHandle bind(Attributes attributes);
+
/**
* Returns {@code true} if the storage is actively recording measurements, and {@code false}
* otherwise (i.e. noop / empty metric storage is installed).
diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/MetricStorageRegistryTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/MetricStorageRegistryTest.java
index f287b76b05e..4ca2d8ee8a1 100644
--- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/MetricStorageRegistryTest.java
+++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/MetricStorageRegistryTest.java
@@ -112,6 +112,17 @@ public void recordLong(long value, Attributes attributes, Context context) {}
@Override
public void recordDouble(double value, Attributes attributes, Context context) {}
+ @Override
+ public BoundStorageHandle bind(Attributes attributes) {
+ return new BoundStorageHandle() {
+ @Override
+ public void recordLong(long value, Context context) {}
+
+ @Override
+ public void recordDouble(double value, Context context) {}
+ };
+ }
+
@Override
public boolean isEnabled() {
return true;
diff --git a/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
index ce135d4d7f1..29be5c455b9 100644
--- a/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
+++ b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/SynchronousInstrumentStressTest.java
@@ -18,14 +18,6 @@
import com.google.common.util.concurrent.Uninterruptibles;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
-import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter;
-import io.opentelemetry.api.incubator.metrics.BoundDoubleGauge;
-import io.opentelemetry.api.incubator.metrics.BoundDoubleHistogram;
-import io.opentelemetry.api.incubator.metrics.BoundDoubleUpDownCounter;
-import io.opentelemetry.api.incubator.metrics.BoundLongCounter;
-import io.opentelemetry.api.incubator.metrics.BoundLongGauge;
-import io.opentelemetry.api.incubator.metrics.BoundLongHistogram;
-import io.opentelemetry.api.incubator.metrics.BoundLongUpDownCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleGauge;
import io.opentelemetry.api.incubator.metrics.ExtendedDoubleHistogram;
@@ -60,9 +52,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.LongStream;
@@ -101,7 +91,7 @@ class SynchronousInstrumentStressTest {
// Can change to a higher value when making changes to internals to improve confidence of
// correctness.
- private static final int STRESS_TEST_REPETITIONS = 1;
+ private static final int STRESS_TEST_REPETITIONS = 10;
@ParameterizedTest
@MethodSource("stressTestArgs")
@@ -146,8 +136,16 @@ private void stressTestOnce(
Meter meter = meterProvider.get("test");
List attributes = Arrays.asList(ATTR_1, ATTR_2, ATTR_3, ATTR_4);
Collections.shuffle(attributes);
- Instrument instrument =
- getInstrument(meter, instrumentType, instrumentValueType, bound, attributes);
+ // When unbound, record through `instrument`, looking up the series by attributes on each call.
+ // When bound, bind one instrument per series up front and record straight to those (no
+ // per-record attribute lookup) — the record loop below forks accordingly.
+ Instrument instrument = getInstrument(meter, instrumentType, instrumentValueType);
+ List boundInstruments = new ArrayList<>();
+ if (bound) {
+ for (Attributes attr : attributes) {
+ boundInstruments.add(getBoundInstrument(meter, instrumentType, instrumentValueType, attr));
+ }
+ }
// Define list of measurements to record
// Later, we'll assert that the data collected matches these measurements, with no lost writes,
@@ -169,11 +167,20 @@ private void stressTestOnce(
new Thread(
() -> {
Uninterruptibles.awaitUninterruptibly(startSignal);
- for (Long measurement : measurements) {
- for (Attributes attr : attributes) {
- instrument.record(measurement, attr);
+ if (bound) {
+ for (Long measurement : measurements) {
+ for (BoundInstrument boundInstrument : boundInstruments) {
+ boundInstrument.record(measurement);
+ }
+ Thread.yield();
+ }
+ } else {
+ for (Long measurement : measurements) {
+ for (Attributes attr : attributes) {
+ instrument.record(measurement, attr);
+ }
+ Thread.yield();
}
- Thread.yield();
}
latch.countDown();
}));
@@ -354,19 +361,7 @@ private static Stream stressTestArgs() {
}
private static Instrument getInstrument(
- Meter meter,
- InstrumentType instrumentType,
- InstrumentValueType instrumentValueType,
- boolean bound,
- List attributesList) {
- if (bound) {
- // Bind one bound instrument per attribute set up front, then dispatch records by attributes.
- // The bound record path is the one under test; the per-record map lookup here is just test
- // plumbing to reuse the unbound recording loop.
- Map boundInstruments =
- bindInstruments(meter, instrumentType, instrumentValueType, attributesList);
- return (value, attributes) -> boundInstruments.get(attributes).record(value);
- }
+ Meter meter, InstrumentType instrumentType, InstrumentValueType instrumentValueType) {
switch (instrumentType) {
case COUNTER:
return instrumentValueType == InstrumentValueType.DOUBLE
@@ -401,95 +396,57 @@ private static Instrument getInstrument(
}
/**
- * Builds the instrument for the given type / value type, then binds one {@link BoundInstrument}
- * per attribute set in {@code attributesList}.
+ * Builds the instrument for the given type / value type and binds it to {@code attributes},
+ * returning a {@link BoundInstrument} that records straight to that series. Mirrors {@link
+ * #getInstrument} but for the bound API.
*/
- private static Map bindInstruments(
+ private static BoundInstrument getBoundInstrument(
Meter meter,
InstrumentType instrumentType,
InstrumentValueType instrumentValueType,
- List attributesList) {
- Map result = new HashMap<>();
+ Attributes attributes) {
boolean isDouble = instrumentValueType == InstrumentValueType.DOUBLE;
switch (instrumentType) {
case COUNTER:
- if (isDouble) {
- ExtendedDoubleCounter instrument =
- (ExtendedDoubleCounter) meter.counterBuilder(INSTRUMENT_NAME).ofDoubles().build();
- for (Attributes attributes : attributesList) {
- BoundDoubleCounter bound = instrument.bind(attributes);
- result.put(attributes, bound::add);
- }
- } else {
- ExtendedLongCounter instrument =
- (ExtendedLongCounter) meter.counterBuilder(INSTRUMENT_NAME).build();
- for (Attributes attributes : attributesList) {
- BoundLongCounter bound = instrument.bind(attributes);
- result.put(attributes, bound::add);
- }
- }
- return result;
+ return isDouble
+ ? ((ExtendedDoubleCounter) meter.counterBuilder(INSTRUMENT_NAME).ofDoubles().build())
+ .bind(attributes)
+ ::add
+ : ((ExtendedLongCounter) meter.counterBuilder(INSTRUMENT_NAME).build()).bind(attributes)
+ ::add;
case UP_DOWN_COUNTER:
- if (isDouble) {
- ExtendedDoubleUpDownCounter instrument =
- (ExtendedDoubleUpDownCounter)
- meter.upDownCounterBuilder(INSTRUMENT_NAME).ofDoubles().build();
- for (Attributes attributes : attributesList) {
- BoundDoubleUpDownCounter bound = instrument.bind(attributes);
- result.put(attributes, bound::add);
- }
- } else {
- ExtendedLongUpDownCounter instrument =
- (ExtendedLongUpDownCounter) meter.upDownCounterBuilder(INSTRUMENT_NAME).build();
- for (Attributes attributes : attributesList) {
- BoundLongUpDownCounter bound = instrument.bind(attributes);
- result.put(attributes, bound::add);
- }
- }
- return result;
+ return isDouble
+ ? ((ExtendedDoubleUpDownCounter)
+ meter.upDownCounterBuilder(INSTRUMENT_NAME).ofDoubles().build())
+ .bind(attributes)
+ ::add
+ : ((ExtendedLongUpDownCounter) meter.upDownCounterBuilder(INSTRUMENT_NAME).build())
+ .bind(attributes)
+ ::add;
case HISTOGRAM:
- if (isDouble) {
- ExtendedDoubleHistogram instrument =
- (ExtendedDoubleHistogram)
- meter
- .histogramBuilder(INSTRUMENT_NAME)
- .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
- .build();
- for (Attributes attributes : attributesList) {
- BoundDoubleHistogram bound = instrument.bind(attributes);
- result.put(attributes, bound::record);
- }
- } else {
- ExtendedLongHistogram instrument =
- (ExtendedLongHistogram)
- meter
- .histogramBuilder(INSTRUMENT_NAME)
- .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
- .ofLongs()
- .build();
- for (Attributes attributes : attributesList) {
- BoundLongHistogram bound = instrument.bind(attributes);
- result.put(attributes, bound::record);
- }
- }
- return result;
+ return isDouble
+ ? ((ExtendedDoubleHistogram)
+ meter
+ .histogramBuilder(INSTRUMENT_NAME)
+ .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
+ .build())
+ .bind(attributes)
+ ::record
+ : ((ExtendedLongHistogram)
+ meter
+ .histogramBuilder(INSTRUMENT_NAME)
+ .setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
+ .ofLongs()
+ .build())
+ .bind(attributes)
+ ::record;
case GAUGE:
- if (isDouble) {
- ExtendedDoubleGauge instrument =
- (ExtendedDoubleGauge) meter.gaugeBuilder(INSTRUMENT_NAME).build();
- for (Attributes attributes : attributesList) {
- BoundDoubleGauge bound = instrument.bind(attributes);
- result.put(attributes, bound::set);
- }
- } else {
- ExtendedLongGauge instrument =
- (ExtendedLongGauge) meter.gaugeBuilder(INSTRUMENT_NAME).ofLongs().build();
- for (Attributes attributes : attributesList) {
- BoundLongGauge bound = instrument.bind(attributes);
- result.put(attributes, bound::set);
- }
- }
- return result;
+ return isDouble
+ ? ((ExtendedDoubleGauge) meter.gaugeBuilder(INSTRUMENT_NAME).build()).bind(attributes)
+ ::set
+ : ((ExtendedLongGauge) meter.gaugeBuilder(INSTRUMENT_NAME).ofLongs().build())
+ .bind(attributes)
+ ::set;
case OBSERVABLE_COUNTER:
case OBSERVABLE_UP_DOWN_COUNTER:
case OBSERVABLE_GAUGE:
From 4da6ca033b835a2dabb2e54b8be9d776625e8998 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Tue, 23 Jun 2026 15:08:03 -0500
Subject: [PATCH 05/12] PR feedback
---
.../sdk/MetricRecordBenchmark.java | 7 ++--
.../CumulativeSynchronousMetricStorage.java | 2 +-
.../DefaultSynchronousMetricStorage.java | 5 ---
.../state/DeltaSynchronousMetricStorage.java | 39 +++++++------------
4 files changed, 20 insertions(+), 33 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index 2a753e60087..fc4ae9bd311 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -119,9 +119,10 @@ public static class BenchmarkState {
// Whether to record through bound instruments (Extended*#bind(Attributes)), which resolve the
// timeseries once up front, or unbound instruments, which look up the timeseries by Attributes
- // on every record.
- @Param({"false", "true"})
- boolean bound;
+ // on every record. Uncomment to evaluate.
+ // @Param({"false", "true"})
+ // boolean bound;
+ boolean bound = false;
// The following parameters are excluded from the benchmark to reduce combinatorial explosion
// but can optionally be enabled for adhoc evaluation.
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
index b323fcd2ebf..8c1f60afe5e 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java
@@ -78,7 +78,7 @@ private final class CumulativeBoundHandle implements BoundStorageHandle {
@Override
public void recordLong(long value, Context context) {
- if (!recordingEnabled()) {
+ if (!isEnabled()) {
return;
}
handle.recordLong(value, attributes, context);
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
index f99eb7fc9b8..d352b05e3fd 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java
@@ -101,11 +101,6 @@ public void recordDouble(double value, Attributes attributes, Context context) {
doRecordDouble(value, attributes, context);
}
- /** Returns whether recording is currently enabled. Used by bound record paths. */
- final boolean recordingEnabled() {
- return enabled;
- }
-
/**
* Returns true if a double {@code value} should be recorded. Returns false (dropping the
* measurement) when recording is disabled, or when {@code value} is NaN, logging in the latter
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
index 3b0f88553b7..f15c17919ba 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
@@ -232,29 +232,20 @@ public MetricData collect(
// Seed the new holder with the bound handles, so that (a) the bound wrappers survive the swap
// (their bound instruments hold direct references), (b) a series recorded both bound and
// unbound continues to share one wrapper, and (c) bound series are collected every interval.
- ConcurrentHashMap> newHolderHandles;
- if (memoryMode == REUSABLE_DATA) {
- // Ping-pong between two maps. Copy bound wrappers into the next map so they appear every
- // interval rather than every other interval.
- holder.aggregatorHandles.forEach(
- (attributes, handle) -> {
- if (handle.bound) {
- previousCollectionAggregatorHandles.put(attributes, handle);
- }
- });
- newHolderHandles = previousCollectionAggregatorHandles;
- } else {
- // IMMUTABLE_DATA: unbound series start fresh each interval (the old map is abandoned), so
- // seed
- // the new holder with only the bound wrappers.
- newHolderHandles = new ConcurrentHashMap<>();
- holder.aggregatorHandles.forEach(
- (attributes, handle) -> {
- if (handle.bound) {
- newHolderHandles.put(attributes, handle);
- }
- });
- }
+ // In REUSABLE_DATA we ping-pong between two maps, so the bound wrappers are copied into the
+ // next
+ // map; in IMMUTABLE_DATA the old map is abandoned and the new map starts with only the bound
+ // wrappers.
+ ConcurrentHashMap> newHolderHandles =
+ (memoryMode == REUSABLE_DATA)
+ ? previousCollectionAggregatorHandles
+ : new ConcurrentHashMap<>();
+ holder.aggregatorHandles.forEach(
+ (attributes, handle) -> {
+ if (handle.bound) {
+ newHolderHandles.put(attributes, handle);
+ }
+ });
this.aggregatorHolder = new AggregatorHolder<>(newHolderHandles);
ConcurrentHashMap> aggregatorHandles =
@@ -403,7 +394,7 @@ private final class DeltaBoundHandle implements BoundStorageHandle {
@Override
public void recordLong(long value, Context context) {
- if (!recordingEnabled()) {
+ if (!isEnabled()) {
return;
}
wrapper.recordLong(value, attributes, context);
From c748bf3cb66ddfa663e09ea7d815aba8bde1d9c4 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Fri, 26 Jun 2026 13:26:50 -0500
Subject: [PATCH 06/12] Try tweaking benchmark
---
.../sdk/MetricRecordBenchmark.java | 63 ++++++++++++++++---
1 file changed, 53 insertions(+), 10 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index fc4ae9bd311..6b5ecdc30d5 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -46,6 +46,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Group;
@@ -91,6 +92,13 @@
* spreading their record activities over more distinct series. The highest contention scenario is
* cardinality=1, threads=4. Any scenario with threads=1 has zero contention.
*
+ * To make the cardinality dimension meaningful under contention, each thread traverses the series
+ * in its own independent (per-thread shuffled) order — see {@link ThreadState}. This models
+ * independent threads recording to arbitrary series. A naive shared {@code i % cardinality} index
+ * would instead march all threads through the same series in lockstep (contention self-synchronizes
+ * them), collapsing high-cardinality multi-thread runs into a single rotating hotspot that behaves
+ * like cardinality=1.
+ *
*
It's useful to characterize the performance of the metrics system under contention, as some
* high-performance applications may have many threads trying to record to the same series. It's
* also useful to characterize the performance of the metrics system under low contention, as some
@@ -153,6 +161,9 @@ public static class BenchmarkState {
List attributesList;
Span span;
io.opentelemetry.context.Scope contextScope;
+ // Hands out a distinct seed to each recording thread's ThreadState so threads traverse the
+ // series in independent orders (see ThreadState).
+ final AtomicInteger threadSeedSequence = new AtomicInteger();
@Setup
@SuppressWarnings("MustBeClosedChecker")
@@ -218,6 +229,37 @@ public void tearDown() {
}
}
+ /**
+ * Per-thread series traversal order. Each recording thread shuffles {@code [0, cardinality)} with
+ * a distinct seed, so that at any given record the threads are recording to different
+ * series rather than marching through the same series in lockstep. Without this, the shared
+ * sequential {@code i % cardinality} index plus contention's self-synchronizing effect collapses
+ * the high-cardinality, multi-thread cases into a single rotating hotspot (effectively
+ * cardinality=1 contention), which does not reflect real-world recording where independent threads
+ * touch arbitrary series.
+ */
+ @State(Scope.Thread)
+ public static class ThreadState {
+ int[] order;
+
+ @Setup
+ public void setup(BenchmarkState benchmarkState) {
+ int cardinality = benchmarkState.cardinality;
+ order = new int[cardinality];
+ for (int i = 0; i < cardinality; i++) {
+ order[i] = i;
+ }
+ // Distinct seed per thread => independent permutations => no cross-thread lockstep.
+ Random random = new Random(INITIAL_SEED + benchmarkState.threadSeedSequence.getAndIncrement());
+ for (int i = cardinality - 1; i > 0; i--) {
+ int j = random.nextInt(i + 1);
+ int tmp = order[i];
+ order[i] = order[j];
+ order[j] = tmp;
+ }
+ }
+ }
+
@Benchmark
@Group("threads1")
@GroupThreads(1)
@@ -225,8 +267,8 @@ public void tearDown() {
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_SingleThread(BenchmarkState benchmarkState) {
- record(benchmarkState);
+ public void record_SingleThread(BenchmarkState benchmarkState, ThreadState threadState) {
+ record(benchmarkState, threadState);
}
@Benchmark
@@ -236,23 +278,24 @@ public void record_SingleThread(BenchmarkState benchmarkState) {
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_MultipleThreads(BenchmarkState benchmarkState) {
- record(benchmarkState);
+ public void record_MultipleThreads(BenchmarkState benchmarkState, ThreadState threadState) {
+ record(benchmarkState, threadState);
}
- private static void record(BenchmarkState benchmarkState) {
+ private static void record(BenchmarkState benchmarkState, ThreadState threadState) {
+ // Per-thread series order: at a given i, different threads hit different series (no lockstep).
+ int[] order = threadState.order;
if (benchmarkState.bound) {
- // Bound: record straight to the pre-resolved bound instrument for the series, indexed in
- // lockstep with attributesList — no per-record Attributes lookup.
+ // Bound: record straight to the pre-resolved bound instrument for the series — no per-record
+ // Attributes lookup.
List boundInstruments = benchmarkState.boundInstruments;
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
- boundInstruments.get(i % boundInstruments.size()).record(value);
+ boundInstruments.get(order[i % order.length]).record(value);
}
} else {
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
- Attributes attributes =
- benchmarkState.attributesList.get(i % benchmarkState.attributesList.size());
+ Attributes attributes = benchmarkState.attributesList.get(order[i % order.length]);
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
benchmarkState.instrument.record(value, attributes);
}
From 87d8fd0f005a291f129fdff8ab662c0d1889a5d2 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Fri, 26 Jun 2026 13:31:59 -0500
Subject: [PATCH 07/12] spotless
---
.../io/opentelemetry/sdk/MetricRecordBenchmark.java | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index 6b5ecdc30d5..baffd5020b2 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -92,8 +92,8 @@
* spreading their record activities over more distinct series. The highest contention scenario is
* cardinality=1, threads=4. Any scenario with threads=1 has zero contention.
*
- * To make the cardinality dimension meaningful under contention, each thread traverses the series
- * in its own independent (per-thread shuffled) order — see {@link ThreadState}. This models
+ *
To make the cardinality dimension meaningful under contention, each thread traverses the
+ * series in its own independent (per-thread shuffled) order — see {@link ThreadState}. This models
* independent threads recording to arbitrary series. A naive shared {@code i % cardinality} index
* would instead march all threads through the same series in lockstep (contention self-synchronizes
* them), collapsing high-cardinality multi-thread runs into a single rotating hotspot that behaves
@@ -235,8 +235,8 @@ public void tearDown() {
* series rather than marching through the same series in lockstep. Without this, the shared
* sequential {@code i % cardinality} index plus contention's self-synchronizing effect collapses
* the high-cardinality, multi-thread cases into a single rotating hotspot (effectively
- * cardinality=1 contention), which does not reflect real-world recording where independent threads
- * touch arbitrary series.
+ * cardinality=1 contention), which does not reflect real-world recording where independent
+ * threads touch arbitrary series.
*/
@State(Scope.Thread)
public static class ThreadState {
@@ -250,7 +250,8 @@ public void setup(BenchmarkState benchmarkState) {
order[i] = i;
}
// Distinct seed per thread => independent permutations => no cross-thread lockstep.
- Random random = new Random(INITIAL_SEED + benchmarkState.threadSeedSequence.getAndIncrement());
+ Random random =
+ new Random(INITIAL_SEED + benchmarkState.threadSeedSequence.getAndIncrement());
for (int i = cardinality - 1; i > 0; i--) {
int j = random.nextInt(i + 1);
int tmp = order[i];
From 7cbf62da89c344e0c0937278ab8412a25bcddb28 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:08:03 -0500
Subject: [PATCH 08/12] simplify delta storage
---
.../sdk/MetricRecordBenchmark.java | 7 +-
.../state/DeltaSynchronousMetricStorage.java | 76 +++++++++----------
2 files changed, 40 insertions(+), 43 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index baffd5020b2..547a5637e7a 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -128,9 +128,10 @@ public static class BenchmarkState {
// Whether to record through bound instruments (Extended*#bind(Attributes)), which resolve the
// timeseries once up front, or unbound instruments, which look up the timeseries by Attributes
// on every record. Uncomment to evaluate.
- // @Param({"false", "true"})
- // boolean bound;
- boolean bound = false;
+ @Param({"false", "true"})
+ boolean bound;
+
+ // boolean bound = false;
// The following parameters are excluded from the benchmark to reduce combinatorial explosion
// but can optionally be enabled for adhoc evaluation.
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
index f15c17919ba..3aaad1938cd 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
@@ -145,7 +145,7 @@ private DeltaAggregatorHandle tryAcquireHandleForRecord(
// correctness.
DeltaAggregatorHandle newDeltaHandle = aggregatorHandlePool.poll();
if (newDeltaHandle == null) {
- newDeltaHandle = new DeltaAggregatorHandle<>(aggregator.createHandle(clock.now()));
+ newDeltaHandle = new DeltaAggregatorHandle<>(this, aggregator.createHandle(clock.now()));
}
handle = aggregatorHandles.putIfAbsent(attributes, newDeltaHandle);
if (handle == null) {
@@ -169,8 +169,12 @@ public BoundStorageHandle bind(Attributes attributes) {
// pooled / reset-in-place) by collect(), so this reference stays valid for the instrument's
// lifetime.
Attributes processed = attributesProcessor.process(attributes, Context.current());
- DeltaAggregatorHandle wrapper = bindHandle(processed);
- return new DeltaBoundHandle(wrapper, attributes);
+ DeltaAggregatorHandle handle = bindHandle(processed);
+ // Stash the original (unprocessed) attributes for exemplar sampling on the bound record path,
+ // matching the unbound path which passes the call-site attributes to the aggregator handle. The
+ // handle itself is the BoundStorageHandle.
+ handle.boundAttributes = attributes;
+ return handle;
}
@SuppressWarnings("ThreadPriorityCheck")
@@ -204,7 +208,8 @@ private DeltaAggregatorHandle bindHandle(Attributes attributes) {
if (handle == null) {
DeltaAggregatorHandle newDeltaHandle = aggregatorHandlePool.poll();
if (newDeltaHandle == null) {
- newDeltaHandle = new DeltaAggregatorHandle<>(aggregator.createHandle(clock.now()));
+ newDeltaHandle =
+ new DeltaAggregatorHandle<>(this, aggregator.createHandle(clock.now()));
}
DeltaAggregatorHandle existing =
aggregatorHandles.putIfAbsent(attributes, newDeltaHandle);
@@ -381,34 +386,6 @@ private void collectBound(
}
}
- private final class DeltaBoundHandle implements BoundStorageHandle {
- private final DeltaAggregatorHandle wrapper;
- // Original (unprocessed) attributes, passed to the handle for exemplar sampling, matching the
- // unbound record path.
- private final Attributes attributes;
-
- DeltaBoundHandle(DeltaAggregatorHandle wrapper, Attributes attributes) {
- this.wrapper = wrapper;
- this.attributes = attributes;
- }
-
- @Override
- public void recordLong(long value, Context context) {
- if (!isEnabled()) {
- return;
- }
- wrapper.recordLong(value, attributes, context);
- }
-
- @Override
- public void recordDouble(double value, Context context) {
- if (!shouldRecordDouble(value, attributes)) {
- return;
- }
- wrapper.recordDouble(value, attributes, context);
- }
- }
-
private static class AggregatorHolder {
private final ConcurrentHashMap> aggregatorHandles;
// Guards new-series creation using an even/odd protocol:
@@ -458,7 +435,13 @@ void lockForCollectAndAwait() {
}
}
- private static final class DeltaAggregatorHandle {
+ private static final class DeltaAggregatorHandle
+ implements BoundStorageHandle {
+ // The storage this handle belongs to. Used by the bound record path (this is the
+ // BoundStorageHandle) to reach the storage-level enabled / NaN checks. Kept as an explicit
+ // back-reference rather than making this a non-static inner class, which would force
+ // AggregatorHolder to be non-static too.
+ private final DeltaSynchronousMetricStorage storage;
// Volatile and non-final: bound series rotate this accumulator each collect (a fresh,
// already-zero handle is swapped in while recorders are drained), so a bound instrument's
// stable reference to this wrapper keeps recording into the current interval without blocking
@@ -470,6 +453,9 @@ private static final class DeltaAggregatorHandle {
// collect. Touched only by the (serialized) collector; volatile to publish across collect
// cycles. Always null for unbound handles.
@Nullable private volatile AggregatorHandle spare;
+ // Original (unprocessed) attributes captured at bind(), passed to the aggregator handle for
+ // exemplar sampling on the bound record path. Null until this handle is bound.
+ @Nullable private volatile Attributes boundAttributes;
// True once this series has been bound. Bound wrappers are carried across holder swaps (never
// pooled) and rotated rather than reset in place. Written under the holder gate by bind(), read
// by the collector.
@@ -484,18 +470,23 @@ private static final class DeltaAggregatorHandle {
// thread decrements by 1 to restore it to even for the next cycle.
private final AtomicInteger state = new AtomicInteger(0);
- DeltaAggregatorHandle(AggregatorHandle handle) {
+ DeltaAggregatorHandle(DeltaSynchronousMetricStorage storage, AggregatorHandle handle) {
+ this.storage = storage;
this.handle = handle;
}
/**
- * Records onto the current accumulator via the per-handle gate. Used by bound instruments,
- * which reference this wrapper directly — no map lookup and no holder coordination, since the
- * wrapper already exists and is carried across holders. Spins if the collector currently holds
- * the lock.
+ * The bound record path ({@link BoundStorageHandle}). Records onto the current accumulator via
+ * the per-handle gate — no map lookup and no holder coordination, since this wrapper already
+ * exists and is carried across holders. Spins if the collector currently holds the lock.
*/
+ @Override
@SuppressWarnings("ThreadPriorityCheck")
- void recordLong(long value, Attributes attributes, Context context) {
+ public void recordLong(long value, Context context) {
+ if (!storage.isEnabled()) {
+ return;
+ }
+ Attributes attributes = Objects.requireNonNull(boundAttributes);
while (!tryAcquireForRecord()) {
Thread.yield();
}
@@ -506,8 +497,13 @@ void recordLong(long value, Attributes attributes, Context context) {
}
}
+ @Override
@SuppressWarnings("ThreadPriorityCheck")
- void recordDouble(double value, Attributes attributes, Context context) {
+ public void recordDouble(double value, Context context) {
+ Attributes attributes = Objects.requireNonNull(boundAttributes);
+ if (!storage.shouldRecordDouble(value, attributes)) {
+ return;
+ }
while (!tryAcquireForRecord()) {
Thread.yield();
}
From 3cfa56ecde91a0b9d2ab4976c26c04d298311eb4 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Mon, 29 Jun 2026 12:50:05 -0500
Subject: [PATCH 09/12] remove handle volatile keyword
---
.../state/DeltaSynchronousMetricStorage.java | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
index 3aaad1938cd..3505d6cdc48 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
@@ -442,13 +442,11 @@ private static final class DeltaAggregatorHandle
// back-reference rather than making this a non-static inner class, which would force
// AggregatorHolder to be non-static too.
private final DeltaSynchronousMetricStorage storage;
- // Volatile and non-final: bound series rotate this accumulator each collect (a fresh,
- // already-zero handle is swapped in while recorders are drained), so a bound instrument's
- // stable reference to this wrapper keeps recording into the current interval without blocking
- // on aggregation. For unbound series it never changes after construction. Recorders must read
- // this only after acquiring the per-handle gate, which establishes the happens-before with the
- // collector's swap.
- volatile AggregatorHandle handle;
+ // Rotated by the collector for bound series (a fresh, already-zero handle swapped in while
+ // recorders are drained); write-once for unbound series. Not volatile: the per-handle `state`
+ // gate carries visibility — every read is preceded by a `state` acquire and every rotation write
+ // followed by a `state` release. INVARIANT: never read `handle` outside the gate.
+ AggregatorHandle handle;
// The off-duty accumulator used for bound rotation, ping-ponged with {@link #handle} each
// collect. Touched only by the (serialized) collector; volatile to publish across collect
// cycles. Always null for unbound handles.
From 358b88bc2a0e186717f6e54a5819392e69a49423 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Tue, 30 Jun 2026 08:58:47 -0500
Subject: [PATCH 10/12] Simplify benchmark shuffling
---
.../sdk/MetricRecordBenchmark.java | 81 ++++++-------------
1 file changed, 24 insertions(+), 57 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index 547a5637e7a..2a46975dde3 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -46,7 +46,6 @@
import java.util.Collections;
import java.util.List;
import java.util.Random;
-import java.util.concurrent.atomic.AtomicInteger;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Group;
@@ -59,6 +58,7 @@
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.ThreadParams;
/**
* This benchmark measures the performance of recording metrics. It includes the following
@@ -92,12 +92,12 @@
* spreading their record activities over more distinct series. The highest contention scenario is
* cardinality=1, threads=4. Any scenario with threads=1 has zero contention.
*
- * To make the cardinality dimension meaningful under contention, each thread traverses the
- * series in its own independent (per-thread shuffled) order — see {@link ThreadState}. This models
- * independent threads recording to arbitrary series. A naive shared {@code i % cardinality} index
- * would instead march all threads through the same series in lockstep (contention self-synchronizes
- * them), collapsing high-cardinality multi-thread runs into a single rotating hotspot that behaves
- * like cardinality=1.
+ *
To make the cardinality dimension meaningful under contention, each thread starts at a
+ * staggered offset into the series (see {@link #record}), so threads record to different series
+ * rather than marching through the same one in lockstep. A naive shared {@code i % cardinality}
+ * index would instead start every thread on the same series, and contention's self-synchronizing
+ * effect keeps them aligned, collapsing high-cardinality multi-thread runs into a single rotating
+ * hotspot that behaves like cardinality=1.
*
*
It's useful to characterize the performance of the metrics system under contention, as some
* high-performance applications may have many threads trying to record to the same series. It's
@@ -127,12 +127,10 @@ public static class BenchmarkState {
// Whether to record through bound instruments (Extended*#bind(Attributes)), which resolve the
// timeseries once up front, or unbound instruments, which look up the timeseries by Attributes
- // on every record. Uncomment to evaluate.
+ // on every record.
@Param({"false", "true"})
boolean bound;
- // boolean bound = false;
-
// The following parameters are excluded from the benchmark to reduce combinatorial explosion
// but can optionally be enabled for adhoc evaluation.
@@ -162,9 +160,6 @@ public static class BenchmarkState {
List attributesList;
Span span;
io.opentelemetry.context.Scope contextScope;
- // Hands out a distinct seed to each recording thread's ThreadState so threads traverse the
- // series in independent orders (see ThreadState).
- final AtomicInteger threadSeedSequence = new AtomicInteger();
@Setup
@SuppressWarnings("MustBeClosedChecker")
@@ -230,38 +225,6 @@ public void tearDown() {
}
}
- /**
- * Per-thread series traversal order. Each recording thread shuffles {@code [0, cardinality)} with
- * a distinct seed, so that at any given record the threads are recording to different
- * series rather than marching through the same series in lockstep. Without this, the shared
- * sequential {@code i % cardinality} index plus contention's self-synchronizing effect collapses
- * the high-cardinality, multi-thread cases into a single rotating hotspot (effectively
- * cardinality=1 contention), which does not reflect real-world recording where independent
- * threads touch arbitrary series.
- */
- @State(Scope.Thread)
- public static class ThreadState {
- int[] order;
-
- @Setup
- public void setup(BenchmarkState benchmarkState) {
- int cardinality = benchmarkState.cardinality;
- order = new int[cardinality];
- for (int i = 0; i < cardinality; i++) {
- order[i] = i;
- }
- // Distinct seed per thread => independent permutations => no cross-thread lockstep.
- Random random =
- new Random(INITIAL_SEED + benchmarkState.threadSeedSequence.getAndIncrement());
- for (int i = cardinality - 1; i > 0; i--) {
- int j = random.nextInt(i + 1);
- int tmp = order[i];
- order[i] = order[j];
- order[j] = tmp;
- }
- }
- }
-
@Benchmark
@Group("threads1")
@GroupThreads(1)
@@ -269,8 +232,8 @@ public void setup(BenchmarkState benchmarkState) {
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_SingleThread(BenchmarkState benchmarkState, ThreadState threadState) {
- record(benchmarkState, threadState);
+ public void record_SingleThread(BenchmarkState benchmarkState, ThreadParams threadParams) {
+ record(benchmarkState, threadParams);
}
@Benchmark
@@ -280,24 +243,27 @@ public void record_SingleThread(BenchmarkState benchmarkState, ThreadState threa
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_MultipleThreads(BenchmarkState benchmarkState, ThreadState threadState) {
- record(benchmarkState, threadState);
+ public void record_MultipleThreads(BenchmarkState benchmarkState, ThreadParams threadParams) {
+ record(benchmarkState, threadParams);
}
- private static void record(BenchmarkState benchmarkState, ThreadState threadState) {
- // Per-thread series order: at a given i, different threads hit different series (no lockstep).
- int[] order = threadState.order;
+ private static void record(BenchmarkState benchmarkState, ThreadParams threadParams) {
+ int cardinality = benchmarkState.attributesList.size();
+ // Stagger each thread's starting series so threads don't march through the same series in
+ // lockstep (which would collapse high-cardinality multi-thread runs into a single hotspot).
+ // Single thread / cardinality=1 => offset 0, i.e. plain sequential access.
+ int offset = threadParams.getThreadIndex() * (cardinality / threadParams.getThreadCount());
if (benchmarkState.bound) {
- // Bound: record straight to the pre-resolved bound instrument for the series — no per-record
- // Attributes lookup.
+ // Bound: record straight to the pre-resolved bound instrument for the series (no per-record
+ // Attributes lookup). Indexed identically to the unbound path so the access pattern matches.
List boundInstruments = benchmarkState.boundInstruments;
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
- boundInstruments.get(order[i % order.length]).record(value);
+ boundInstruments.get((i + offset) % cardinality).record(value);
}
} else {
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
- Attributes attributes = benchmarkState.attributesList.get(order[i % order.length]);
+ Attributes attributes = benchmarkState.attributesList.get((i + offset) % cardinality);
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
benchmarkState.instrument.record(value, attributes);
}
@@ -359,7 +325,8 @@ private interface BoundInstrument {
/**
* Builds the instrument, then binds one {@link BoundInstrument} per series in {@code
- * attributesList}, returned in the same order so the record loop can index it in lockstep.
+ * attributesList}, returned in the same order so the record loop can index it identically to the
+ * unbound path.
*/
private static List bindInstruments(
Meter meter,
From bd49f74a8c05a086db57995c5d99bf97cd5bc67b Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Tue, 30 Jun 2026 12:30:44 -0500
Subject: [PATCH 11/12] Revert "Simplify benchmark shuffling"
This reverts commit 358b88bc2a0e186717f6e54a5819392e69a49423.
---
.../sdk/MetricRecordBenchmark.java | 81 +++++++++++++------
1 file changed, 57 insertions(+), 24 deletions(-)
diff --git a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
index 2a46975dde3..547a5637e7a 100644
--- a/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
+++ b/sdk/all/src/jmh/java/io/opentelemetry/sdk/MetricRecordBenchmark.java
@@ -46,6 +46,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Group;
@@ -58,7 +59,6 @@
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;
-import org.openjdk.jmh.infra.ThreadParams;
/**
* This benchmark measures the performance of recording metrics. It includes the following
@@ -92,12 +92,12 @@
* spreading their record activities over more distinct series. The highest contention scenario is
* cardinality=1, threads=4. Any scenario with threads=1 has zero contention.
*
- * To make the cardinality dimension meaningful under contention, each thread starts at a
- * staggered offset into the series (see {@link #record}), so threads record to different series
- * rather than marching through the same one in lockstep. A naive shared {@code i % cardinality}
- * index would instead start every thread on the same series, and contention's self-synchronizing
- * effect keeps them aligned, collapsing high-cardinality multi-thread runs into a single rotating
- * hotspot that behaves like cardinality=1.
+ *
To make the cardinality dimension meaningful under contention, each thread traverses the
+ * series in its own independent (per-thread shuffled) order — see {@link ThreadState}. This models
+ * independent threads recording to arbitrary series. A naive shared {@code i % cardinality} index
+ * would instead march all threads through the same series in lockstep (contention self-synchronizes
+ * them), collapsing high-cardinality multi-thread runs into a single rotating hotspot that behaves
+ * like cardinality=1.
*
*
It's useful to characterize the performance of the metrics system under contention, as some
* high-performance applications may have many threads trying to record to the same series. It's
@@ -127,10 +127,12 @@ public static class BenchmarkState {
// Whether to record through bound instruments (Extended*#bind(Attributes)), which resolve the
// timeseries once up front, or unbound instruments, which look up the timeseries by Attributes
- // on every record.
+ // on every record. Uncomment to evaluate.
@Param({"false", "true"})
boolean bound;
+ // boolean bound = false;
+
// The following parameters are excluded from the benchmark to reduce combinatorial explosion
// but can optionally be enabled for adhoc evaluation.
@@ -160,6 +162,9 @@ public static class BenchmarkState {
List attributesList;
Span span;
io.opentelemetry.context.Scope contextScope;
+ // Hands out a distinct seed to each recording thread's ThreadState so threads traverse the
+ // series in independent orders (see ThreadState).
+ final AtomicInteger threadSeedSequence = new AtomicInteger();
@Setup
@SuppressWarnings("MustBeClosedChecker")
@@ -225,6 +230,38 @@ public void tearDown() {
}
}
+ /**
+ * Per-thread series traversal order. Each recording thread shuffles {@code [0, cardinality)} with
+ * a distinct seed, so that at any given record the threads are recording to different
+ * series rather than marching through the same series in lockstep. Without this, the shared
+ * sequential {@code i % cardinality} index plus contention's self-synchronizing effect collapses
+ * the high-cardinality, multi-thread cases into a single rotating hotspot (effectively
+ * cardinality=1 contention), which does not reflect real-world recording where independent
+ * threads touch arbitrary series.
+ */
+ @State(Scope.Thread)
+ public static class ThreadState {
+ int[] order;
+
+ @Setup
+ public void setup(BenchmarkState benchmarkState) {
+ int cardinality = benchmarkState.cardinality;
+ order = new int[cardinality];
+ for (int i = 0; i < cardinality; i++) {
+ order[i] = i;
+ }
+ // Distinct seed per thread => independent permutations => no cross-thread lockstep.
+ Random random =
+ new Random(INITIAL_SEED + benchmarkState.threadSeedSequence.getAndIncrement());
+ for (int i = cardinality - 1; i > 0; i--) {
+ int j = random.nextInt(i + 1);
+ int tmp = order[i];
+ order[i] = order[j];
+ order[j] = tmp;
+ }
+ }
+ }
+
@Benchmark
@Group("threads1")
@GroupThreads(1)
@@ -232,8 +269,8 @@ public void tearDown() {
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_SingleThread(BenchmarkState benchmarkState, ThreadParams threadParams) {
- record(benchmarkState, threadParams);
+ public void record_SingleThread(BenchmarkState benchmarkState, ThreadState threadState) {
+ record(benchmarkState, threadState);
}
@Benchmark
@@ -243,27 +280,24 @@ public void record_SingleThread(BenchmarkState benchmarkState, ThreadParams thre
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@OperationsPerInvocation(RECORDS_PER_INVOCATION)
- public void record_MultipleThreads(BenchmarkState benchmarkState, ThreadParams threadParams) {
- record(benchmarkState, threadParams);
+ public void record_MultipleThreads(BenchmarkState benchmarkState, ThreadState threadState) {
+ record(benchmarkState, threadState);
}
- private static void record(BenchmarkState benchmarkState, ThreadParams threadParams) {
- int cardinality = benchmarkState.attributesList.size();
- // Stagger each thread's starting series so threads don't march through the same series in
- // lockstep (which would collapse high-cardinality multi-thread runs into a single hotspot).
- // Single thread / cardinality=1 => offset 0, i.e. plain sequential access.
- int offset = threadParams.getThreadIndex() * (cardinality / threadParams.getThreadCount());
+ private static void record(BenchmarkState benchmarkState, ThreadState threadState) {
+ // Per-thread series order: at a given i, different threads hit different series (no lockstep).
+ int[] order = threadState.order;
if (benchmarkState.bound) {
- // Bound: record straight to the pre-resolved bound instrument for the series (no per-record
- // Attributes lookup). Indexed identically to the unbound path so the access pattern matches.
+ // Bound: record straight to the pre-resolved bound instrument for the series — no per-record
+ // Attributes lookup.
List boundInstruments = benchmarkState.boundInstruments;
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
- boundInstruments.get((i + offset) % cardinality).record(value);
+ boundInstruments.get(order[i % order.length]).record(value);
}
} else {
for (int i = 0; i < RECORDS_PER_INVOCATION; i++) {
- Attributes attributes = benchmarkState.attributesList.get((i + offset) % cardinality);
+ Attributes attributes = benchmarkState.attributesList.get(order[i % order.length]);
long value = benchmarkState.measurements.get(i % benchmarkState.measurements.size());
benchmarkState.instrument.record(value, attributes);
}
@@ -325,8 +359,7 @@ private interface BoundInstrument {
/**
* Builds the instrument, then binds one {@link BoundInstrument} per series in {@code
- * attributesList}, returned in the same order so the record loop can index it identically to the
- * unbound path.
+ * attributesList}, returned in the same order so the record loop can index it in lockstep.
*/
private static List bindInstruments(
Meter meter,
From 8f573e09d358f97f56827d801509d260e38af356 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Tue, 30 Jun 2026 15:19:58 -0500
Subject: [PATCH 12/12] spotless
---
.../metrics/internal/state/DeltaSynchronousMetricStorage.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
index 3505d6cdc48..17a44cb3d97 100644
--- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
+++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java
@@ -444,7 +444,8 @@ private static final class DeltaAggregatorHandle
private final DeltaSynchronousMetricStorage storage;
// Rotated by the collector for bound series (a fresh, already-zero handle swapped in while
// recorders are drained); write-once for unbound series. Not volatile: the per-handle `state`
- // gate carries visibility — every read is preceded by a `state` acquire and every rotation write
+ // gate carries visibility — every read is preceded by a `state` acquire and every rotation
+ // write
// followed by a `state` release. INVARIANT: never read `handle` outside the gate.
AggregatorHandle handle;
// The off-duty accumulator used for bound rotation, ping-ponged with {@link #handle} each