From c137116afec794dbfdf0f17a8aa9c2d15165f1c3 Mon Sep 17 00:00:00 2001 From: Karsten Schnitter Date: Wed, 5 Aug 2026 09:18:48 +0200 Subject: [PATCH 1/2] Refactor SanitizeSpanExporterCustomizer Introduction of SpanAttributeCustomizer interface to allow different implementations in future. Customizers can be disabled by config in which case they are removed from the list of potential customizers. To avoid allocation of a new attributes map they can preemptively declare "no changes" on a span dataset. Signed-off-by: Karsten Schnitter --- .../SanitizeSpanExporterCustomizer.java | 56 +++++++----- .../DbConnectStatementCustomizer.java | 52 +++++++++++ .../customizer/SpanAttributeCustomizer.java | 42 +++++++++ .../SanitizeSpanExporterCustomizerTest.java | 91 +++++++++---------- .../DbConnectStatementCustomizerTest.java | 72 +++++++++++++++ 5 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizer.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeCustomizer.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizerTest.java diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java index 19583657..24cf8e07 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java @@ -1,7 +1,7 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; -import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXTENSION; -import io.opentelemetry.api.common.AttributeKey; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.DbConnectStatementCustomizer; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.SpanAttributeCustomizer; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -11,26 +11,39 @@ import io.opentelemetry.sdk.trace.export.SpanExporter; import java.util.Collection; +import java.util.List; import java.util.function.BiFunction; -import java.util.stream.Collectors; -import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static java.util.stream.Collectors.toList; public class SanitizeSpanExporterCustomizer implements BiFunction { - private static final AttributeKey DB_QUERY_TEXT = stringKey("db.query.text"); - //@Deprecated - private static final AttributeKey DB_STATEMENT = stringKey("db.statement"); + private final List customizers; + + public SanitizeSpanExporterCustomizer() { + this(List.of(new DbConnectStatementCustomizer())); + } + + SanitizeSpanExporterCustomizer(List customizers) { + this.customizers = customizers; + } @Override public SpanExporter apply(SpanExporter delegate, ConfigProperties config) { - if (EXTENSION.SANITIZER.ENABLED.getValue(config) != Boolean.TRUE) { + // Keep delegate exporter unwrapped if no customizers are provided. + if (customizers == null || customizers.isEmpty()) { + return delegate; + } + // Keep delegate exporter unwrapped if no customizers are enabled. + final List enabledCustomizers = + customizers.stream().filter(c -> c.isEnabled(config)).collect(toList()); + if (enabledCustomizers.isEmpty()) { return delegate; } return new SpanExporter() { @Override public CompletableResultCode export(Collection spans) { - return delegate.export(spans.stream().map(this::sanitizeSpanData).collect(Collectors.toList())); + return delegate.export(spans.stream().map(this::sanitizeSpanData).collect(toList())); } private SpanData sanitizeSpanData(SpanData spanData) { @@ -38,25 +51,22 @@ private SpanData sanitizeSpanData(SpanData spanData) { if (attributes == null) { return spanData; } - String dbQueryText = attributes.get(DB_QUERY_TEXT); - String dbStatement = attributes.get(DB_STATEMENT); - if (isClean(dbQueryText) && isClean(dbStatement)) { - return spanData; - } - AttributesBuilder sanitized = attributes.toBuilder(); - if (!isClean(dbQueryText)) { - sanitized.put(DB_QUERY_TEXT, dbQueryText.substring(0, 7) + " [REDACTED]"); + // Only create a new AttributesBuilder if at least one customizer is applicable to the attributes. + AttributesBuilder sanitized = null; + for (SpanAttributeCustomizer customizer: enabledCustomizers) { + if (customizer.isApplicable(attributes)) { + if (sanitized == null) { + sanitized = attributes.toBuilder(); + } + customizer.customize(sanitized, attributes); + } } - if (!isClean(dbStatement)) { - sanitized.put(DB_STATEMENT, dbStatement.substring(0, 7) + " [REDACTED]"); + if (sanitized == null) { + return spanData; } return new SanitizedSpanData(spanData, sanitized.build()); } - private boolean isClean(String query) { - return query == null || !query.toLowerCase().startsWith("connect"); - } - @Override public CompletableResultCode flush() { return delegate.flush(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizer.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizer.java new file mode 100644 index 00000000..21acabf4 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizer.java @@ -0,0 +1,52 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +/** + * A customizer that redacts the text of database connect statements in span attributes. This is to avoid leaking + * sensitive information in traces. The customizer checks for the presence of the "db.query.text" and "db.statement" + * attributes, and if they start with "connect", it replaces the rest of the string with "[REDACTED]". The customizer + * can be enabled or disabled via the configuration property "sap.cf.integration.otel.extension.sanitizer.enabled". By + * default, it is enabled. + */ +public class DbConnectStatementCustomizer implements SpanAttributeCustomizer { + + private static final AttributeKey DB_QUERY_TEXT = stringKey("db.query.text"); + //@Deprecated + private static final AttributeKey DB_STATEMENT = stringKey("db.statement"); + private static final String REDACTED = " [REDACTED]"; + + @Override + public boolean isEnabled(ConfigProperties config) { + return ExtensionConfigurations.EXTENSION.SANITIZER.ENABLED.getValue(config); + } + + @Override + public boolean isApplicable(Attributes original) { + String dbQueryText = original.get(DB_QUERY_TEXT); + String dbStatement = original.get(DB_STATEMENT); + return isCritical(dbQueryText) || isCritical(dbStatement); + } + + private boolean isCritical(String query) { + return query != null && query.toLowerCase().startsWith("connect"); + } + + @Override + public void customize(AttributesBuilder builder, Attributes original) { + String dbQueryText = original.get(DB_QUERY_TEXT); + String dbStatement = original.get(DB_STATEMENT); + if (isCritical(dbQueryText)) { + builder.put(DB_QUERY_TEXT, dbQueryText.substring(0, 7) + REDACTED); + } + if (isCritical(dbStatement)) { + builder.put(DB_STATEMENT, dbStatement.substring(0, 7) + REDACTED); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeCustomizer.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeCustomizer.java new file mode 100644 index 00000000..ec0bc25d --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeCustomizer.java @@ -0,0 +1,42 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +public interface SpanAttributeCustomizer { + + /** + * Returns true if this customizer is enabled based on the given configuration. + * + * @param config + * the configuration properties + * @return true if this customizer is enabled, false otherwise + */ + default boolean isEnabled(ConfigProperties config) { + return true; + } + + /** + * Returns true if this customizer is applicable to the given attributes. This avoids object allocation for + * attributes that are not relevant to this customizer. + * + * @param attributes + * the attributes to check + * @return true if this customizer is applicable, false otherwise + */ + default boolean isApplicable(Attributes attributes) { + return false; + } + + /** + * Customizes the given attributes builder based on the original attributes. This should be a no-op if the + * customizer is not applicable to the given attributes. + * + * @param attributesBuilder + * the attributes builder to customize + * @param original + * the original attributes + */ + void customize(AttributesBuilder attributesBuilder, Attributes original); +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizerTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizerTest.java index 802a9dd7..e298a60b 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizerTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizerTest.java @@ -1,7 +1,9 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.SpanAttributeCustomizer; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.export.SpanExporter; @@ -11,13 +13,16 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.HashMap; import java.util.List; import java.util.Map; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,7 +43,9 @@ class SanitizeSpanExporterCustomizerTest { @BeforeEach void setUp() { when(spanData.getName()).thenReturn("test-span"); - this.sanitizeExporter = new SanitizeSpanExporterCustomizer().apply(delegateExporter, null); + this.sanitizeExporter = + new SanitizeSpanExporterCustomizer(List.of(new TestSpanAttributeCustomizer())).apply(delegateExporter, + null); } @Test @@ -69,18 +76,8 @@ void forwardsSpanWithoutSensitiveAttributeKey() { } @Test - void forwardsSpanWithSensitiveAttributeKeyButWithoutSensitiveValue() { - Attributes attributes = Attributes.builder().put("db.query.text", "some safe value").build(); - when(spanData.getAttributes()).thenReturn(attributes); - List spans = List.of(spanData); - sanitizeExporter.export(spans); - - verify(delegateExporter).export(spans); - } - - @Test - void redactsSensitiveDbQueryTextValue() { - Attributes attributes = Attributes.builder().put("db.query.text", "Connect somewhere").build(); + void customizesCriticalAttribute() { + Attributes attributes = Attributes.builder().put("test.key", "Overwrite me!").build(); when(spanData.getAttributes()).thenReturn(attributes); List spans = List.of(spanData); sanitizeExporter.export(spans); @@ -89,42 +86,8 @@ void redactsSensitiveDbQueryTextValue() { SpanData sanitizedSpan = spanDataCaptor.getValue().get(0); assertThat(sanitizedSpan).extracting(SpanData::getName).isEqualTo("test-span"); assertThat(sanitizedSpan).extracting(SpanData::getAttributes) - .extracting(attrs -> attrs.get(AttributeKey.stringKey("db.query.text"))) - .isEqualTo("Connect [REDACTED]"); - } - - @Test - void redactsSensitiveDbStatementValue() { - Attributes attributes = Attributes.builder().put("db.statement", "CONNECT somewhere").build(); - when(spanData.getAttributes()).thenReturn(attributes); - List spans = List.of(spanData); - sanitizeExporter.export(spans); - - verify(delegateExporter).export(spanDataCaptor.capture()); - SpanData sanitizedSpan = spanDataCaptor.getValue().get(0); - assertThat(sanitizedSpan).extracting(SpanData::getName).isEqualTo("test-span"); - assertThat(sanitizedSpan).extracting(SpanData::getAttributes) - .extracting(attrs -> attrs.get(AttributeKey.stringKey("db.statement"))) - .isEqualTo("CONNECT [REDACTED]"); - } - - @Test - void keepsOtherAttributesOnRedaction() { - Attributes attributes = - Attributes.builder().put("db.query.text", "connect somewhere").put("some.key", "some.value").build(); - when(spanData.getAttributes()).thenReturn(attributes); - List spans = List.of(spanData); - sanitizeExporter.export(spans); - - verify(delegateExporter).export(spanDataCaptor.capture()); - SpanData sanitizedSpan = spanDataCaptor.getValue().get(0); - assertThat(sanitizedSpan).extracting(SpanData::getName).isEqualTo("test-span"); - assertThat(sanitizedSpan).extracting(SpanData::getAttributes) - .extracting(attrs -> attrs.get(AttributeKey.stringKey("db.query.text"))) - .isEqualTo("connect [REDACTED]"); - assertThat(sanitizedSpan).extracting(SpanData::getAttributes) - .extracting(attrs -> attrs.get(AttributeKey.stringKey("some.key"))) - .isEqualTo("some.value"); + .extracting(attrs -> attrs.get(AttributeKey.stringKey("test.key"))) + .isEqualTo("customized"); } @Test @@ -136,6 +99,13 @@ void canBeDisabledViaConfig() { assertThat(spanExporter).isSameAs(delegateExporter); } + @Test + void disabledWithoutCustomizers() { + assertThat(new SanitizeSpanExporterCustomizer(null).apply(delegateExporter, null)).isSameAs(delegateExporter); + assertThat(new SanitizeSpanExporterCustomizer(emptyList()).apply(delegateExporter, null)).isSameAs( + delegateExporter); + } + @Test void delegatesFlush() { sanitizeExporter.flush(); @@ -147,4 +117,27 @@ void delegatesShutdown() { sanitizeExporter.shutdown(); verify(delegateExporter).shutdown(); } + + @Test + void doesNotCallDisableCustomizer() { + SpanAttributeCustomizer disabled = + when(Mockito.mock(SpanAttributeCustomizer.class).isEnabled(any())).thenReturn(false).getMock(); + SanitizeSpanExporterCustomizer customizer = new SanitizeSpanExporterCustomizer(List.of(disabled)); + SpanExporter exporter = customizer.apply(delegateExporter, null); + exporter.export(List.of(spanData)); + assertThat(exporter).isSameAs(delegateExporter); + } + + private static class TestSpanAttributeCustomizer implements SpanAttributeCustomizer { + + @Override + public boolean isApplicable(Attributes original) { + return original.get(AttributeKey.stringKey("test.key")) != null; + } + + @Override + public void customize(AttributesBuilder builder, Attributes original) { + builder.put("test.key", "customized"); + } + } } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizerTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizerTest.java new file mode 100644 index 00000000..3427dc9b --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/DbConnectStatementCustomizerTest.java @@ -0,0 +1,72 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DbConnectStatementCustomizerTest { + + private static final DbConnectStatementCustomizer CUSTOMIZER = new DbConnectStatementCustomizer(); + + @Test + void canBeDisabled() { + Map configEntries = new HashMap<>(); + configEntries.put("sap.cf.integration.otel.extension.sanitizer.enabled", "false"); + DefaultConfigProperties configProperties = DefaultConfigProperties.createFromMap(configEntries); + assertFalse(CUSTOMIZER.isEnabled(configProperties)); + assertTrue(CUSTOMIZER.isEnabled(DefaultConfigProperties.createFromMap(new HashMap<>()))); + } + + @Test + void isApplicableOnConnectStatements() { + assertTrue(CUSTOMIZER.isApplicable(Attributes.builder().put("db.query.text", "connect to database").build())); + assertTrue(CUSTOMIZER.isApplicable(Attributes.builder().put("db.statement", "connect to database").build())); + } + + @Test + void notApplicableOnNonConnectDbStatements() { + assertFalse(CUSTOMIZER.isApplicable(Attributes.builder().put("db.query.text", "select * from table").build())); + assertFalse(CUSTOMIZER.isApplicable( + Attributes.builder().put("db.statement", "insert into table values (1)").build())); + } + + @Test + void notApplicableOnNonDbStatements() { + assertFalse(CUSTOMIZER.isApplicable(Attributes.builder().put("http.method", "GET").build())); + assertFalse(CUSTOMIZER.isApplicable(Attributes.builder().put("key", "connect to somewhere").build())); + } + + @Test + void redactsConnectStatements() { + Attributes original = Attributes.builder().put("db.query.text", "connect to database") + .put("db.statement", "Connect to database").build(); + AttributesBuilder sanitized = original.toBuilder(); + CUSTOMIZER.customize(sanitized, original); + assertThat(sanitized.build()).extracting(a -> a.get(AttributeKey.stringKey("db.query.text"))) + .isEqualTo("connect [REDACTED]"); + assertThat(sanitized.build()).extracting(a -> a.get(AttributeKey.stringKey("db.statement"))) + .isEqualTo("Connect [REDACTED]"); + } + + @Test + void keepsNonConnectStatements() { + Attributes original = Attributes.builder().put("db.query.text", "insert to database") + .put("db.statement", "INSERT to database").build(); + AttributesBuilder sanitized = original.toBuilder(); + CUSTOMIZER.customize(sanitized, original); + assertThat(sanitized.build()).extracting(a -> a.get(AttributeKey.stringKey("db.query.text"))) + .isEqualTo("insert to database"); + assertThat(sanitized.build()).extracting(a -> a.get(AttributeKey.stringKey("db.statement"))) + .isEqualTo("INSERT to database"); + + } +} From ff5eb7d323a93be8f6083fc992372efda83343de Mon Sep 17 00:00:00 2001 From: Karsten Schnitter Date: Wed, 5 Aug 2026 13:34:34 +0200 Subject: [PATCH 2/2] Add span attributes filter by name Adds a new span exporter customizer to filter spann attributes by the same semantics as already available for metrics by name. The user is able to provide inclusion and exclusion lists with wildcard support. Signed-off-by: Karsten Schnitter --- .../README.md | 21 ++- .../ext/config/ExtensionConfigurations.java | 27 ++++ .../SanitizeSpanExporterCustomizer.java | 3 +- .../SpanAttributeNameFilterCustomizer.java | 57 ++++++++ ...SpanAttributeNameFilterCustomizerTest.java | 127 ++++++++++++++++++ 5 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizer.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizerTest.java diff --git a/cf-java-logging-support-opentelemetry-agent-extension/README.md b/cf-java-logging-support-opentelemetry-agent-extension/README.md index 62c4695d..4c0ce2e8 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/README.md +++ b/cf-java-logging-support-opentelemetry-agent-extension/README.md @@ -13,6 +13,7 @@ The extension provides the following main features: * additional exporters for logs, metrics and traces for [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) * additional exporter for metrics for [Dynatrace](https://docs.dynatrace.com/docs/setup-and-configuration/setup-on-container-platforms/cloud-foundry/deploy-oneagent-on-sap-cloud-platform-for-application-only-monitoring) * adding resource attributes describing the CF application +* filtering span attributes by name before export See the section on [configuration](#configuration) for further details. @@ -151,6 +152,22 @@ 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. +### Filtering Span Attributes + +_This feature was introduced with version 4.3.0 of the extension._ + +You can filter which span attributes are exported by name using the following properties: + +| Property | Description | +|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names` | A comma-separated list of span attribute name patterns to be included. This may include a wildcard "*" at the end of the name. | +| `sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names` | A comma-separated list of span attribute name patterns to be excluded. This may include a wildcard "*" at the end of the name. | + +The filter is only active when the sanitizer is enabled (`sap.cf.integration.otel.extension.sanitizer.enabled=true`) and at least one of the two properties is set. + +The `include` filter is applied before the `exclude` filter. +That means, if a span attribute matches both filters, it will be excluded. + ### Configuration Properties Summary The following table summarizes all configuration properties provided by the extension: @@ -175,7 +192,9 @@ The following table summarizes all configuration properties provided by the exte | `otel.exporter.dynatrace.metrics.include.names` | A comma-separated list of metric name patterns to be included when exporting metrics to Dynatrace. Wildcard "\*" is only supported at the end of the name. If not set, all metrics are exported. | | | `otel.exporter.dynatrace.metrics.temporality.preference` | The default histogram aggregation for metrics exported to Dynatrace. Delegates to the underlying OTLP exporter, supporting all its configurations. The Dynatrace metrics exporter provides an additional option `always_delta` which always uses delta aggregation temporality. This is also the default behavior if the property is not set. | `always_delta` | | `otel.exporter.dynatrace.metrics.timeout` | The maximum duration to wait for Dynatrace when exporting metrics. | `10000` (from OTel SDK) | -| `sap.cf.integration.otel.extension.sanitizer.enabled` | Enables or disables the sanitizer. | `true` | +| `sap.cf.integration.otel.extension.sanitizer.enabled` | Enables or disables the sanitizer. | `true` | +| `sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names` | A comma-separated list of span attribute name patterns to be excluded. Wildcard "\*" is only supported at the end of the name. If not set, no span attributes are excluded. Requires the sanitizer to be enabled and at least one filter property to be set. | | +| `sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names` | A comma-separated list of span attribute name patterns to be included. Wildcard "\*" is only supported at the end of the name. If not set, all span attributes are included. Requires the sanitizer to be enabled and at least one filter property to be set. | | | `sap.cloudfoundry.otel.resources.enabled` | Should Cloud Foundry resource attributes be added to the OpenTelemetry resource? | `true` | | `sap.cloudfoundry.otel.resources.format` | Determines the semantic convention used for Cloud Foundry resource attributes names. `SAP` - use SAP specific attribute names (default). `OTEL` - use OpenTelemetry semantic convention attribute names. | `SAP` | | `sap.cloud-logging.cf.binding.label.value` | The label value used to identify managed Cloud Logging service bindings. | `cloud-logging` | diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java index edb28ce9..b19bed3a 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java @@ -185,6 +185,33 @@ interface SANITIZER { */ ConfigProperty ENABLED = booleanValued("sap.cf.integration.otel.extension.sanitizer.enabled").withDefaultValue(true).build(); + + interface SPAN { + interface ATTRIBUTE { + interface FILTER { + /** + *

Parses + * {@code sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names}.

+ *

A comma-seperated list of span attribute name patterns to be excluded when sanitizing + * span + * attributes. Wildcard "*" is only supported at the end of the name. If not set, no span + * attributes are excluded.

+ */ + ConfigProperty> EXCLUDE_NAMES = listValued( + "sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names").build(); + + /** + *

Parses + * {@code sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names}.

+ *

A comma-seperated list of span attribute name patterns to be included when sanitizing span + * attributes. Wildcard "*" is only supported at the end of the name. If not set, all span + * attributes are included.

+ */ + ConfigProperty> INCLUDE_NAMES = listValued( + "sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names").build(); + } + } + } } } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java index 24cf8e07..64f275f3 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/SanitizeSpanExporterCustomizer.java @@ -2,6 +2,7 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.DbConnectStatementCustomizer; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.SpanAttributeCustomizer; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer.SpanAttributeNameFilterCustomizer; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -21,7 +22,7 @@ public class SanitizeSpanExporterCustomizer implements BiFunction customizers; public SanitizeSpanExporterCustomizer() { - this(List.of(new DbConnectStatementCustomizer())); + this(List.of(new DbConnectStatementCustomizer(), new SpanAttributeNameFilterCustomizer())); } SanitizeSpanExporterCustomizer(List customizers) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizer.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizer.java new file mode 100644 index 00000000..b0ecc2e6 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizer.java @@ -0,0 +1,57 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXTENSION.SANITIZER; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static java.util.function.Predicate.not; + +public class SpanAttributeNameFilterCustomizer implements SpanAttributeCustomizer { + + private Predicate> rejected = k -> false; + + @Override + public boolean isEnabled(ConfigProperties config) { + List included = SANITIZER.SPAN.ATTRIBUTE.FILTER.INCLUDE_NAMES.getValue(config); + List excluded = SANITIZER.SPAN.ATTRIBUTE.FILTER.EXCLUDE_NAMES.getValue(config); + + List includedNames = getNames(included); + List includedPrefixes = getPrefixes(included); + List excludedNames = getNames(excluded); + List excludedPrefixes = getPrefixes(excluded); + this.rejected = k -> { + String name = k.getKey(); + boolean isIncluded = (includedNames.isEmpty() && includedPrefixes.isEmpty()) || includedNames.contains( + name) || includedPrefixes.stream().anyMatch(name::startsWith); + boolean isExcluded = excludedNames.contains(name) || excludedPrefixes.stream().anyMatch(name::startsWith); + return !isIncluded || isExcluded; + }; + + return SANITIZER.ENABLED.getValue(config) && (!included.isEmpty() || !excluded.isEmpty()); + } + + private static List getPrefixes(List included) { + return included.stream().filter(s -> s.endsWith("*")).map(s -> s.substring(0, s.length() - 1)) + .collect(Collectors.toList()); + } + + private static List getNames(List included) { + return included.stream().filter(not(s -> s.endsWith("*"))).collect(Collectors.toList()); + } + + @Override + public boolean isApplicable(Attributes attributes) { + return attributes.asMap().keySet().stream().anyMatch(rejected); + } + + @Override + public void customize(AttributesBuilder attributesBuilder, Attributes original) { + attributesBuilder.removeIf(rejected); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizerTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizerTest.java new file mode 100644 index 00000000..a31a7877 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/customizer/SpanAttributeNameFilterCustomizerTest.java @@ -0,0 +1,127 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.customizer; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import org.assertj.core.api.AbstractObjectAssert; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SpanAttributeNameFilterCustomizerTest { + + private static final Map INCLUSIONS = + Map.of("sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names", + "included,prefix*"); + private static final Map EXCLUSIONS = + Map.of("sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names", + "excluded,prefix*"); + private static final Map DISABLED = + Map.of("sap.cf.integration.otel.extension.sanitizer.enabled", "false"); + private static final Attributes ATTRIBUTES = + Attributes.builder().put("included", "included-value").put("excluded", "excluded-value") + .put("other", "other-value").put("prefix-test", "prefix-value").build(); + + @SafeVarargs + private static ConfigProperties configOf(Map... partialConfigs) { + if (partialConfigs == null) { + return DefaultConfigProperties.createFromMap(Collections.emptyMap()); + } + Map merged = new HashMap<>(); + for (Map current: partialConfigs) { + merged.putAll(current); + } + return DefaultConfigProperties.createFromMap(merged); + } + + @Test + void isDisabledByDefault() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + assertFalse(customizer.isEnabled(null)); + } + + @Test + void isEnabledWhenConfigured() { + assertTrue(new SpanAttributeNameFilterCustomizer().isEnabled(configOf(INCLUSIONS))); + assertTrue(new SpanAttributeNameFilterCustomizer().isEnabled(configOf(EXCLUSIONS))); + } + + @Test + void isDisabledWhenSanitizerIsDisabled() { + assertFalse(new SpanAttributeNameFilterCustomizer().isEnabled(configOf(INCLUSIONS, DISABLED))); + } + + @Test + void isApplicableOnlyWhenConfiguredAttributeNamesLeadToDrop() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + customizer.isEnabled(configOf(INCLUSIONS, EXCLUSIONS)); + assertFalse(customizer.isApplicable(Attributes.builder().put("included", "ignored").build()), + "included key does not require changes to attributes"); + assertTrue(customizer.isApplicable(Attributes.builder().put("excluded", "ignored").build()), + "excluded key requires changes to attributes"); + assertTrue(customizer.isApplicable(Attributes.builder().put("prefix-test", "ignored").build()), + "excluded prefix requires changes to attributes"); + assertTrue(customizer.isApplicable(Attributes.builder().put("other", "other-value").build()), + "not included key requires changes to attributes"); + } + + @Test + void allowsAllAttributesWithoutConfig() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + customizer.isEnabled(configOf()); + AttributesBuilder result = ATTRIBUTES.toBuilder(); + customizer.customize(result, ATTRIBUTES); + assertAttributeStringKey(result, "included").isEqualTo("included-value"); + assertAttributeStringKey(result, "excluded").isEqualTo("excluded-value"); + assertAttributeStringKey(result, "other").isEqualTo("other-value"); + assertAttributeStringKey(result, "prefix-test").isEqualTo("prefix-value"); + } + + private static AbstractObjectAssert assertAttributeStringKey(AttributesBuilder result, String name) { + return assertThat(result.build()).extracting(a -> a.get(AttributeKey.stringKey(name))); + } + + @Test + void allowsOnlyIncludedAttributes() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + customizer.isEnabled(configOf(INCLUSIONS)); + AttributesBuilder result = ATTRIBUTES.toBuilder(); + customizer.customize(result, ATTRIBUTES); + assertAttributeStringKey(result, "included").isEqualTo("included-value"); + assertAttributeStringKey(result, "excluded").isNull(); + assertAttributeStringKey(result, "other").isNull(); + assertAttributeStringKey(result, "prefix-test").isEqualTo("prefix-value"); + } + + @Test + void removesExcludedAttributes() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + customizer.isEnabled(configOf(EXCLUSIONS)); + AttributesBuilder result = ATTRIBUTES.toBuilder(); + customizer.customize(result, ATTRIBUTES); + assertAttributeStringKey(result, "included").isEqualTo("included-value"); + assertAttributeStringKey(result, "excluded").isNull(); + assertAttributeStringKey(result, "other").isEqualTo("other-value"); + assertAttributeStringKey(result, "prefix-test").isNull(); + } + + @Test + void exclusionsTakePriorityOverInclusions() { + SpanAttributeNameFilterCustomizer customizer = new SpanAttributeNameFilterCustomizer(); + customizer.isEnabled(configOf(INCLUSIONS, EXCLUSIONS)); + AttributesBuilder result = ATTRIBUTES.toBuilder(); + customizer.customize(result, ATTRIBUTES); + assertAttributeStringKey(result, "included").isEqualTo("included-value"); + assertAttributeStringKey(result, "excluded").isNull(); + assertAttributeStringKey(result, "other").isNull(); + assertAttributeStringKey(result, "prefix-test").isNull(); + } +}