diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 3fd2f5fc38..3c4fed3dc3 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -21,7 +21,8 @@ val DEPENDENCY_BOMS = listOf( "io.zipkin.brave:brave-bom:5.13.7", "io.zipkin.reporter2:zipkin-reporter-bom:2.16.3", "org.junit:junit-bom:5.8.2", - "org.testcontainers:testcontainers-bom:1.16.3" + "org.testcontainers:testcontainers-bom:1.16.3", + "org.yaml:snakeyaml:1.30" ) val DEPENDENCY_SETS = listOf( diff --git a/sdk-extensions/metric-incubator/README.md b/sdk-extensions/metric-incubator/README.md new file mode 100644 index 0000000000..36d750ae79 --- /dev/null +++ b/sdk-extensions/metric-incubator/README.md @@ -0,0 +1,83 @@ +# OpenTelemetry Metric Incubator + +[![Javadocs][javadoc-image]][javadoc-url] + +This artifact contains experimental code related to metrics. + +## View File Configuration + +Adds support for file based YAML configuration of Metric SDK Views. + +For example, suppose `/Users/user123/view.yaml` has the following content: + +```yaml +- selector: + instrument_name: my-instrument + instrument_type: COUNTER + meter_name: my-meter + meter_version: 1.0.0 + meter_schema_url: http://example.com + view: + name: new-instrument-name + description: new-description + aggregation: histogram + attribute_keys: + - foo + - bar +``` + +The equivalent view configuration would be: + +``` +SdkMeterProvider.builder() + .registerView( + InstrumentSelector.builder() + .setInstrumentName("my-instrument") + .setInstrumentType(InstrumentType.COUNTER) + .setMeterSelector( + MeterSelector.builder() + .setName("my-meter") + .setVersion("1.0.0") + .setSchemaUrl("http://example.com") + .build()) + .build(), + View.builder() + .setName("new-instrument") + .setDescription("new-description") + .setAggregation(Aggregation.histogram()) + .filterAttributes(key -> new HashSet<>(Arrays.asList("foo", "bar")).contains(key)) + .build()); +``` + +If using [autoconfigure](../autoconfigure) with this artifact on your classpath, it will automatically load a list of view config files specified via environment variable or system property: + +| System property | Environment variable | Purpose | +|---------------------------------------|---------------------------------------|------------------------------------------------------| +| otel.experimental.metrics.view-config | OTEL_EXPERIMENTAL_METRICS_VIEW_CONFIG | List of files containing view configuration YAML [1] | + +**[1]** In addition to absolute paths, resources on the classpath packaged with a jar can be loaded. +For example, `otel.experimental.metrics.view-config=classpath:/my-view.yaml` loads the +resource `/my-view.yaml`. + +If not using autoconfigure, a file can be used to configure views as follows: + +``` +SdkMeterProviderBuilder builder = SdkMeterProvider.builder(); +try (FileInputStream fileInputStream = new FileInputStream("/Users/user123/view.yaml")) { + ViewConfig.registerViews(builder, fileInputStream); +} +``` + +Notes on usage: + +- Many view configurations can live in one file. The YAML is parsed as an array of view + configurations. +- At least one selection field is required, but including all is not necessary. Any omitted fields + will result in the default from `InstrumentSelector` being used. +- At least one view field is required, but including all is not required. Any omitted fields will + result in the default from `View` being used. +- Advanced selection criteria, like regular expressions, is not yet supported. + +[javadoc-image]: https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-metric-incubator + +[javadoc-url]: https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-metric-incubator diff --git a/sdk-extensions/metric-incubator/build.gradle.kts b/sdk-extensions/metric-incubator/build.gradle.kts new file mode 100644 index 0000000000..e1eb309d4a --- /dev/null +++ b/sdk-extensions/metric-incubator/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("otel.java-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry SDK Metric Incubator" +otelJava.moduleName.set("io.opentelemetry.sdk.extension.metric.incubator") + +dependencies { + implementation(project(":sdk:metrics")) + implementation(project(":sdk-extensions:autoconfigure-spi")) + + implementation("org.yaml:snakeyaml") + + annotationProcessor("com.google.auto.value:auto-value") + + testImplementation(project(":sdk:testing")) + testImplementation(project(":sdk-extensions:autoconfigure")) + testImplementation(project(":sdk:metrics-testing")) + + testImplementation("com.google.guava:guava") +} diff --git a/sdk-extensions/metric-incubator/gradle.properties b/sdk-extensions/metric-incubator/gradle.properties new file mode 100644 index 0000000000..4476ae57e3 --- /dev/null +++ b/sdk-extensions/metric-incubator/gradle.properties @@ -0,0 +1 @@ +otel.release=alpha diff --git a/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/SelectorSpecification.java b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/SelectorSpecification.java new file mode 100644 index 0000000000..1ff1d06d0d --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/SelectorSpecification.java @@ -0,0 +1,48 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import com.google.auto.value.AutoValue; +import io.opentelemetry.sdk.metrics.common.InstrumentType; +import javax.annotation.Nullable; + +@AutoValue +abstract class SelectorSpecification { + + static AutoValue_SelectorSpecification.Builder builder() { + return new AutoValue_SelectorSpecification.Builder(); + } + + @Nullable + abstract String getInstrumentName(); + + @Nullable + abstract InstrumentType getInstrumentType(); + + @Nullable + abstract String getMeterName(); + + @Nullable + abstract String getMeterVersion(); + + @Nullable + abstract String getMeterSchemaUrl(); + + @AutoValue.Builder + interface Builder { + Builder instrumentName(@Nullable String instrumentName); + + Builder instrumentType(@Nullable InstrumentType instrumentType); + + Builder meterName(@Nullable String meterName); + + Builder meterVersion(@Nullable String meterVersion); + + Builder meterSchemaUrl(@Nullable String meterSchemaUrl); + + SelectorSpecification build(); + } +} diff --git a/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfig.java b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfig.java new file mode 100644 index 0000000000..d5af5b317c --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfig.java @@ -0,0 +1,228 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toList; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.common.InstrumentType; +import io.opentelemetry.sdk.metrics.view.Aggregation; +import io.opentelemetry.sdk.metrics.view.InstrumentSelector; +import io.opentelemetry.sdk.metrics.view.MeterSelector; +import io.opentelemetry.sdk.metrics.view.View; +import io.opentelemetry.sdk.metrics.view.ViewBuilder; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; +import org.yaml.snakeyaml.Yaml; + +/** + * Enables file based YAML configuration of Metric SDK {@link View}s. + * + *

For example, a YAML file with the following content: + * + *

+ *   - selector:
+ *       instrument_name: my-instrument
+ *       instrument_type: COUNTER
+ *       meter_name: my-meter
+ *       meter_version: 1.0.0
+ *       meter_schema_url: http://example.com
+ *     view:
+ *       name: new-instrument-name
+ *       description: new-description
+ *       aggregation: histogram
+ *       attribute_keys:
+ *         - foo
+ *         - bar
+ * 
+ * + *

Is equivalent to the following configuration: + * + *

{@code
+ * SdkMeterProvider.builder()
+ *     .registerView(
+ *         InstrumentSelector.builder()
+ *             .setInstrumentName("my-instrument")
+ *             .setInstrumentType(InstrumentType.COUNTER)
+ *             .setMeterSelector(
+ *                 MeterSelector.builder()
+ *                     .setName("my-meter")
+ *                     .setVersion("1.0.0")
+ *                     .setSchemaUrl("http://example.com")
+ *                     .build())
+ *             .build(),
+ *         View.builder()
+ *             .setName("new-instrument")
+ *             .setDescription("new-description")
+ *             .setAggregation(Aggregation.histogram())
+ *             .filterAttributes(key -> new HashSet<>(Arrays.asList("foo", "bar")).contains(key))
+ *             .build());
+ * }
+ */ +public final class ViewConfig { + + private ViewConfig() {} + + /** + * Load the view configuration YAML from the {@code inputStream} and apply it to the {@link + * SdkMeterProviderBuilder}. + * + * @throws ConfigurationException if unable to interpret {@code inputStream} contents + */ + public static void registerViews( + SdkMeterProviderBuilder meterProviderBuilder, InputStream inputStream) { + List viewConfigSpecs = loadViewConfig(inputStream); + + for (ViewConfigSpecification viewConfigSpec : viewConfigSpecs) { + meterProviderBuilder.registerView( + toInstrumentSelector(viewConfigSpec.getSelectorSpecification()), + toView(viewConfigSpec.getViewSpecification())); + } + } + + // Visible for testing + @SuppressWarnings("unchecked") + static List loadViewConfig(InputStream inputStream) { + Yaml yaml = new Yaml(); + try { + List result = new ArrayList<>(); + List> viewConfigs = yaml.load(inputStream); + for (Map viewConfigSpecMap : viewConfigs) { + Map selectorSpecMap = + requireNonNull( + getAsType(viewConfigSpecMap, "selector", Map.class), "selector is required"); + Map viewSpecMap = + requireNonNull(getAsType(viewConfigSpecMap, "view", Map.class), "view is required"); + + InstrumentType instrumentType = + Optional.ofNullable(getAsType(selectorSpecMap, "instrument_type", String.class)) + .map(InstrumentType::valueOf) + .orElse(null); + List attributeKeys = + Optional.ofNullable( + ((List) getAsType(viewSpecMap, "attribute_keys", List.class))) + .map(objects -> objects.stream().map(String::valueOf).collect(toList())) + .orElse(null); + + result.add( + ViewConfigSpecification.builder() + .selectorSpecification( + SelectorSpecification.builder() + .instrumentName(getAsType(selectorSpecMap, "instrument_name", String.class)) + .instrumentType(instrumentType) + .meterName(getAsType(selectorSpecMap, "meter_name", String.class)) + .meterVersion(getAsType(selectorSpecMap, "meter_version", String.class)) + .meterSchemaUrl( + getAsType(selectorSpecMap, "meter_schema_url", String.class)) + .build()) + .viewSpecification( + ViewSpecification.builder() + .name(getAsType(viewSpecMap, "name", String.class)) + .description(getAsType(viewSpecMap, "description", String.class)) + .aggregation(getAsType(viewSpecMap, "aggregation", String.class)) + .attributeKeys(attributeKeys) + .build()) + .build()); + } + return result; + } catch (RuntimeException e) { + throw new ConfigurationException("Failed to parse view config", e); + } + } + + @Nullable + @SuppressWarnings("unchecked") + private static T getAsType(Map map, String key, Class type) { + Object value = map.get(key); + if (value != null && !type.isInstance(value)) { + throw new IllegalStateException( + "Expected " + + key + + " to be type " + + type.getName() + + " but was " + + value.getClass().getName()); + } + return (T) value; + } + + // Visible for testing + static View toView(ViewSpecification viewSpec) { + ViewBuilder builder = View.builder(); + String name = viewSpec.getName(); + if (name != null) { + builder.setName(name); + } + String description = viewSpec.getDescription(); + if (description != null) { + builder.setDescription(description); + } + String aggregation = viewSpec.getAggregation(); + if (aggregation != null) { + builder.setAggregation(toAggregation(aggregation)); + } + List attributeKeys = viewSpec.getAttributeKeys(); + if (attributeKeys != null) { + Set keySet = new HashSet<>(attributeKeys); + builder.filterAttributes(keySet::contains); + } + return builder.build(); + } + + // Visible for testing + static Aggregation toAggregation(String aggregation) { + switch (aggregation) { + case "sum": + return Aggregation.sum(); + case "last_value": + return Aggregation.lastValue(); + case "drop": + return Aggregation.drop(); + case "histogram": + return Aggregation.explicitBucketHistogram(); + default: + throw new ConfigurationException("Unrecognized aggregation " + aggregation); + } + } + + // Visible for testing + static InstrumentSelector toInstrumentSelector(SelectorSpecification selectorSpec) { + InstrumentSelector.Builder builder = InstrumentSelector.builder(); + String instrumentName = selectorSpec.getInstrumentName(); + if (instrumentName != null) { + builder.setInstrumentName(instrumentName); + } + InstrumentType instrumentType = selectorSpec.getInstrumentType(); + if (instrumentType != null) { + builder.setInstrumentType(instrumentType); + } + + MeterSelector.Builder meterBuilder = MeterSelector.builder(); + String meterName = selectorSpec.getMeterName(); + if (meterName != null) { + meterBuilder.setName(meterName); + } + String meterVersion = selectorSpec.getMeterVersion(); + if (meterVersion != null) { + meterBuilder.setVersion(meterVersion); + } + String meterSchemaUrl = selectorSpec.getMeterSchemaUrl(); + if (meterSchemaUrl != null) { + meterBuilder.setSchemaUrl(meterSchemaUrl); + } + builder.setMeterSelector(meterBuilder.build()); + + return builder.build(); + } +} diff --git a/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizer.java b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizer.java new file mode 100644 index 0000000000..7d8303a4f2 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizer.java @@ -0,0 +1,63 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** SPI implementation for loading view configuration YAML. */ +public final class ViewConfigCustomizer implements AutoConfigurationCustomizerProvider { + + @Override + public void customize(AutoConfigurationCustomizer autoConfiguration) { + autoConfiguration.addMeterProviderCustomizer(ViewConfigCustomizer::customizeMeterProvider); + } + + // Visible for testing + static SdkMeterProviderBuilder customizeMeterProvider( + SdkMeterProviderBuilder meterProviderBuilder, ConfigProperties configProperties) { + List configFileLocations = + configProperties.getList("otel.experimental.metrics.view.config"); + for (String configFileLocation : configFileLocations) { + if (configFileLocation.startsWith("classpath:")) { + String classpathLocation = configFileLocation.substring("classpath:".length()); + try (InputStream inputStream = + ViewConfigCustomizer.class.getResourceAsStream(classpathLocation)) { + if (inputStream == null) { + throw new ConfigurationException( + "Resource " + + classpathLocation + + " not found on classpath of classloader " + + ViewConfigCustomizer.class.getClassLoader().getClass().getName()); + } + ViewConfig.registerViews(meterProviderBuilder, inputStream); + } catch (IOException e) { + throw new ConfigurationException( + "An error occurred reading view config resource on classpath: " + classpathLocation, + e); + } + } else { + try (FileInputStream fileInputStream = new FileInputStream(configFileLocation)) { + ViewConfig.registerViews(meterProviderBuilder, fileInputStream); + } catch (FileNotFoundException e) { + throw new ConfigurationException("View config file not found: " + configFileLocation, e); + } catch (IOException e) { + throw new ConfigurationException( + "An error occurred reading view config file: " + configFileLocation, e); + } + } + } + return meterProviderBuilder; + } +} diff --git a/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigSpecification.java b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigSpecification.java new file mode 100644 index 0000000000..aad1e67ce3 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewConfigSpecification.java @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import com.google.auto.value.AutoValue; + +@AutoValue +abstract class ViewConfigSpecification { + + static AutoValue_ViewConfigSpecification.Builder builder() { + return new AutoValue_ViewConfigSpecification.Builder(); + } + + abstract SelectorSpecification getSelectorSpecification(); + + abstract ViewSpecification getViewSpecification(); + + @AutoValue.Builder + interface Builder { + Builder selectorSpecification(SelectorSpecification selectorSpecification); + + Builder viewSpecification(ViewSpecification viewSpecification); + + ViewConfigSpecification build(); + } +} diff --git a/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewSpecification.java b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewSpecification.java new file mode 100644 index 0000000000..6495a4a488 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/java/io/opentelemetry/sdk/viewconfig/ViewSpecification.java @@ -0,0 +1,43 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import com.google.auto.value.AutoValue; +import java.util.List; +import javax.annotation.Nullable; + +@AutoValue +abstract class ViewSpecification { + + static AutoValue_ViewSpecification.Builder builder() { + return new AutoValue_ViewSpecification.Builder(); + } + + @Nullable + abstract String getName(); + + @Nullable + abstract String getDescription(); + + @Nullable + abstract String getAggregation(); + + @Nullable + abstract List getAttributeKeys(); + + @AutoValue.Builder + interface Builder { + Builder name(@Nullable String name); + + Builder description(@Nullable String description); + + Builder aggregation(@Nullable String aggregation); + + Builder attributeKeys(@Nullable List attributeKeys); + + ViewSpecification build(); + } +} diff --git a/sdk-extensions/metric-incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider b/sdk-extensions/metric-incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider new file mode 100644 index 0000000000..e33fbf08e1 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider @@ -0,0 +1 @@ +io.opentelemetry.sdk.viewconfig.ViewConfigCustomizer \ No newline at end of file diff --git a/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizerTest.java b/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizerTest.java new file mode 100644 index 0000000000..3f16dee518 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigCustomizerTest.java @@ -0,0 +1,130 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import static io.opentelemetry.sdk.testing.assertj.MetricAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Arrays; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ViewConfigCustomizerTest { + + @Test + void customizeMeterProvider_Spi() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal(false) + .addPropertiesSupplier( + () -> { + return ImmutableMap.of( + "otel.traces.exporter", + "none", + "otel.experimental.metrics.view.config", + "classpath:/view-config-customizer-test.yaml"); + }) + .addMeterProviderCustomizer( + (meterProviderBuilder, configProperties) -> + meterProviderBuilder.registerMetricReader(reader)) + .build() + .getOpenTelemetrySdk() + .getSdkMeterProvider() + .get("test-meter") + .counterBuilder("counter") + .buildWithCallback( + callback -> { + // Attributes with keys baz and qux should be filtered out + callback.record( + 1, + Attributes.builder() + .put("foo", "val") + .put("bar", "val") + .put("baz", "val") + .put("qux", "val") + .build()); + }); + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metricData -> { + assertThat(metricData) + .hasLongSum() + .points() + .satisfiesExactly( + point -> + assertThat(point) + .hasValue(1) + .hasAttributes( + Attributes.builder() + .put("foo", "val") + .put("bar", "val") + .build())); + }); + } + + @Test + void customizeMeterProvider_MultipleFiles() { + ConfigProperties properties = + withConfigFileLocations( + "classpath:/view-config-customizer-test.yaml", "classpath:/full-config.yaml"); + + assertThatCode( + () -> + ViewConfigCustomizer.customizeMeterProvider(SdkMeterProvider.builder(), properties)) + .doesNotThrowAnyException(); + } + + @Test + void customizeMeterProvider_AbsolutePath() { + ConfigProperties properties = + withConfigFileLocations( + ViewConfigTest.class.getResource("/view-config-customizer-test.yaml").getFile()); + + assertThatCode( + () -> + ViewConfigCustomizer.customizeMeterProvider(SdkMeterProvider.builder(), properties)) + .doesNotThrowAnyException(); + } + + @Test + void customizeMeterProvider_Invalid() { + assertThatThrownBy( + () -> + ViewConfigCustomizer.customizeMeterProvider( + SdkMeterProvider.builder(), + withConfigFileLocations("classpath:" + UUID.randomUUID()))) + .hasMessageMatching("Resource .* not found on classpath of classloader .*"); + assertThatThrownBy( + () -> + ViewConfigCustomizer.customizeMeterProvider( + SdkMeterProvider.builder(), withConfigFileLocations("/" + UUID.randomUUID()))) + .hasMessageContaining("View config file not found:"); + assertThatThrownBy( + () -> + ViewConfigCustomizer.customizeMeterProvider( + SdkMeterProvider.builder(), + withConfigFileLocations("classpath:/empty-selector-config.yaml"))) + .hasMessageContaining("Failed to parse view config") + .hasRootCauseMessage("selector is required"); + } + + private static ConfigProperties withConfigFileLocations(String... fileLocations) { + ConfigProperties properties = mock(ConfigProperties.class); + when(properties.getList("otel.experimental.metrics.view.config")) + .thenReturn(Arrays.asList(fileLocations)); + return properties; + } +} diff --git a/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigTest.java b/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigTest.java new file mode 100644 index 0000000000..ecc8200904 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/java/io/opentelemetry/sdk/viewconfig/ViewConfigTest.java @@ -0,0 +1,187 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.viewconfig; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.common.InstrumentType; +import io.opentelemetry.sdk.metrics.internal.view.ViewRegistryBuilder; +import io.opentelemetry.sdk.metrics.view.Aggregation; +import io.opentelemetry.sdk.metrics.view.InstrumentSelector; +import io.opentelemetry.sdk.metrics.view.View; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +class ViewConfigTest { + + @Test + void registerViews_FullConfig() { + SdkMeterProviderBuilder builder = SdkMeterProvider.builder(); + + ViewConfig.registerViews(builder, resourceFileInputStream("full-config.yaml")); + + assertThat(builder) + .extracting( + "viewRegistryBuilder", as(InstanceOfAssertFactories.type(ViewRegistryBuilder.class))) + .extracting("orderedViews", as(InstanceOfAssertFactories.list(Object.class))) + .hasSize(2); + } + + @Test + void loadViewConfig_FullConfig() { + List viewConfigSpecs = + ViewConfig.loadViewConfig(resourceFileInputStream("full-config.yaml")); + + assertThat(viewConfigSpecs) + .satisfiesExactly( + viewConfigSpec -> { + SelectorSpecification selectorSpec = viewConfigSpec.getSelectorSpecification(); + assertThat(selectorSpec.getInstrumentName()).isEqualTo("name1"); + assertThat(selectorSpec.getInstrumentType()).isEqualTo(InstrumentType.COUNTER); + assertThat(selectorSpec.getMeterName()).isEqualTo("meterName1"); + assertThat(selectorSpec.getMeterVersion()).isEqualTo("1.0.0"); + assertThat(selectorSpec.getMeterSchemaUrl()).isEqualTo("http://example1.com"); + ViewSpecification viewSpec = viewConfigSpec.getViewSpecification(); + assertThat(viewSpec.getName()).isEqualTo("name1"); + assertThat(viewSpec.getDescription()).isEqualTo("description1"); + assertThat(viewSpec.getAggregation()).isEqualTo("sum"); + assertThat(viewSpec.getAttributeKeys()).containsExactly("foo", "bar"); + }, + viewConfigSpec -> { + SelectorSpecification selectorSpec = viewConfigSpec.getSelectorSpecification(); + assertThat(selectorSpec.getInstrumentName()).isEqualTo("name2"); + assertThat(selectorSpec.getInstrumentType()).isEqualTo(InstrumentType.COUNTER); + assertThat(selectorSpec.getMeterName()).isEqualTo("meterName2"); + assertThat(selectorSpec.getMeterVersion()).isEqualTo("2.0.0"); + assertThat(selectorSpec.getMeterSchemaUrl()).isEqualTo("http://example2.com"); + ViewSpecification viewSpec = viewConfigSpec.getViewSpecification(); + assertThat(viewSpec.getName()).isEqualTo("name2"); + assertThat(viewSpec.getDescription()).isEqualTo("description2"); + assertThat(viewSpec.getAggregation()).isEqualTo("last_value"); + assertThat(viewSpec.getAttributeKeys()).containsExactly("baz", "qux"); + }); + } + + @Test + void loadViewConfig_Invalid() { + assertThatThrownBy( + () -> ViewConfig.loadViewConfig(resourceFileInputStream("empty-view-config.yaml"))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Failed to parse view config") + .hasRootCauseMessage("view is required"); + assertThatThrownBy( + () -> ViewConfig.loadViewConfig(resourceFileInputStream("empty-selector-config.yaml"))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Failed to parse view config") + .hasRootCauseMessage("selector is required"); + } + + @Test + void toView_Empty() { + View view = ViewConfig.toView(ViewSpecification.builder().build()); + assertThat(view).isEqualTo(View.builder().build()); + } + + @Test + void toView() { + View view = + ViewConfig.toView( + ViewSpecification.builder() + .name("name") + .description("description") + .aggregation("sum") + .attributeKeys(Arrays.asList("foo", "bar")) + .build()); + assertThat(view.getName()).isEqualTo("name"); + assertThat(view.getDescription()).isEqualTo("description"); + assertThat(view.getAggregation()).isEqualTo(Aggregation.sum()); + assertThat( + view.getAttributesProcessor() + .process( + Attributes.builder() + .put("foo", "val") + .put("bar", "val") + .put("baz", "val") + .build(), + Context.current())) + .containsEntry("foo", "val") + .containsEntry("bar", "val") + .satisfies( + (Consumer) + attributes -> assertThat(attributes.get(AttributeKey.stringKey("baz"))).isBlank()); + } + + @Test + void toAggregation() { + assertThat(ViewConfig.toAggregation("sum")).isEqualTo(Aggregation.sum()); + assertThat(ViewConfig.toAggregation("last_value")).isEqualTo(Aggregation.lastValue()); + assertThat(ViewConfig.toAggregation("histogram")).isEqualTo(Aggregation.histogram()); + assertThat(ViewConfig.toAggregation("drop")).isEqualTo(Aggregation.drop()); + assertThatThrownBy(() -> ViewConfig.toAggregation("foo")) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Unrecognized aggregation foo"); + } + + @Test + void toInstrumentSelector_Empty() { + InstrumentSelector selector = + ViewConfig.toInstrumentSelector(SelectorSpecification.builder().build()); + assertThat(selector).isEqualTo(InstrumentSelector.builder().build()); + } + + @Test + void toInstrumentSelector() { + InstrumentSelector selector = + ViewConfig.toInstrumentSelector( + SelectorSpecification.builder() + .instrumentName("name") + .instrumentType(InstrumentType.COUNTER) + .meterName("meterName") + .meterVersion("meterVersion") + .meterSchemaUrl("http://example.com") + .build()); + + assertThat(selector.getInstrumentNameFilter().test("name")).isTrue(); + assertThat(selector.getInstrumentNameFilter().test("name1")).isFalse(); + assertThat(selector.getInstrumentType()).isEqualTo(InstrumentType.COUNTER); + assertThat(selector.getMeterSelector().getNameFilter().test("meterName")).isTrue(); + assertThat(selector.getMeterSelector().getNameFilter().test("meterName1")).isFalse(); + assertThat(selector.getMeterSelector().getVersionFilter().test("meterVersion")).isTrue(); + assertThat(selector.getMeterSelector().getVersionFilter().test("meterVersion1")).isFalse(); + assertThat(selector.getMeterSelector().getSchemaUrlFilter().test("http://example.com")) + .isTrue(); + assertThat(selector.getMeterSelector().getSchemaUrlFilter().test("http://example1.com")) + .isFalse(); + } + + private static InputStream resourceFileInputStream(String resourceFileName) { + URL resourceUrl = ViewConfigTest.class.getResource("/" + resourceFileName); + if (resourceUrl == null) { + throw new IllegalStateException("Could not find resource file: " + resourceFileName); + } + String path = resourceUrl.getFile(); + try { + return new FileInputStream(path); + } catch (FileNotFoundException e) { + throw new IllegalStateException("File not found: " + path, e); + } + } +} diff --git a/sdk-extensions/metric-incubator/src/test/resources/empty-selector-config.yaml b/sdk-extensions/metric-incubator/src/test/resources/empty-selector-config.yaml new file mode 100644 index 0000000000..657a53c0f8 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/resources/empty-selector-config.yaml @@ -0,0 +1,5 @@ +- selector: + view: + name: name1 + description: description1 + aggregation: sum diff --git a/sdk-extensions/metric-incubator/src/test/resources/empty-view-config.yaml b/sdk-extensions/metric-incubator/src/test/resources/empty-view-config.yaml new file mode 100644 index 0000000000..57c527ed3e --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/resources/empty-view-config.yaml @@ -0,0 +1,7 @@ +- selector: + instrument_name: name1 + instrument_type: COUNTER + meter_name: meterName1 + meter_version: 1.0.0 + meter_schema_url: http://example1.com + view: diff --git a/sdk-extensions/metric-incubator/src/test/resources/full-config.yaml b/sdk-extensions/metric-incubator/src/test/resources/full-config.yaml new file mode 100644 index 0000000000..1f0245d8c5 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/resources/full-config.yaml @@ -0,0 +1,26 @@ +- selector: + instrument_name: name1 + instrument_type: COUNTER + meter_name: meterName1 + meter_version: 1.0.0 + meter_schema_url: http://example1.com + view: + name: name1 + description: description1 + aggregation: sum + attribute_keys: + - foo + - bar +- selector: + instrument_name: name2 + instrument_type: COUNTER + meter_name: meterName2 + meter_version: 2.0.0 + meter_schema_url: http://example2.com + view: + name: name2 + description: description2 + aggregation: last_value + attribute_keys: + - baz + - qux diff --git a/sdk-extensions/metric-incubator/src/test/resources/view-config-customizer-test.yaml b/sdk-extensions/metric-incubator/src/test/resources/view-config-customizer-test.yaml new file mode 100644 index 0000000000..afa6514b62 --- /dev/null +++ b/sdk-extensions/metric-incubator/src/test/resources/view-config-customizer-test.yaml @@ -0,0 +1,6 @@ +- selector: + instrument_type: OBSERVABLE_COUNTER + view: + attribute_keys: + - foo + - bar \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index df1796944a..80d2b64ce8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -68,6 +68,7 @@ include(":sdk:trace-shaded-deps") include(":sdk-extensions:autoconfigure") include(":sdk-extensions:autoconfigure-spi") include(":sdk-extensions:aws") +include(":sdk-extensions:metric-incubator") include(":sdk-extensions:resources") include(":sdk-extensions:tracing-incubator") include(":sdk-extensions:jaeger-remote-sampler")