From 07ce2d7a1801d8a8acb88642c84ee38f91edd41a Mon Sep 17 00:00:00 2001 From: Jack Berg <34418638+jack-berg@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:50:55 -0500 Subject: [PATCH 01/12] Create basic bound instrument API shape --- .../incubator/metrics/BoundLongCounter.java | 42 ++++++ .../metrics/ExtendedDefaultMeter.java | 14 ++ .../metrics/ExtendedLongCounter.java | 11 +- .../metrics/BoundInstrumentUsageTest.java | 132 ++++++++++++++++++ .../sdk/metrics/ExtendedSdkLongCounter.java | 9 ++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongCounter.java create mode 100644 api/incubator/src/test/java/io/opentelemetry/api/incubator/metrics/BoundInstrumentUsageTest.java diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongCounter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongCounter.java new file mode 100644 index 00000000000..294b7d3de61 --- /dev/null +++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/metrics/BoundLongCounter.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.LongCounter; +import io.opentelemetry.context.Context; +import javax.annotation.concurrent.ThreadSafe; + +/** + * A {@link LongCounter} bound to a fixed set of {@link Attributes}, obtained via {@link + * ExtendedLongCounter#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 + * LongCounter#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 BoundLongCounter { + + /** + * 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(long 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(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 8283c7bb0b6..c8da4835924 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 @@ -107,8 +107,22 @@ public void add(long value, Attributes attributes) {} @Override public void add(long value) {} + + @Override + public BoundLongCounter bind(Attributes attributes) { + return NOOP_BOUND_LONG_COUNTER; + } } + private static final BoundLongCounter NOOP_BOUND_LONG_COUNTER = + new BoundLongCounter() { + @Override + public void add(long value) {} + + @Override + public void add(long value, Context context) {} + }; + private static class NoopDoubleCounter implements ExtendedDoubleCounter { @Override public boolean isEnabled() { 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 2db89872610..bfbc0be78df 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 @@ -5,11 +5,20 @@ package io.opentelemetry.api.incubator.metrics; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.DoubleCounter; import io.opentelemetry.api.metrics.LongCounter; /** Extended {@link DoubleCounter} with experimental APIs. */ public interface ExtendedLongCounter extends LongCounter { - // keep this class even if it is empty, since experimental methods may be added in the future. + /** + * Binds this counter to the given {@code attributes}, returning a {@link BoundLongCounter} 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. + */ + BoundLongCounter bind(Attributes attributes); } diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/metrics/BoundInstrumentUsageTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/metrics/BoundInstrumentUsageTest.java new file mode 100644 index 00000000000..6361914ded1 --- /dev/null +++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/metrics/BoundInstrumentUsageTest.java @@ -0,0 +1,132 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.metrics; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Random; +import org.junit.jupiter.api.Test; + +/** + * Demonstrates usage of bound instruments for a dice-rolling scenario. + * + *

When the full set of attribute combinations is known ahead of time — as it is here, with 6 + * fixed die faces — bound instruments eliminate the per-recording overhead of the CHM lookup + * (bucket traversal, {@link Attributes} equality comparison) and attribute processing by resolving + * the underlying timeseries once at bind time. + */ +class BoundInstrumentUsageTest { + + private static final AttributeKey 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