From 0dbd6d0aa4ef915521c27dd58e322c24b0d9d119 Mon Sep 17 00:00:00 2001 From: navneet singh Date: Sun, 8 Nov 2026 12:11:34 +0530 Subject: [PATCH 1/2] feat(metrics): add composable metric views support Implement experimental composable view matching mode according to the OpenTelemetry Metrics SDK specification and declarative config schema. - Introduce ViewMatchingMode (INDEPENDENT, COMPOSABLE). - Track explicit View properties in ViewBuilder, View, and RegisteredView to distinguish configured fields from defaults. - Implement composable view resolution in ViewRegistry: stream grouping by configured name, last-wins attribute/cardinality merging, backward-scanning aggregation compatibility fallback, and attribute filter conjunction. - Add reflection bridge in SdkMeterProviderUtil to safely configure ViewMatchingMode on SdkMeterProviderBuilder without modifying public 1.x APIs. - Wire view_matching_mode/development in MeterProviderFactory for declarative configuration. - Add comprehensive unit and integration tests. Fixes #9013 --- .../MeterProviderFactory.java | 12 + .../MeterProviderFactoryTest.java | 8 + .../opentelemetry/sdk/metrics/SdkMeter.java | 14 +- .../sdk/metrics/SdkMeterProvider.java | 11 +- .../sdk/metrics/SdkMeterProviderBuilder.java | 21 +- .../io/opentelemetry/sdk/metrics/View.java | 24 +- .../sdk/metrics/ViewBuilder.java | 31 +- .../internal/SdkMeterProviderUtil.java | 17 + .../internal/view/CompositeAndPredicate.java | 81 +++ .../metrics/internal/view/RegisteredView.java | 36 +- .../internal/view/ViewMatchingMode.java | 24 + .../metrics/internal/view/ViewRegistry.java | 284 ++++++++- .../sdk/metrics/SdkMeterTest.java | 147 +++++ .../opentelemetry/sdk/metrics/ViewTest.java | 26 + .../sdk/metrics/ViewTestUtil.java | 48 ++ .../view/CompositeAndPredicateTest.java | 56 ++ .../internal/view/ViewRegistryTest.java | 593 +++++++++++++++++- 17 files changed, 1393 insertions(+), 40 deletions(-) create mode 100644 sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicate.java create mode 100644 sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewMatchingMode.java create mode 100644 sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTestUtil.java create mode 100644 sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicateTest.java diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java index 1eb96c282c6..94f11eb38e7 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java @@ -16,6 +16,7 @@ import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterConfigModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterConfiguratorModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterMatcherAndConfigModel; +import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalViewMatchingModeModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.MeterProviderModelAccessor; import io.opentelemetry.sdk.common.internal.ScopeConfigurator; import io.opentelemetry.sdk.common.internal.ScopeConfiguratorBuilder; @@ -24,6 +25,7 @@ import io.opentelemetry.sdk.metrics.export.CardinalityLimitSelector; import io.opentelemetry.sdk.metrics.internal.MeterConfig; import io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import java.util.List; final class MeterProviderFactory implements Factory { @@ -95,6 +97,16 @@ public SdkMeterProviderBuilder create( SdkMeterProviderUtil.setMeterConfigurator(builder, configuratorBuilder.build()); } + ExperimentalViewMatchingModeModel viewMatchingModeModel = + MeterProviderModelAccessor.getViewMatchingMode(model); + if (viewMatchingModeModel != null) { + ViewMatchingMode viewMatchingMode = + viewMatchingModeModel == ExperimentalViewMatchingModeModel.COMPOSABLE + ? ViewMatchingMode.COMPOSABLE + : ViewMatchingMode.INDEPENDENT; + SdkMeterProviderUtil.setViewMatchingMode(builder, viewMatchingMode); + } + ExemplarFilterModel exemplarFilterModel = model.getExemplarFilter(); if (exemplarFilterModel != null) { builder.setExemplarFilter( diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java index 584bdfa22f4..81e1b4329c5 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java @@ -6,6 +6,7 @@ package io.opentelemetry.sdk.autoconfigure.declarativeconfig; import static io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil.setMeterConfigurator; +import static io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil.setViewMatchingMode; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import io.opentelemetry.common.ComponentLoader; @@ -23,6 +24,7 @@ import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterConfigModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterConfiguratorModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalMeterMatcherAndConfigModel; +import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalViewMatchingModeModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.MeterProviderModelAccessor; import io.opentelemetry.sdk.common.internal.ScopeConfigurator; import io.opentelemetry.sdk.common.internal.ScopeConfiguratorBuilder; @@ -32,6 +34,7 @@ import io.opentelemetry.sdk.metrics.View; import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; import io.opentelemetry.sdk.metrics.internal.MeterConfig; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import java.io.Closeable; import java.util.ArrayList; import java.util.Collections; @@ -73,6 +76,11 @@ private static Stream createArguments() { return Stream.of( Arguments.argumentSet( "defaults", new MeterProviderModel(), SdkMeterProvider.builder().build()), + Arguments.argumentSet( + "with view matching mode composable", + MeterProviderModelAccessor.setViewMatchingMode( + new MeterProviderModel(), ExperimentalViewMatchingModeModel.COMPOSABLE), + setViewMatchingMode(SdkMeterProvider.builder(), ViewMatchingMode.COMPOSABLE).build()), Arguments.argumentSet( "with reader view and meter configurator", MeterProviderModelAccessor.setMeterConfigurator( 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 f59b1f62eb3..e734d193911 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 @@ -305,7 +305,7 @@ WriteableMetricStorage registerSynchronousMetricStorage(InstrumentDescriptor ins if (Aggregation.drop() == registeredView.getView().getAggregation()) { continue; } - registeredStorages.add( + SynchronousMetricStorage storage = registry.register( SynchronousMetricStorage.create( reader, @@ -314,7 +314,10 @@ WriteableMetricStorage registerSynchronousMetricStorage(InstrumentDescriptor ins instrument, meterProviderSharedState.getExemplarFilter(), meterEnabled, - getContextScopedAttributesFilter()))); + getContextScopedAttributesFilter())); + if (!registeredStorages.contains(storage)) { + registeredStorages.add(storage); + } } } @@ -339,7 +342,7 @@ SdkObservableMeasurement registerObservableMeasurement( if (Aggregation.drop() == registeredView.getView().getAggregation()) { continue; } - registeredStorages.add( + AsynchronousMetricStorage storage = registry.register( AsynchronousMetricStorage.create( reader, @@ -347,7 +350,10 @@ SdkObservableMeasurement registerObservableMeasurement( meterProviderSharedState.getClock(), instrumentDescriptor, meterProviderSharedState.getExemplarFilter(), - meterEnabled))); + meterEnabled)); + if (!registeredStorages.contains(storage)) { + registeredStorages.add(storage); + } } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java index abfd4171b52..584d561de24 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java @@ -27,6 +27,7 @@ import io.opentelemetry.sdk.metrics.internal.export.RegisteredReader; import io.opentelemetry.sdk.metrics.internal.state.MeterProviderSharedState; import io.opentelemetry.sdk.metrics.internal.view.RegisteredView; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import io.opentelemetry.sdk.metrics.internal.view.ViewRegistry; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.resources.internal.SdkResourceProvider; @@ -55,6 +56,7 @@ public final class SdkMeterProvider implements MeterProvider, Closeable { static final String DEFAULT_METER_NAME = "unknown"; private final List registeredViews; + private final ViewMatchingMode viewMatchingMode; private final List registeredReaders; private final List metricProducers; private final MeterProviderSharedState sharedState; @@ -78,15 +80,18 @@ public static SdkMeterProviderBuilder builder() { ExemplarFilterInternal exemplarFilter, ScopeConfigurator meterConfigurator, @Nullable - BiPredicate> contextScopedAttributesFilter) { + BiPredicate> contextScopedAttributesFilter, + ViewMatchingMode viewMatchingMode) { this.resourceProvider = resourceProvider; this.registeredViews = registeredViews; + this.viewMatchingMode = viewMatchingMode; this.registeredReaders = metricReaders.entrySet().stream() .map(entry -> RegisteredReader.create( entry.getKey(), - ViewRegistry.create(entry.getKey(), entry.getValue(), registeredViews))) + ViewRegistry.create( + entry.getKey(), entry.getValue(), registeredViews, viewMatchingMode))) .collect(toList()); this.metricProducers = metricProducers; this.sharedState = @@ -210,6 +215,8 @@ public String toString() { + metricProducers + ", views=" + registeredViews + + ", viewMatchingMode=" + + viewMatchingMode + ", meterConfigurator=" + meterConfigurator + "}"; diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java index 1dd3a8fca87..643d2fcd691 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java @@ -19,6 +19,7 @@ import io.opentelemetry.sdk.metrics.internal.exemplar.ExemplarFilterInternal; import io.opentelemetry.sdk.metrics.internal.view.AttributesFilters; import io.opentelemetry.sdk.metrics.internal.view.RegisteredView; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.resources.internal.SdkResourceProvider; import java.util.ArrayList; @@ -52,6 +53,7 @@ public final class SdkMeterProviderBuilder { new IdentityHashMap<>(); private final List metricProducers = new ArrayList<>(); private final List registeredViews = new ArrayList<>(); + private ViewMatchingMode viewMatchingMode = ViewMatchingMode.INDEPENDENT; private ExemplarFilterInternal exemplarFilter = DEFAULT_EXEMPLAR_FILTER; private ScopeConfiguratorBuilder meterConfiguratorBuilder = MeterConfig.configuratorBuilder(); @@ -171,7 +173,10 @@ public SdkMeterProviderBuilder registerView(InstrumentSelector selector, View vi view, AttributesFilters.byKeyName(view.getAttributeFilter()), view.getCardinalityLimit(), - SourceInfo.fromCurrentStack())); + SourceInfo.fromCurrentStack(), + view.getExplicitAggregation(), + view.getExplicitAttributeFilter(), + view.getExplicitCardinalityLimit())); return this; } @@ -263,6 +268,17 @@ SdkMeterProviderBuilder setContextScopedAttributesFilter( return this; } + /** + * Set the view matching mode. + * + *

This method is experimental so not public. You may reflectively call it using {@link + * SdkMeterProviderUtil#setViewMatchingMode(SdkMeterProviderBuilder, ViewMatchingMode)}. + */ + SdkMeterProviderBuilder setViewMatchingMode(ViewMatchingMode viewMatchingMode) { + this.viewMatchingMode = Objects.requireNonNull(viewMatchingMode, "viewMatchingMode"); + return this; + } + /** Returns an {@link SdkMeterProvider} built with the configuration of this builder. */ public SdkMeterProvider build() { SdkResourceProvider effectiveSdkResourceProvider = @@ -275,6 +291,7 @@ public SdkMeterProvider build() { effectiveSdkResourceProvider, exemplarFilter, meterConfiguratorBuilder.build(), - contextScopedAttributesFilter); + contextScopedAttributesFilter, + viewMatchingMode); } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java index ada250561d6..d9ec7175cac 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java @@ -33,8 +33,19 @@ static View create( @Nullable String description, Aggregation aggregation, Predicate attributeFilter, - int cardinalityLimit) { - return new AutoValue_View(name, description, aggregation, attributeFilter, cardinalityLimit); + int cardinalityLimit, + @Nullable Aggregation explicitAggregation, + @Nullable Predicate explicitAttributeFilter, + @Nullable Integer explicitCardinalityLimit) { + return new AutoValue_View( + name, + description, + aggregation, + attributeFilter, + cardinalityLimit, + explicitAggregation, + explicitAttributeFilter, + explicitCardinalityLimit); } View() {} @@ -71,6 +82,15 @@ static View create( */ public abstract int getCardinalityLimit(); + @Nullable + abstract Aggregation getExplicitAggregation(); + + @Nullable + abstract Predicate getExplicitAttributeFilter(); + + @Nullable + abstract Integer getExplicitCardinalityLimit(); + @Override public final String toString() { StringJoiner joiner = new StringJoiner(", ", "View{", "}"); diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ViewBuilder.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ViewBuilder.java index 9aa7823f9bd..4e35945121e 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ViewBuilder.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/ViewBuilder.java @@ -25,9 +25,9 @@ public final class ViewBuilder { @Nullable private String name; @Nullable private String description; - private Aggregation aggregation = Aggregation.defaultAggregation(); - private Predicate attributeFilter = StringPredicates.ALL; - private int cardinalityLimit = MetricStorage.DEFAULT_MAX_CARDINALITY; + @Nullable private Aggregation explicitAggregation; + @Nullable private Predicate explicitAttributeFilter; + @Nullable private Integer explicitCardinalityLimit; ViewBuilder() {} @@ -58,12 +58,13 @@ public ViewBuilder setDescription(String description) { * @param aggregation aggregation to use. */ public ViewBuilder setAggregation(Aggregation aggregation) { + Objects.requireNonNull(aggregation, "aggregation"); if (!(aggregation instanceof AggregatorFactory)) { throw new IllegalArgumentException( "Custom Aggregation implementations are currently not supported. " + "Use one of the standard implementations returned by the static factories in the Aggregation class."); } - this.aggregation = aggregation; + this.explicitAggregation = aggregation; return this; } @@ -104,7 +105,7 @@ public ViewBuilder setAttributeFilter(Set keysToRetain) { */ public ViewBuilder setAttributeFilter(Predicate keyFilter) { Objects.requireNonNull(keyFilter, "keyFilter"); - this.attributeFilter = keyFilter; + this.explicitAttributeFilter = keyFilter; return this; } @@ -121,12 +122,28 @@ public ViewBuilder setCardinalityLimit(int cardinalityLimit) { if (cardinalityLimit <= 0) { throw new IllegalArgumentException("cardinalityLimit must be > 0"); } - this.cardinalityLimit = cardinalityLimit; + this.explicitCardinalityLimit = cardinalityLimit; return this; } /** Returns a {@link View} with the configuration of this builder. */ public View build() { - return View.create(name, description, aggregation, attributeFilter, cardinalityLimit); + Aggregation aggregation = + explicitAggregation != null ? explicitAggregation : Aggregation.defaultAggregation(); + Predicate attributeFilter = + explicitAttributeFilter != null ? explicitAttributeFilter : StringPredicates.ALL; + int cardinalityLimit = + explicitCardinalityLimit != null + ? explicitCardinalityLimit + : MetricStorage.DEFAULT_MAX_CARDINALITY; + return View.create( + name, + description, + aggregation, + attributeFilter, + cardinalityLimit, + explicitAggregation, + explicitAttributeFilter, + explicitCardinalityLimit); } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java index 9c1c6e11253..9fdd1d8ae16 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java @@ -10,6 +10,7 @@ import io.opentelemetry.sdk.common.internal.ScopeConfigurator; import io.opentelemetry.sdk.metrics.SdkMeterProvider; import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import io.opentelemetry.sdk.resources.internal.SdkResourceProvider; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -133,4 +134,20 @@ public static SdkMeterProviderBuilder setContextScopedAttributesFilter( } return sdkMeterProviderBuilder; } + + /** Reflectively set the {@link ViewMatchingMode} on the {@link SdkMeterProviderBuilder}. */ + public static SdkMeterProviderBuilder setViewMatchingMode( + SdkMeterProviderBuilder sdkMeterProviderBuilder, ViewMatchingMode viewMatchingMode) { + try { + Method method = + SdkMeterProviderBuilder.class.getDeclaredMethod( + "setViewMatchingMode", ViewMatchingMode.class); + method.setAccessible(true); + method.invoke(sdkMeterProviderBuilder, viewMatchingMode); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException( + "Error calling setViewMatchingMode on SdkMeterProviderBuilder", e); + } + return sdkMeterProviderBuilder; + } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicate.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicate.java new file mode 100644 index 00000000000..0413f49a238 --- /dev/null +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicate.java @@ -0,0 +1,81 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.view; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import javax.annotation.concurrent.Immutable; + +/** + * An immutable composite predicate that evaluates the logical AND of a list of predicates, + * providing value-based equality and hash code over its constituent predicates. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +@Immutable +public final class CompositeAndPredicate implements Predicate { + + private final List> predicates; + + /** Creates a composite AND predicate from the provided predicates. */ + public static Predicate create(List> predicates) { + if (predicates.isEmpty()) { + return StringPredicates.ALL; + } + if (predicates.size() == 1) { + return predicates.get(0); + } + List> flattened = new ArrayList<>(predicates.size()); + for (Predicate predicate : predicates) { + if (predicate instanceof CompositeAndPredicate) { + flattened.addAll(((CompositeAndPredicate) predicate).predicates); + } else { + flattened.add(predicate); + } + } + return new CompositeAndPredicate(Collections.unmodifiableList(flattened)); + } + + private CompositeAndPredicate(List> predicates) { + this.predicates = predicates; + } + + @Override + public boolean test(String s) { + for (Predicate predicate : predicates) { + if (!predicate.test(s)) { + return false; + } + } + return true; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CompositeAndPredicate)) { + return false; + } + CompositeAndPredicate that = (CompositeAndPredicate) o; + return Objects.equals(this.predicates, that.predicates); + } + + @Override + public int hashCode() { + return predicates.hashCode(); + } + + @Override + public String toString() { + return "CompositeAndPredicate{" + predicates + "}"; + } +} diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/RegisteredView.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/RegisteredView.java index 296fdadc609..c62306933b5 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/RegisteredView.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/RegisteredView.java @@ -7,10 +7,13 @@ import com.google.auto.value.AutoValue; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.metrics.Aggregation; import io.opentelemetry.sdk.metrics.InstrumentSelector; import io.opentelemetry.sdk.metrics.View; import io.opentelemetry.sdk.metrics.internal.debug.SourceInfo; +import java.util.function.Predicate; import java.util.function.UnaryOperator; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; /** @@ -28,9 +31,29 @@ public static RegisteredView create( View view, UnaryOperator attributesFilter, int cardinalityLimit, - SourceInfo viewSourceInfo) { + SourceInfo viewSourceInfo, + @Nullable Aggregation explicitAggregation, + @Nullable Predicate explicitAttributeFilter, + @Nullable Integer explicitCardinalityLimit) { return new AutoValue_RegisteredView( - selector, view, attributesFilter, cardinalityLimit, viewSourceInfo); + selector, + view, + attributesFilter, + cardinalityLimit, + viewSourceInfo, + explicitAggregation, + explicitAttributeFilter, + explicitCardinalityLimit); + } + + public static RegisteredView create( + InstrumentSelector selector, + View view, + UnaryOperator attributesFilter, + int cardinalityLimit, + SourceInfo viewSourceInfo) { + return create( + selector, view, attributesFilter, cardinalityLimit, viewSourceInfo, null, null, null); } RegisteredView() {} @@ -54,6 +77,15 @@ public static RegisteredView create( /** The {@link SourceInfo} from where the view was registered. */ public abstract SourceInfo getViewSourceInfo(); + @Nullable + public abstract Aggregation getExplicitAggregation(); + + @Nullable + public abstract Predicate getExplicitAttributeFilter(); + + @Nullable + public abstract Integer getExplicitCardinalityLimit(); + @Override public final String toString() { return "RegisteredView{" diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewMatchingMode.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewMatchingMode.java new file mode 100644 index 00000000000..374cbde436a --- /dev/null +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewMatchingMode.java @@ -0,0 +1,24 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.view; + +/** + * Controls how multiple matching {@link io.opentelemetry.sdk.metrics.View}s are applied to an + * instrument. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public enum ViewMatchingMode { + /** + * Each matching view creates a separate metric stream independently of any other views registered + * for the same matching instrument. + */ + INDEPENDENT, + + /** Matching views are combined (merged) to modify metric streams rather than duplicating them. */ + COMPOSABLE +} diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistry.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistry.java index 671b7ab3276..f4aa7792998 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistry.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistry.java @@ -7,6 +7,7 @@ import static java.util.Objects.requireNonNull; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.common.internal.GlobUtil; import io.opentelemetry.sdk.metrics.Aggregation; @@ -14,6 +15,7 @@ import io.opentelemetry.sdk.metrics.InstrumentType; import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; import io.opentelemetry.sdk.metrics.View; +import io.opentelemetry.sdk.metrics.ViewBuilder; import io.opentelemetry.sdk.metrics.export.CardinalityLimitSelector; import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector; import io.opentelemetry.sdk.metrics.internal.aggregator.AggregationUtil; @@ -25,10 +27,15 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; /** @@ -50,36 +57,84 @@ public final class ViewRegistry { SourceInfo.noSourceInfo()); private static final Logger logger = Logger.getLogger(ViewRegistry.class.getName()); + private final DefaultAggregationSelector defaultAggregationSelector; + private final CardinalityLimitSelector cardinalityLimitSelector; + private final ViewMatchingMode viewMatchingMode; private final Map instrumentDefaultRegisteredView; private final List registeredViews; ViewRegistry( DefaultAggregationSelector defaultAggregationSelector, CardinalityLimitSelector cardinalityLimitSelector, - List registeredViews) { - instrumentDefaultRegisteredView = new HashMap<>(); + List registeredViews, + ViewMatchingMode viewMatchingMode) { + this.defaultAggregationSelector = defaultAggregationSelector; + this.cardinalityLimitSelector = cardinalityLimitSelector; + this.viewMatchingMode = viewMatchingMode; + this.instrumentDefaultRegisteredView = new HashMap<>(); for (InstrumentType instrumentType : InstrumentType.values()) { + Aggregation defaultAgg = defaultAggregationSelector.getDefaultAggregation(instrumentType); + ViewBuilder vb = View.builder(); + Aggregation explicitAgg = null; + if (!defaultAgg.equals(Aggregation.defaultAggregation())) { + vb.setAggregation(defaultAgg); + explicitAgg = defaultAgg; + } + int cardinalityLimit = cardinalityLimitSelector.getCardinalityLimit(instrumentType); + Integer explicitLimit = null; + if (cardinalityLimit != MetricStorage.DEFAULT_MAX_CARDINALITY) { + vb.setCardinalityLimit(cardinalityLimit); + explicitLimit = cardinalityLimit; + } instrumentDefaultRegisteredView.put( instrumentType, RegisteredView.create( InstrumentSelector.builder().setName("*").build(), - View.builder() - .setAggregation(defaultAggregationSelector.getDefaultAggregation(instrumentType)) - .build(), + vb.build(), AttributesFilters.ALLOW_ALL, - cardinalityLimitSelector.getCardinalityLimit(instrumentType), - SourceInfo.noSourceInfo())); + cardinalityLimit, + SourceInfo.noSourceInfo(), + explicitAgg, + null, + explicitLimit)); } this.registeredViews = registeredViews; } + ViewRegistry( + DefaultAggregationSelector defaultAggregationSelector, + CardinalityLimitSelector cardinalityLimitSelector, + List registeredViews) { + this( + defaultAggregationSelector, + cardinalityLimitSelector, + registeredViews, + ViewMatchingMode.INDEPENDENT); + } + /** Returns a {@link ViewRegistry}. */ public static ViewRegistry create( DefaultAggregationSelector defaultAggregationSelector, CardinalityLimitSelector cardinalityLimitSelector, - List registeredViews) { + List registeredViews, + ViewMatchingMode viewMatchingMode) { return new ViewRegistry( - defaultAggregationSelector, cardinalityLimitSelector, new ArrayList<>(registeredViews)); + defaultAggregationSelector, + cardinalityLimitSelector, + new ArrayList<>(registeredViews), + viewMatchingMode); + } + + /** Returns a {@link ViewRegistry}. */ + public static ViewRegistry create( + DefaultAggregationSelector defaultAggregationSelector, + CardinalityLimitSelector cardinalityLimitSelector, + List registeredViews) { + return create( + defaultAggregationSelector, + cardinalityLimitSelector, + registeredViews, + ViewMatchingMode.INDEPENDENT); } /** Return a {@link ViewRegistry} using the default aggregation and no views registered. */ @@ -87,7 +142,8 @@ public static ViewRegistry create() { return create( unused -> Aggregation.defaultAggregation(), CardinalityLimitSelector.defaultCardinalityLimitSelector(), - Collections.emptyList()); + Collections.emptyList(), + ViewMatchingMode.INDEPENDENT); } /** @@ -98,6 +154,14 @@ public static ViewRegistry create() { */ public List findViews( InstrumentDescriptor descriptor, InstrumentationScopeInfo meterScope) { + if (viewMatchingMode == ViewMatchingMode.COMPOSABLE) { + return findComposableViews(descriptor, meterScope); + } + return findIndependentViews(descriptor, meterScope); + } + + private List findIndependentViews( + InstrumentDescriptor descriptor, InstrumentationScopeInfo meterScope) { List result = new ArrayList<>(); // Find matching views for the instrument for (RegisteredView entry : registeredViews) { @@ -124,7 +188,198 @@ public List findViews( return Collections.unmodifiableList(result); } - // No views matched, use default view + return Collections.singletonList(getDefaultRegisteredView(descriptor)); + } + + private List findComposableViews( + InstrumentDescriptor descriptor, InstrumentationScopeInfo meterScope) { + List matchingViews = new ArrayList<>(); + for (RegisteredView entry : registeredViews) { + if (matchesSelector(entry.getInstrumentSelector(), descriptor, meterScope)) { + matchingViews.add(entry); + } + } + + if (matchingViews.isEmpty()) { + return Collections.singletonList(getDefaultRegisteredView(descriptor)); + } + + // Collect distinct configured stream names case-insensitively, preserving order of appearance + // Key: lowercase canonical name; Value: latest configured name casing + LinkedHashMap configuredNames = new LinkedHashMap<>(); + for (RegisteredView entry : matchingViews) { + String viewName = entry.getView().getName(); + if (viewName != null) { + configuredNames.put(viewName.toLowerCase(Locale.ROOT), viewName); + } + } + + List result = new ArrayList<>(); + + if (configuredNames.isEmpty()) { + // No matching View configures a stream name: single group using instrument's default name + result.add(composeStreamGroup(descriptor, null, matchingViews)); + } else { + for (Map.Entry entry : configuredNames.entrySet()) { + String canonicalName = entry.getKey(); + String targetName = entry.getValue(); + List group = new ArrayList<>(); + for (RegisteredView viewEntry : matchingViews) { + String viewName = viewEntry.getView().getName(); + if (viewName == null || canonicalName.equals(viewName.toLowerCase(Locale.ROOT))) { + group.add(viewEntry); + } + } + result.add(composeStreamGroup(descriptor, targetName, group)); + } + } + + return Collections.unmodifiableList(result); + } + + private RegisteredView composeStreamGroup( + InstrumentDescriptor descriptor, @Nullable String targetName, List group) { + String effectiveDescription = resolveDescription(descriptor, group); + int effectiveCardinalityLimit = + resolveCardinalityLimit(descriptor, group, cardinalityLimitSelector); + Aggregation effectiveAggregation = + resolveAggregation(descriptor, group, defaultAggregationSelector); + AttributeFilterComposition filterComposition = composeAttributeFilter(descriptor, group); + + RegisteredView lastEntry = group.get(group.size() - 1); + ViewBuilder viewBuilder = View.builder(); + if (targetName != null) { + viewBuilder.setName(targetName); + } + if (effectiveDescription != null) { + viewBuilder.setDescription(effectiveDescription); + } + viewBuilder.setAggregation(effectiveAggregation); + if (filterComposition.explicitPredicate != null) { + viewBuilder.setAttributeFilter(filterComposition.explicitPredicate); + } + viewBuilder.setCardinalityLimit(effectiveCardinalityLimit); + View composedView = viewBuilder.build(); + + return RegisteredView.create( + lastEntry.getInstrumentSelector(), + composedView, + filterComposition.attributesFilter, + effectiveCardinalityLimit, + lastEntry.getViewSourceInfo(), + effectiveAggregation, + filterComposition.explicitPredicate, + effectiveCardinalityLimit); + } + + private static String resolveDescription( + InstrumentDescriptor descriptor, List group) { + String effectiveDescription = descriptor.getDescription(); + for (RegisteredView entry : group) { + String desc = entry.getView().getDescription(); + if (desc != null) { + effectiveDescription = desc; + } + } + return effectiveDescription; + } + + private static int resolveCardinalityLimit( + InstrumentDescriptor descriptor, + List group, + CardinalityLimitSelector cardinalityLimitSelector) { + int effectiveCardinalityLimit = + cardinalityLimitSelector.getCardinalityLimit(descriptor.getType()); + for (RegisteredView entry : group) { + Integer limit = entry.getExplicitCardinalityLimit(); + if (limit != null) { + effectiveCardinalityLimit = limit; + } + } + return effectiveCardinalityLimit; + } + + private static Aggregation resolveAggregation( + InstrumentDescriptor descriptor, + List group, + DefaultAggregationSelector defaultAggregationSelector) { + Aggregation effectiveAggregation = null; + for (int i = group.size() - 1; i >= 0; i--) { + RegisteredView entry = group.get(i); + Aggregation agg = entry.getExplicitAggregation(); + if (agg != null) { + AggregatorFactory factory = (AggregatorFactory) agg; + if (factory.isCompatibleWithInstrument(descriptor)) { + effectiveAggregation = agg; + break; + } else { + logger.log( + Level.WARNING, + "View aggregation " + + AggregationUtil.aggregationName(agg) + + " is incompatible with instrument " + + descriptor.getName() + + " of type " + + descriptor.getType()); + } + } + } + + if (effectiveAggregation == null) { + Aggregation defaultAgg = + defaultAggregationSelector.getDefaultAggregation(descriptor.getType()); + AggregatorFactory defaultFactory = (AggregatorFactory) defaultAgg; + if (!defaultFactory.isCompatibleWithInstrument(descriptor)) { + logger.log( + Level.WARNING, + "Instrument default aggregation " + + AggregationUtil.aggregationName(defaultAgg) + + " is incompatible with instrument " + + descriptor.getName() + + " of type " + + descriptor.getType()); + effectiveAggregation = DEFAULT_VIEW.getAggregation(); + } else { + effectiveAggregation = defaultAgg; + } + } + return effectiveAggregation; + } + + private static AttributeFilterComposition composeAttributeFilter( + InstrumentDescriptor descriptor, List group) { + List> explicitFilters = new ArrayList<>(); + for (RegisteredView entry : group) { + Predicate explicitFilter = entry.getExplicitAttributeFilter(); + if (explicitFilter != null) { + explicitFilters.add(explicitFilter); + } + } + + if (!explicitFilters.isEmpty()) { + Predicate composedPredicate = CompositeAndPredicate.create(explicitFilters); + return new AttributeFilterComposition( + composedPredicate, AttributesFilters.byKeyName(composedPredicate)); + } + if (descriptor.getAdvice().hasAttributes()) { + return new AttributeFilterComposition( + null, AttributesFilters.byAdvice(requireNonNull(descriptor.getAdvice().getAttributes()))); + } + return new AttributeFilterComposition(null, AttributesFilters.ALLOW_ALL); + } + + private static final class AttributeFilterComposition { + @Nullable private final Predicate explicitPredicate; + private final UnaryOperator attributesFilter; + + AttributeFilterComposition( + @Nullable Predicate explicitPredicate, UnaryOperator attributesFilter) { + this.explicitPredicate = explicitPredicate; + this.attributesFilter = attributesFilter; + } + } + + private RegisteredView getDefaultRegisteredView(InstrumentDescriptor descriptor) { RegisteredView instrumentDefaultView = requireNonNull(instrumentDefaultRegisteredView.get(descriptor.getType())); @@ -151,7 +406,7 @@ public List findViews( applyAdviceToDefaultView(instrumentDefaultView, descriptor.getAdvice()); } - return Collections.singletonList(instrumentDefaultView); + return instrumentDefaultView; } // Matches an instrument selector against an instrument + meter. @@ -196,6 +451,9 @@ private static RegisteredView applyAdviceToDefaultView( instrumentDefaultView.getView(), AttributesFilters.byAdvice(requireNonNull(advice.getAttributes())), instrumentDefaultView.getCardinalityLimit(), - instrumentDefaultView.getViewSourceInfo()); + instrumentDefaultView.getViewSourceInfo(), + instrumentDefaultView.getExplicitAggregation(), + instrumentDefaultView.getExplicitAttributeFilter(), + instrumentDefaultView.getExplicitCardinalityLimit()); } } diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java index bd02509d9bd..99619a3d11f 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java @@ -9,6 +9,8 @@ import static org.assertj.core.api.Assertions.assertThat; import io.github.netmikey.logunit.api.LogCapturer; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.DoubleCounter; import io.opentelemetry.api.metrics.DoubleHistogram; import io.opentelemetry.api.metrics.DoubleUpDownCounter; @@ -18,9 +20,17 @@ import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.metrics.MeterProvider; import io.opentelemetry.internal.testing.slf4j.SuppressLogger; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; import io.opentelemetry.sdk.metrics.internal.MeterConfig; +import io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil; import io.opentelemetry.sdk.metrics.internal.state.MetricStorageRegistry; +import io.opentelemetry.sdk.metrics.internal.view.ViewMatchingMode; import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; import java.util.Locale; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -494,4 +504,141 @@ void updateEnabled() { meter.updateMeterConfig(MeterConfig.enabled()); assertThat(meter.isMeterEnabled()).isTrue(); } + + @Test + void composableViews_InstrumentRegisteredTwiceProducesSingleStorageAndStream() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setViewMatchingMode(builder, ViewMatchingMode.COMPOSABLE); + + builder.registerView( + InstrumentSelector.builder().setName("duplicate_counter").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("k1", "k2"))).build()); + builder.registerView( + InstrumentSelector.builder().setName("duplicate_counter").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("k2", "k3"))).build()); + + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + LongCounter counter1 = meter.counterBuilder("duplicate_counter").build(); + LongCounter counter2 = meter.counterBuilder("duplicate_counter").build(); + + counter1.add(1, Attributes.builder().put("k1", "v1").put("k2", "v2").put("k3", "v3").build()); + counter2.add(2, Attributes.builder().put("k1", "v1").put("k2", "v2").put("k3", "v3").build()); + + Collection metrics = reader.collectAllMetrics(); + assertThat(metrics).hasSize(1); + MetricData metric = metrics.iterator().next(); + assertThat(metric.getName()).isEqualTo("duplicate_counter"); + assertThat(metric.getLongSumData().getPoints()).hasSize(1); + LongPointData point = metric.getLongSumData().getPoints().iterator().next(); + assertThat(point.getValue()).isEqualTo(3); + assertThat(point.getAttributes().asMap()).containsOnlyKeys(AttributeKey.stringKey("k2")); + } + + @Test + void composableViews_AsyncGaugeRegisteredTwiceProducesSingleStream() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setViewMatchingMode(builder, ViewMatchingMode.COMPOSABLE); + + builder.registerView( + InstrumentSelector.builder().setName("duplicate_gauge").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("k1", "k2"))).build()); + builder.registerView( + InstrumentSelector.builder().setName("duplicate_gauge").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("k2", "k3"))).build()); + + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + meter + .gaugeBuilder("duplicate_gauge") + .buildWithCallback( + m -> m.record(42.0, Attributes.builder().put("k1", "v1").put("k2", "v2").build())); + meter + .gaugeBuilder("duplicate_gauge") + .buildWithCallback( + m -> m.record(84.0, Attributes.builder().put("k1", "v1").put("k2", "v2").build())); + + Collection metrics = reader.collectAllMetrics(); + assertThat(metrics).hasSize(1); + MetricData metric = metrics.iterator().next(); + assertThat(metric.getName()).isEqualTo("duplicate_gauge"); + } + + @Test + void independentViews_TwoViewsWithIdenticalFilter_DoesNotDoubleCount() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + + builder.registerView( + InstrumentSelector.builder().setName("*").build(), + View.builder().setAttributeFilter(Collections.singleton("a")).build()); + builder.registerView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAttributeFilter(Collections.singleton("a")).build()); + + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + LongCounter counter = meter.counterBuilder("my_counter").build(); + counter.add(1, Attributes.of(AttributeKey.stringKey("a"), "val")); + + Collection metrics = reader.collectAllMetrics(); + // In independent mode without filter value equality, conflicting views emit 2 streams of value + // 1 each + for (MetricData metric : metrics) { + for (LongPointData point : metric.getLongSumData().getPoints()) { + assertThat(point.getValue()).isEqualTo(1); + } + } + } + + @Test + void independentViews_SameViewInstanceMatchingMultipleSelectors_DoesNotDoubleCount() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + + View view = View.builder().setAttributeFilter(Collections.singleton("a")).build(); + builder.registerView(InstrumentSelector.builder().setName("*").build(), view); + builder.registerView(InstrumentSelector.builder().setName("same_view_counter").build(), view); + + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + LongCounter counter = meter.counterBuilder("same_view_counter").build(); + counter.add(1, Attributes.of(AttributeKey.stringKey("a"), "val")); + + Collection metrics = reader.collectAllMetrics(); + assertThat(metrics).hasSize(1); + MetricData metric = metrics.iterator().next(); + assertThat(metric.getLongSumData().getPoints()).hasSize(1); + assertThat(metric.getLongSumData().getPoints().iterator().next().getValue()).isEqualTo(1); + } + + @Test + void independentViews_AsyncGaugeSameViewInstance_DoesNotDoubleCount() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + + View view = View.builder().setAttributeFilter(Collections.singleton("a")).build(); + builder.registerView(InstrumentSelector.builder().setName("*").build(), view); + builder.registerView(InstrumentSelector.builder().setName("same_view_gauge").build(), view); + + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + meter + .gaugeBuilder("same_view_gauge") + .buildWithCallback(m -> m.record(42.0, Attributes.of(AttributeKey.stringKey("a"), "val"))); + + Collection metrics = reader.collectAllMetrics(); + assertThat(metrics).hasSize(1); + MetricData metric = metrics.iterator().next(); + assertThat(metric.getDoubleGaugeData().getPoints()).hasSize(1); + assertThat(metric.getDoubleGaugeData().getPoints().iterator().next().getValue()) + .isEqualTo(42.0); + } } diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java index 00ec42c6f7c..a69ceafdf07 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import io.opentelemetry.sdk.metrics.internal.view.StringPredicates; import java.util.Arrays; import java.util.HashSet; import org.junit.jupiter.api.Test; @@ -36,4 +37,29 @@ void stringRepresentation() { + "cardinalityLimit=10" + "}"); } + + @Test + void equalsAndHashCode() { + View defaultView = View.builder().build(); + View defaultViewCopy = View.builder().build(); + View explicitDefaultAggregation = + View.builder().setAggregation(Aggregation.defaultAggregation()).build(); + View explicitDefaultCardinality = View.builder().setCardinalityLimit(2000).build(); + View explicitDefaultFilter = View.builder().setAttributeFilter(StringPredicates.ALL).build(); + + assertThat(defaultView) + .isEqualTo(defaultViewCopy) + .hasSameHashCodeAs(defaultViewCopy) + .isNotEqualTo(explicitDefaultAggregation) + .isNotEqualTo(explicitDefaultCardinality) + .isNotEqualTo(explicitDefaultFilter); + + assertThat(explicitDefaultAggregation.hashCode()).isNotEqualTo(defaultView.hashCode()); + assertThat(explicitDefaultCardinality.hashCode()).isNotEqualTo(defaultView.hashCode()); + assertThat(explicitDefaultFilter.hashCode()).isNotEqualTo(defaultView.hashCode()); + + View sum1 = View.builder().setAggregation(Aggregation.sum()).build(); + View sum2 = View.builder().setAggregation(Aggregation.sum()).build(); + assertThat(sum1).isEqualTo(sum2).hasSameHashCodeAs(sum2); + } } diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTestUtil.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTestUtil.java new file mode 100644 index 00000000000..3b42584e90c --- /dev/null +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTestUtil.java @@ -0,0 +1,48 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.metrics.internal.debug.SourceInfo; +import io.opentelemetry.sdk.metrics.internal.view.AttributesFilters; +import io.opentelemetry.sdk.metrics.internal.view.RegisteredView; +import java.util.function.UnaryOperator; + +/** + * Test utility providing package-private access to {@link View} properties for tests outside the + * package. + */ +public final class ViewTestUtil { + private ViewTestUtil() {} + + /** Creates a {@link RegisteredView} with explicit properties extracted from the {@link View}. */ + public static RegisteredView createRegisteredView( + InstrumentSelector selector, + View view, + UnaryOperator attributesFilter, + int cardinalityLimit, + SourceInfo sourceInfo) { + return RegisteredView.create( + selector, + view, + attributesFilter, + cardinalityLimit, + sourceInfo, + view.getExplicitAggregation(), + view.getExplicitAttributeFilter(), + view.getExplicitCardinalityLimit()); + } + + /** Creates a {@link RegisteredView} with default attributes filter and cardinality limit. */ + public static RegisteredView createRegisteredView(InstrumentSelector selector, View view) { + return createRegisteredView( + selector, + view, + AttributesFilters.ALLOW_ALL, + view.getCardinalityLimit(), + SourceInfo.fromCurrentStack()); + } +} diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicateTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicateTest.java new file mode 100644 index 00000000000..69811c368e9 --- /dev/null +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/CompositeAndPredicateTest.java @@ -0,0 +1,56 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.view; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +class CompositeAndPredicateTest { + + @Test + void create_Empty() { + assertThat(CompositeAndPredicate.create(Collections.emptyList())) + .isSameAs(StringPredicates.ALL); + } + + @Test + void create_Single() { + Predicate pred = s -> true; + assertThat(CompositeAndPredicate.create(Collections.singletonList(pred))).isSameAs(pred); + } + + @Test + void test_LogicalAnd() { + Predicate p1 = s -> s.startsWith("a"); + Predicate p2 = s -> s.endsWith("z"); + Predicate composite = CompositeAndPredicate.create(Arrays.asList(p1, p2)); + + assertThat(composite.test("abz")).isTrue(); + assertThat(composite.test("ab")).isFalse(); + assertThat(composite.test("bz")).isFalse(); + } + + @Test + void equalsAndHashCode() { + Predicate p1 = StringPredicates.exact("a"); + Predicate p2 = StringPredicates.exact("b"); + Predicate p3 = StringPredicates.exact("c"); + + Predicate c1 = CompositeAndPredicate.create(Arrays.asList(p1, p2)); + Predicate c2 = CompositeAndPredicate.create(Arrays.asList(p1, p2)); + Predicate c3 = CompositeAndPredicate.create(Arrays.asList(p1, p3)); + + assertThat(c1).isEqualTo(c2).hasSameHashCodeAs(c2).isNotEqualTo(c3); + + Predicate nested = CompositeAndPredicate.create(Arrays.asList(c1, p3)); + Predicate flat = CompositeAndPredicate.create(Arrays.asList(p1, p2, p3)); + assertThat(nested).isEqualTo(flat).hasSameHashCodeAs(flat); + } +} diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistryTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistryTest.java index 492518edb37..b236c474ff0 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistryTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/view/ViewRegistryTest.java @@ -20,14 +20,15 @@ import io.opentelemetry.sdk.metrics.InstrumentType; import io.opentelemetry.sdk.metrics.InstrumentValueType; import io.opentelemetry.sdk.metrics.View; +import io.opentelemetry.sdk.metrics.ViewTestUtil; import io.opentelemetry.sdk.metrics.export.CardinalityLimitSelector; import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector; -import io.opentelemetry.sdk.metrics.internal.debug.SourceInfo; import io.opentelemetry.sdk.metrics.internal.descriptor.Advice; import io.opentelemetry.sdk.metrics.internal.descriptor.InstrumentDescriptor; -import io.opentelemetry.sdk.metrics.internal.state.MetricStorage; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; +import java.util.List; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -43,12 +44,7 @@ class ViewRegistryTest { .build(); private static RegisteredView registeredView(InstrumentSelector instrumentSelector, View view) { - return RegisteredView.create( - instrumentSelector, - view, - AttributesFilters.ALLOW_ALL, - MetricStorage.DEFAULT_MAX_CARDINALITY, - SourceInfo.fromCurrentStack()); + return ViewTestUtil.createRegisteredView(instrumentSelector, view); } @Test @@ -538,4 +534,585 @@ private static void assertAdviceFilter(RegisteredView view, AttributeKey... e .asInstanceOf(InstanceOfAssertFactories.collection(AttributeKey.class)) .containsExactlyInAnyOrder(expectedKeys); } + + @Test + void findComposableViews_SingleMatchingView() { + RegisteredView registeredView = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setDescription("custom description").build()); + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Collections.singletonList(registeredView), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getDescription()).isEqualTo("custom description"); + assertThat(views.get(0).getView().getAggregation()).isEqualTo(Aggregation.defaultAggregation()); + } + + @Test + void findComposableViews_LastWinsPrecedence() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder() + .setDescription("description 1") + .setCardinalityLimit(100) + .setAggregation(Aggregation.sum()) + .build()); + RegisteredView view2 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setDescription("description 2").setCardinalityLimit(500).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getDescription()).isEqualTo("description 2"); + assertThat(views.get(0).getCardinalityLimit()).isEqualTo(500); + assertThat(views.get(0).getView().getAggregation()).isEqualTo(Aggregation.sum()); + } + + @Test + void findComposableViews_AttributeFilterConjunction() { + RegisteredView policyView = + registeredView( + InstrumentSelector.builder().setName("*").build(), + View.builder() + .setAttributeFilter(new HashSet<>(Arrays.asList("keep1", "keep2", "keep3"))) + .build()); + RegisteredView serviceView = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder() + .setAttributeFilter(new HashSet<>(Arrays.asList("keep1", "keep2"))) + .build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(policyView, serviceView), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + Attributes attributes = + Attributes.builder() + .put("keep1", "v1") + .put("keep2", "v2") + .put("keep3", "v3") + .put("drop", "v4") + .build(); + Attributes filtered = views.get(0).getAttributesFilter().apply(attributes); + assertThat(filtered.asMap()).containsOnlyKeys(stringKey("keep1"), stringKey("keep2")); + } + + @Test + void findComposableViews_AttributeFilterOverridesAdvice() { + RegisteredView view = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAttributeFilter(Collections.singleton("custom_key")).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Collections.singletonList(view), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.builder() + .setAttributes(Collections.singletonList(stringKey("advice_key"))) + .build()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + Attributes attributes = + Attributes.builder().put("custom_key", "v1").put("advice_key", "v2").build(); + Attributes filtered = views.get(0).getAttributesFilter().apply(attributes); + assertThat(filtered.asMap()).containsOnlyKeys(stringKey("custom_key")); + } + + @Test + void findComposableViews_UnspecifiedAttributeFilterPreservesAdvice() { + RegisteredView view = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setDescription("custom description").build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Collections.singletonList(view), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.builder() + .setAttributes(Collections.singletonList(stringKey("advice_key"))) + .build()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + Attributes attributes = + Attributes.builder().put("advice_key", "v1").put("other_key", "v2").build(); + Attributes filtered = views.get(0).getAttributesFilter().apply(attributes); + assertThat(filtered.asMap()).containsOnlyKeys(stringKey("advice_key")); + } + + @Test + void findComposableViews_SemanticIncompatibilityFallback() { + RegisteredView validView = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAggregation(Aggregation.sum()).build()); + RegisteredView invalidView = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAggregation(Aggregation.lastValue()).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(validView, invalidView), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getAggregation()).isEqualTo(Aggregation.sum()); + assertThat(logs.getEvents()).hasSize(1); + assertThat(logs.getEvents().get(0).getMessage()) + .contains( + "View aggregation last_value is incompatible with instrument my_counter of type COUNTER"); + } + + @Test + void findComposableViews_DistinctConfiguredStreamNames() { + RegisteredView globalPolicy = + registeredView( + InstrumentSelector.builder().setName("*").build(), + View.builder().setAttributeFilter(Collections.singleton("allowed_key")).build()); + RegisteredView streamA = + registeredView( + InstrumentSelector.builder().setName("my_histogram").build(), + View.builder().setName("histogram.stream_a").setAggregation(Aggregation.sum()).build()); + RegisteredView streamB = + registeredView( + InstrumentSelector.builder().setName("my_histogram").build(), + View.builder() + .setName("histogram.stream_b") + .setAggregation(Aggregation.explicitBucketHistogram()) + .build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(globalPolicy, streamA, streamB), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_histogram", + "desc", + "ms", + InstrumentType.HISTOGRAM, + InstrumentValueType.DOUBLE, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(2); + RegisteredView resA = + views.stream() + .filter(v -> "histogram.stream_a".equals(v.getView().getName())) + .findFirst() + .get(); + RegisteredView resB = + views.stream() + .filter(v -> "histogram.stream_b".equals(v.getView().getName())) + .findFirst() + .get(); + + assertThat(resA.getView().getAggregation()).isEqualTo(Aggregation.sum()); + assertThat(resB.getView().getAggregation()).isEqualTo(Aggregation.explicitBucketHistogram()); + + Attributes attrs = + Attributes.builder().put("allowed_key", "v1").put("dropped_key", "v2").build(); + assertThat(resA.getAttributesFilter().apply(attrs).asMap()) + .containsOnlyKeys(stringKey("allowed_key")); + assertThat(resB.getAttributesFilter().apply(attrs).asMap()) + .containsOnlyKeys(stringKey("allowed_key")); + } + + @Test + void findComposableViews_ExplicitDefaultAggregationResetsPreceding() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_histogram").build(), + View.builder().setAggregation(Aggregation.base2ExponentialBucketHistogram()).build()); + RegisteredView view2ExplicitDefault = + registeredView( + InstrumentSelector.builder().setName("my_histogram").build(), + View.builder().setAggregation(Aggregation.defaultAggregation()).build()); + + InstrumentDescriptor histogramDesc = + InstrumentDescriptor.create( + "my_histogram", + "desc", + "ms", + InstrumentType.HISTOGRAM, + InstrumentValueType.DOUBLE, + Advice.empty()); + + ViewRegistry registryWithExplicitDefault = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2ExplicitDefault), + ViewMatchingMode.COMPOSABLE); + + List views = + registryWithExplicitDefault.findViews(histogramDesc, INSTRUMENTATION_SCOPE_INFO); + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getAggregation()).isEqualTo(Aggregation.defaultAggregation()); + + RegisteredView view2Unspecified = + registeredView( + InstrumentSelector.builder().setName("my_histogram").build(), + View.builder().setDescription("updated description").build()); + + ViewRegistry registryWithUnspecified = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2Unspecified), + ViewMatchingMode.COMPOSABLE); + + List viewsUnspecified = + registryWithUnspecified.findViews(histogramDesc, INSTRUMENTATION_SCOPE_INFO); + assertThat(viewsUnspecified).hasSize(1); + assertThat(viewsUnspecified.get(0).getView().getAggregation()) + .isEqualTo(Aggregation.base2ExponentialBucketHistogram()); + assertThat(viewsUnspecified.get(0).getView().getDescription()).isEqualTo("updated description"); + } + + @Test + void findComposableViews_ExplicitDefaultCardinalityResetsPreceding() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setCardinalityLimit(100).build()); + RegisteredView view2ExplicitDefault = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setCardinalityLimit(2000).build()); + + InstrumentDescriptor counterDesc = + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()); + + ViewRegistry registry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2ExplicitDefault), + ViewMatchingMode.COMPOSABLE); + + List views = registry.findViews(counterDesc, INSTRUMENTATION_SCOPE_INFO); + assertThat(views).hasSize(1); + assertThat(views.get(0).getCardinalityLimit()).isEqualTo(2000); + + RegisteredView view2Unspecified = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setDescription("desc2").build()); + + ViewRegistry registryUnspecified = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2Unspecified), + ViewMatchingMode.COMPOSABLE); + + List viewsUnspecified = + registryUnspecified.findViews(counterDesc, INSTRUMENTATION_SCOPE_INFO); + assertThat(viewsUnspecified).hasSize(1); + assertThat(viewsUnspecified.get(0).getCardinalityLimit()).isEqualTo(100); + } + + @Test + void findComposableViews_AllConfiguredAggregationsIncompatible() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAggregation(Aggregation.lastValue()).build()); + RegisteredView view2 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAggregation(Aggregation.lastValue()).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getAggregation()).isEqualTo(Aggregation.defaultAggregation()); + assertThat(logs.getEvents()).hasSize(2); + assertThat(logs.getEvents().get(0).getMessage()) + .contains( + "View aggregation last_value is incompatible with instrument my_counter of type COUNTER"); + assertThat(logs.getEvents().get(1).getMessage()) + .contains( + "View aggregation last_value is incompatible with instrument my_counter of type COUNTER"); + } + + @Test + void findComposableViews_ThreeOrMoreViewsMerged() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("a", "b", "c"))).build()); + RegisteredView view2 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder() + .setAttributeFilter(new HashSet<>(Arrays.asList("b", "c", "d"))) + .setDescription("composed desc") + .build()); + RegisteredView view3 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setCardinalityLimit(500).setAggregation(Aggregation.sum()).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2, view3), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + RegisteredView composed = views.get(0); + assertThat(composed.getView().getDescription()).isEqualTo("composed desc"); + assertThat(composed.getCardinalityLimit()).isEqualTo(500); + assertThat(composed.getView().getAggregation()).isEqualTo(Aggregation.sum()); + + Attributes attrs = + Attributes.builder() + .put("a", "1") + .put("b", "2") + .put("c", "3") + .put("d", "4") + .put("e", "5") + .build(); + assertThat(composed.getAttributesFilter().apply(attrs).asMap()) + .containsOnlyKeys(stringKey("b"), stringKey("c")); + } + + @Test + void findComposableViews_CaseInsensitiveStreamNamesGrouped() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setName("HttpRequests").setDescription("desc1").build()); + RegisteredView view2 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setName("httprequests").setDescription("desc2").build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + // Both views differing only in case must merge into 1 stream group + assertThat(views).hasSize(1); + // Target stream name uses the casing of the last configured view in the group + assertThat(views.get(0).getView().getName()).isEqualTo("httprequests"); + assertThat(views.get(0).getView().getDescription()).isEqualTo("desc2"); + } + + @Test + void findComposableViews_MultipleFiltersEqualAcrossInvocations() { + RegisteredView view1 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("a", "b"))).build()); + RegisteredView view2 = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setAttributeFilter(new HashSet<>(Arrays.asList("b", "c"))).build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Arrays.asList(view1, view2), + ViewMatchingMode.COMPOSABLE); + + InstrumentDescriptor descriptor = + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()); + + List views1 = viewRegistry.findViews(descriptor, INSTRUMENTATION_SCOPE_INFO); + List views2 = viewRegistry.findViews(descriptor, INSTRUMENTATION_SCOPE_INFO); + + assertThat(views1).hasSize(1); + assertThat(views2).hasSize(1); + + View composedView1 = views1.get(0).getView(); + View composedView2 = views2.get(0).getView(); + + // Verify value equality and hashCode contract across separate invocations + assertThat(composedView1).isEqualTo(composedView2).hasSameHashCodeAs(composedView2); + } + + @Test + void findComposableViews_UnicodeStreamNamesDoNotCrash() { + // Turkish capital I with dot above (\u0130) lowercases to \u0069\u0307 in Locale.ROOT (length 2 + // vs 1) + String unicodeName = "İ"; + RegisteredView view = + registeredView( + InstrumentSelector.builder().setName("my_counter").build(), + View.builder().setName(unicodeName).setDescription("unicode desc").build()); + + ViewRegistry viewRegistry = + ViewRegistry.create( + DefaultAggregationSelector.getDefault(), + CardinalityLimitSelector.defaultCardinalityLimitSelector(), + Collections.singletonList(view), + ViewMatchingMode.COMPOSABLE); + + List views = + viewRegistry.findViews( + InstrumentDescriptor.create( + "my_counter", + "desc", + "1", + InstrumentType.COUNTER, + InstrumentValueType.LONG, + Advice.empty()), + INSTRUMENTATION_SCOPE_INFO); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getView().getName()).isEqualTo(unicodeName); + assertThat(views.get(0).getView().getDescription()).isEqualTo("unicode desc"); + } } From ad6265a322f80f59fbad875b21bcc8fd8bdb6456 Mon Sep 17 00:00:00 2001 From: navneet singh Date: Fri, 13 Nov 2026 10:44:13 +0530 Subject: [PATCH 2/2] fix(metrics): exclude internal tracking fields from View equality and remove redundant test --- .../opentelemetry-sdk-metrics.txt | 5 ++- .../io/opentelemetry/sdk/metrics/View.java | 33 +++++++++++++++++++ .../sdk/metrics/SdkMeterTest.java | 28 ---------------- .../opentelemetry/sdk/metrics/ViewTest.java | 14 ++++---- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-metrics.txt b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-metrics.txt index f7029245718..0bb03032278 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-metrics.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-metrics.txt @@ -1,2 +1,5 @@ Comparing source compatibility of opentelemetry-sdk-metrics-1.69.0-SNAPSHOT.jar against opentelemetry-sdk-metrics-1.68.0.jar -No changes. \ No newline at end of file +*** MODIFIED CLASS: PUBLIC ABSTRACT io.opentelemetry.sdk.metrics.View (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) FINAL(+) boolean equals(java.lang.Object) + +++ NEW METHOD: PUBLIC(+) FINAL(+) int hashCode() diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java index d9ec7175cac..9f510e491e3 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/View.java @@ -6,6 +6,7 @@ package io.opentelemetry.sdk.metrics; import com.google.auto.value.AutoValue; +import java.util.Objects; import java.util.StringJoiner; import java.util.function.Predicate; import javax.annotation.Nullable; @@ -105,4 +106,36 @@ public final String toString() { joiner.add("cardinalityLimit=" + getCardinalityLimit()); return joiner.toString(); } + + @Override + public final boolean equals(@Nullable Object o) { + if (o == this) { + return true; + } + if (o instanceof View) { + View that = (View) o; + return Objects.equals(this.getName(), that.getName()) + && Objects.equals(this.getDescription(), that.getDescription()) + && this.getAggregation().equals(that.getAggregation()) + && this.getAttributeFilter().equals(that.getAttributeFilter()) + && this.getCardinalityLimit() == that.getCardinalityLimit(); + } + return false; + } + + @Override + public final int hashCode() { + int hash = 1; + hash *= 1000003; + hash ^= (getName() == null) ? 0 : getName().hashCode(); + hash *= 1000003; + hash ^= (getDescription() == null) ? 0 : getDescription().hashCode(); + hash *= 1000003; + hash ^= getAggregation().hashCode(); + hash *= 1000003; + hash ^= getAttributeFilter().hashCode(); + hash *= 1000003; + hash ^= getCardinalityLimit(); + return hash; + } } diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java index 99619a3d11f..69ed46805e1 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/SdkMeterTest.java @@ -568,34 +568,6 @@ void composableViews_AsyncGaugeRegisteredTwiceProducesSingleStream() { assertThat(metric.getName()).isEqualTo("duplicate_gauge"); } - @Test - void independentViews_TwoViewsWithIdenticalFilter_DoesNotDoubleCount() { - InMemoryMetricReader reader = InMemoryMetricReader.create(); - SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); - - builder.registerView( - InstrumentSelector.builder().setName("*").build(), - View.builder().setAttributeFilter(Collections.singleton("a")).build()); - builder.registerView( - InstrumentSelector.builder().setName("my_counter").build(), - View.builder().setAttributeFilter(Collections.singleton("a")).build()); - - SdkMeterProvider provider = builder.build(); - Meter meter = provider.get("test"); - - LongCounter counter = meter.counterBuilder("my_counter").build(); - counter.add(1, Attributes.of(AttributeKey.stringKey("a"), "val")); - - Collection metrics = reader.collectAllMetrics(); - // In independent mode without filter value equality, conflicting views emit 2 streams of value - // 1 each - for (MetricData metric : metrics) { - for (LongPointData point : metric.getLongSumData().getPoints()) { - assertThat(point.getValue()).isEqualTo(1); - } - } - } - @Test void independentViews_SameViewInstanceMatchingMultipleSelectors_DoesNotDoubleCount() { InMemoryMetricReader reader = InMemoryMetricReader.create(); diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java index a69ceafdf07..18ad4d44b52 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/ViewTest.java @@ -50,16 +50,16 @@ void equalsAndHashCode() { assertThat(defaultView) .isEqualTo(defaultViewCopy) .hasSameHashCodeAs(defaultViewCopy) - .isNotEqualTo(explicitDefaultAggregation) - .isNotEqualTo(explicitDefaultCardinality) - .isNotEqualTo(explicitDefaultFilter); - - assertThat(explicitDefaultAggregation.hashCode()).isNotEqualTo(defaultView.hashCode()); - assertThat(explicitDefaultCardinality.hashCode()).isNotEqualTo(defaultView.hashCode()); - assertThat(explicitDefaultFilter.hashCode()).isNotEqualTo(defaultView.hashCode()); + .isEqualTo(explicitDefaultAggregation) + .hasSameHashCodeAs(explicitDefaultAggregation) + .isEqualTo(explicitDefaultCardinality) + .hasSameHashCodeAs(explicitDefaultCardinality) + .isEqualTo(explicitDefaultFilter) + .hasSameHashCodeAs(explicitDefaultFilter); View sum1 = View.builder().setAggregation(Aggregation.sum()).build(); View sum2 = View.builder().setAggregation(Aggregation.sum()).build(); assertThat(sum1).isEqualTo(sum2).hasSameHashCodeAs(sum2); + assertThat(sum1).isNotEqualTo(defaultView); } }