diff --git a/cf-java-logging-support-opentelemetry-agent-extension/README.md b/cf-java-logging-support-opentelemetry-agent-extension/README.md index 2f3d51f1..8b2d5c28 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/README.md +++ b/cf-java-logging-support-opentelemetry-agent-extension/README.md @@ -142,6 +142,21 @@ java -javaagent:/path/to/opentelemetry-javaagent-.jar \ The [OpenTelemetry Java Instrumentation project](https://github.com/open-telemetry/opentelemetry-java-instrumentation) provides detailed documentation on the configuration properties for [Logback](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/logback/logback-appender-1.0/javaagent) and [Log4j](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/log4j/log4j-appender-2.17/javaagent). +### Filtering Metrics + +_This feature was introduced with version 4.1.0 of the extension._ + +You can filter which metrics are exported to Cloud Logging or Dynatrace by name using the following properties: + +| Property | Description | +|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `otel.exporter.cloud-logging.metrics.include.names` or `otel.exporter.dynatrace.metrics.include.names` | A comma-separated list of metric names to be forwarded. This may include a wildcard "*" at the end of the name. | +| `otel.exporter.cloud-logging.metrics.exclude.names` or `otel.exporter.dynatrace.metrics.exclude.names` | A comma-separated list of metric names to be rejected. This may include a wildcard "*" at the end of the name. | + +Note, that the `include` filter is applied before the `exclude` filter. +That means, if a metric matches both filters, it will be excluded. +The configuration applies to both the `cloud-logging` and `dynatrace` exporters independently. + ## Using User-Provided Service Instances ### SAP Cloud Logging diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java index 8e310f84..d7895194 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingMetricsExporterProvider.java @@ -27,6 +27,8 @@ public class CloudLoggingMetricsExporterProvider implements ConfigurableMetricExporterProvider { + private static final String GENERIC_CONFIG_PREFIX = "otel.exporter.cloud-logging."; + private static final String METRICS_CONFIG_PREFIX = "otel.exporter.cloud-logging.metrics."; private static final Logger LOG = Logger.getLogger(CloudLoggingMetricsExporterProvider.class.getName()); private final Function> servicesProvider; @@ -43,17 +45,17 @@ public CloudLoggingMetricsExporterProvider() { } private static String getCompression(ConfigProperties config) { - String compression = config.getString("otel.exporter.cloud-logging.metrics.compression"); - return compression != null ? compression : config.getString("otel.exporter.cloud-logging.compression", "gzip"); + String compression = config.getString(METRICS_CONFIG_PREFIX + "compression"); + return compression != null ? compression : config.getString(GENERIC_CONFIG_PREFIX + "compression", "gzip"); } private static Duration getTimeOut(ConfigProperties config) { - Duration timeout = config.getDuration("otel.exporter.cloud-logging.metrics.timeout"); - return timeout != null ? timeout : config.getDuration("otel.exporter.cloud-logging.timeout"); + Duration timeout = config.getDuration(METRICS_CONFIG_PREFIX + "timeout"); + return timeout != null ? timeout : config.getDuration(GENERIC_CONFIG_PREFIX + "timeout"); } private static AggregationTemporalitySelector getAggregationTemporalitySelector(ConfigProperties config) { - String temporalityStr = config.getString("otel.exporter.cloud-logging.metrics.temporality.preference"); + String temporalityStr = config.getString(METRICS_CONFIG_PREFIX + "temporality.preference"); if (temporalityStr == null) { return AggregationTemporalitySelector.alwaysCumulative(); } @@ -71,8 +73,7 @@ private static AggregationTemporalitySelector getAggregationTemporalitySelector( } private static DefaultAggregationSelector getDefaultAggregationSelector(ConfigProperties config) { - String defaultHistogramAggregation = - config.getString("otel.exporter.cloud-logging.metrics.default.histogram.aggregation"); + String defaultHistogramAggregation = config.getString(METRICS_CONFIG_PREFIX + "default.histogram.aggregation"); if (defaultHistogramAggregation == null) { return DefaultAggregationSelector.getDefault() .with(InstrumentType.HISTOGRAM, Aggregation.defaultAggregation()); @@ -101,8 +102,11 @@ public MetricExporter createExporter(ConfigProperties config) { List exporters = servicesProvider.apply(config).map(svc -> createExporter(config, svc)) .filter(exp -> !(exp instanceof NoopMetricExporter)) .collect(Collectors.toList()); - return MultiMetricExporter.composite(exporters, getAggregationTemporalitySelector(config), - getDefaultAggregationSelector(config)); + MetricExporter exporter = MultiMetricExporter.composite(exporters, getAggregationTemporalitySelector(config), + getDefaultAggregationSelector(config)); + exporter = FilteringMetricExporter.wrap(exporter).withConfig(config).withPropertyPrefix(METRICS_CONFIG_PREFIX) + .build(); + return exporter; } private MetricExporter createExporter(ConfigProperties config, CloudFoundryServiceInstance service) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/DynatraceMetricsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/DynatraceMetricsExporterProvider.java index 81bd3f08..0b43ed88 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/DynatraceMetricsExporterProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/DynatraceMetricsExporterProvider.java @@ -28,6 +28,10 @@ public class DynatraceMetricsExporterProvider implements ConfigurableMetricExpor public static final String CRED_DYNATRACE_APIURL = "apiurl"; public static final String DT_APIURL_METRICS_SUFFIX = "/v2/otlp/v1/metrics"; + + private static final String GENERIC_CONFIG_PREFIX = "otel.exporter.dynatrace."; + private static final String METRICS_CONFIG_PREFIX = "otel.exporter.dynatrace.metrics."; + private static final Logger LOG = Logger.getLogger(DynatraceMetricsExporterProvider.class.getName()); private static final AggregationTemporalitySelector ALWAYS_DELTA = instrumentType -> AggregationTemporality.DELTA; private final Function serviceProvider; @@ -41,17 +45,17 @@ public DynatraceMetricsExporterProvider(Function predicate; + + private FilteringMetricExporter(MetricExporter delegate, Predicate predicate) { + this.delegate = delegate; + this.predicate = predicate; + } + + @Override + public CompletableResultCode export(Collection collection) { + List filteredMetrics = collection.stream().filter(predicate).collect(toList()); + return delegate.export(filteredMetrics); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return delegate.getAggregationTemporality(instrumentType); + } + + @Override + public Aggregation getDefaultAggregation(InstrumentType instrumentType) { + return delegate.getDefaultAggregation(instrumentType); + } + + @Override + public MemoryMode getMemoryMode() { + return delegate.getMemoryMode(); + } + + public static Builder wrap(MetricExporter delegate) { + return new Builder(delegate); + } + + public static class Builder { + + private final MetricExporter delegate; + private ConfigProperties config; + private String prefix = ""; + + public Builder(MetricExporter delegate) { + this.delegate = delegate; + } + + public Builder withConfig(ConfigProperties config) { + this.config = config; + return this; + } + + public Builder withPropertyPrefix(String prefix) { + this.prefix = prefix.endsWith(".") ? prefix : prefix + "."; + return this; + } + + public MetricExporter build() { + if (config == null) { + return delegate; + } + + List includedNames = config.getList(prefix + INCLUDED_NAMES_KEY); + List excludedNames = config.getList(prefix + EXCLUDED_NAMES_KEY); + if (includedNames.isEmpty() && excludedNames.isEmpty()) { + return delegate; + } + + Predicate predicate = metricData -> true; + predicate = addInclusions(predicate, includedNames); + predicate = addExclusions(predicate, excludedNames); + return new FilteringMetricExporter(delegate, predicate); + } + + private static Predicate addInclusions(Predicate predicate, + List includedNames) { + if (includedNames.isEmpty()) { + return predicate; + } + + HashSet names = getNames(includedNames); + if (!names.isEmpty()) { + predicate = predicate.and(metricData -> names.contains(metricData.getName())); + } + List prefixes = getPrefixes(includedNames); + if (!prefixes.isEmpty()) { + predicate = predicate.and( + metricData -> prefixes.stream().anyMatch(p -> metricData.getName().startsWith(p))); + } + return predicate; + } + + private static Predicate addExclusions(Predicate predicate, + List excludedNames) { + if (excludedNames.isEmpty()) { + return predicate; + } + + HashSet names = getNames(excludedNames); + if (!names.isEmpty()) { + predicate = predicate.and(metricData -> !names.contains(metricData.getName())); + } + List prefixes = getPrefixes(excludedNames); + if (!prefixes.isEmpty()) { + predicate = predicate.and( + metricData -> prefixes.stream().anyMatch(p -> !metricData.getName().startsWith(p))); + } + return predicate; + } + + private static HashSet getNames(List names) { + return names.stream().filter(n -> !n.endsWith("*")).collect(toCollection(HashSet::new)); + } + + private static List getPrefixes(List names) { + return names.stream().filter(n -> n.endsWith("*")).map(n -> n.substring(0, n.length() - 1)) + .collect(toList()); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/FilteringMetricExporterTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/FilteringMetricExporterTest.java new file mode 100644 index 00000000..bafd60ca --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/FilteringMetricExporterTest.java @@ -0,0 +1,196 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import io.opentelemetry.sdk.common.export.MemoryMode; +import io.opentelemetry.sdk.metrics.Aggregation; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import org.assertj.core.data.MapEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.List; + +import static io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties.createFromMap; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FilteringMetricExporterTest { + + @Mock + private MetricExporter delegate; + + @Mock(strictness = LENIENT) + private MetricData includedMetric; + + @Mock(strictness = LENIENT) + private MetricData excludedMetric; + + @Mock(strictness = LENIENT) + private MetricData anotherMetric; + + @Captor + ArgumentCaptor> exported; + + @BeforeEach + void setUp() { + when(includedMetric.getName()).thenReturn("included"); + when(excludedMetric.getName()).thenReturn("excluded"); + when(anotherMetric.getName()).thenReturn("another"); + } + + @Test + void exportsAllWithoutConfig() { + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric, excludedMetric, anotherMetric); + } + + @Test + void exportsAllWithEmptyConfig() { + DefaultConfigProperties config = createFromMap(emptyMap()); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric, excludedMetric, anotherMetric); + } + + @Test + void exportsOnlyIncluded() { + ConfigProperties config = createConfig(MapEntry.entry("include.names", "included")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric); + } + + @Test + void rejectsEncluded() { + ConfigProperties config = createConfig(MapEntry.entry("exclude.names", "excluded")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric, anotherMetric); + } + + @Test + void rejectsExcludedFromIncluded() { + ConfigProperties config = createConfig(MapEntry.entry("include.names", "included,excluded"), + MapEntry.entry("exclude.names", "excluded")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric); + } + + @Test + void supportsWildcardsOnIncluded() { + ConfigProperties config = createConfig(MapEntry.entry("include.names", "incl*")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric); + } + + @Test + void supportsWildcardsOnEncluded() { + ConfigProperties config = createConfig(MapEntry.entry("exclude.names", "excl*")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config).build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric, anotherMetric); + } + + @Test + void supportsConfigPrefixes() { + ConfigProperties config = createConfig(MapEntry.entry("config.include.names", "included,excluded"), + MapEntry.entry("config.exclude.names", "excluded")); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).withConfig(config) + .withPropertyPrefix("config").build()) { + exporter.export(asList(includedMetric, excludedMetric, anotherMetric)); + } + verify(delegate).export(exported.capture()); + assertThat(exported.getValue()).containsExactlyInAnyOrder(includedMetric); + } + + @SafeVarargs + private static ConfigProperties createConfig(MapEntry... entries) { + HashMap map = new HashMap<>(); + for (MapEntry entry: entries) { + map.put(entry.key, entry.value); + } + return createFromMap(map); + } + + @Test + void close() { + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + // nothing to do + } + verify(delegate).close(); + } + + @Test + void flush() { + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + exporter.flush(); + } + verify(delegate).flush(); + } + + @Test + void shutdown() { + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + exporter.shutdown(); + } + verify(delegate).shutdown(); + } + + @Test + void getAggregationTemporality() { + when(delegate.getAggregationTemporality(InstrumentType.COUNTER)).thenReturn(AggregationTemporality.DELTA); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + assertThat(exporter.getAggregationTemporality(InstrumentType.COUNTER)).isEqualTo( + AggregationTemporality.DELTA); + } + } + + @Test + void getDefaultAggregation() { + when(delegate.getDefaultAggregation(InstrumentType.COUNTER)).thenReturn(Aggregation.defaultAggregation()); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + assertThat(exporter.getDefaultAggregation(InstrumentType.COUNTER)).isEqualTo( + Aggregation.defaultAggregation()); + } + } + + @Test + void getMemoryMode() { + when(delegate.getMemoryMode()).thenReturn(MemoryMode.IMMUTABLE_DATA); + try (MetricExporter exporter = FilteringMetricExporter.wrap(delegate).build()) { + assertThat(exporter.getMemoryMode()).isEqualTo(MemoryMode.IMMUTABLE_DATA); + } + } +}