diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 6b74234ad..a2a58ed86 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -25,6 +25,11 @@ components: baggage-processor: - mikegoldsmith - zeitlinger + cel-sampler: + - dol + - trask + - jack-berg + - breedx-splk cloudfoundry-resources: - KarstenSchnitter compressors: diff --git a/README.md b/README.md index 4ba3fdc12..521d31426 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ feature or via instrumentation, this project is hopefully for you. | alpha | [AWS X-Ray Propagator](./aws-xray-propagator/README.md) | | alpha | [Baggage Processors](./baggage-processor/README.md) | | alpha | [zstd Compressor](./compressors/compressor-zstd/README.md) | +| alpha | [CEL-Based Sampler](./cel-sampler/README.md) | | alpha | [Consistent Sampling](./consistent-sampling/README.md) | | alpha | [Disk Buffering](./disk-buffering/README.md) | | alpha | [GCP Authentication Extension](./gcp-auth-extension/README.md) | diff --git a/cel-sampler/README.md b/cel-sampler/README.md new file mode 100644 index 000000000..b9321e04d --- /dev/null +++ b/cel-sampler/README.md @@ -0,0 +1,82 @@ +# CEL-Based Sampler + +## Declarative configuration + +The `CelBasedSampler` supports [declarative configuration](https://opentelemetry.io/docs/languages/java/configuration/#declarative-configuration). + +To use: + +* Add a dependency on `io.opentelemetry.contrib:opentelemetry-cel-sampler:` +* Follow the [instructions](https://github.com/open-telemetry/opentelemetry-java/blob/main/sdk-extensions/incubator/README.md#declarative-configuration) to configure OpenTelemetry with declarative configuration. +* Configure the `.tracer_provider.sampler` to include the `cel_based` sampler. + +Support is now available for the java agent, see an [example here](https://github.com/open-telemetry/opentelemetry-java-examples/blob/main/javaagent). + +## Overview + +The `CelBasedSampler` uses [Common Expression Language (CEL)](https://github.com/google/cel-spec) to create advanced sampling rules based on span attributes. CEL provides a powerful, yet simple expression language that allows you to create complex matching conditions. + +## Schema + +Schema for `cel_based` sampler: + +```yaml +# The fallback sampler to use if no expressions match. +fallback_sampler: + always_on: +# List of CEL expressions to evaluate. Expressions are evaluated in order. +expressions: + # The action to take when the expression evaluates to true. Must be one of: DROP, RECORD_AND_SAMPLE. + - action: DROP + # The CEL expression to evaluate. Must return a boolean. + expression: attribute['url.path'].startsWith('/actuator') + - action: RECORD_AND_SAMPLE + expression: attribute['http.method'] == 'GET' && attribute['http.status_code'] < 400 +``` + +## Available variables + +Available variables in CEL expressions: + +* `name` (string): The span name +* `spanKind` (string): The span kind (e.g., "SERVER", "CLIENT") +* `attribute` (map): A map of span attributes + +## Example configuration + +Example of using `cel_based` sampler as the root sampler in `parent_based` sampler configuration: + +```yaml +tracer_provider: + sampler: + parent_based: + root: + cel_based: + fallback_sampler: + always_on: + expressions: + # Drop health check endpoints + - action: DROP + expression: spanKind == 'SERVER' && attribute['url.path'].startsWith('/health') + # Drop actuator endpoints + - action: DROP + expression: spanKind == 'SERVER' && attribute['url.path'].startsWith('/actuator') + # Sample only HTTP GET requests with successful responses + - action: RECORD_AND_SAMPLE + expression: spanKind == 'SERVER' && attribute['http.method'] == 'GET' && attribute['http.status_code'] < 400 + # Selectively sample based on span name + - action: RECORD_AND_SAMPLE + expression: name.contains('checkout') || name.contains('payment') + # Drop spans with specific name patterns + - action: DROP + expression: name.matches('.*internal.*') && spanKind == 'INTERNAL' +``` + +## Component owners + +* [Dominic Lüchinger](https://github.com/dol), SIX Group +* [Jack Berg](https://github.com/jack-berg), New Relic +* [Jason Plumb](https://github.com/breedx-splk), Splunk +* [Trask Stalnaker](https://github.com/trask), Microsoft + +Learn more about component owners in [component_owners.yml](../.github/component_owners.yml). diff --git a/cel-sampler/build.gradle.kts b/cel-sampler/build.gradle.kts new file mode 100644 index 000000000..a20022c35 --- /dev/null +++ b/cel-sampler/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("otel.java-conventions") + id("otel.publish-conventions") +} + +description = "Sampler which makes its decision based on semantic attributes values" +otelJava.moduleName.set("io.opentelemetry.contrib.sampler.cel") + +dependencies { + api("io.opentelemetry:opentelemetry-sdk") + + implementation("dev.cel:cel:0.11.0") + + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-incubator") + + testImplementation("io.opentelemetry.semconv:opentelemetry-semconv-incubating") + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-incubator") +} diff --git a/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSampler.java b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSampler.java new file mode 100644 index 000000000..0551d4cb8 --- /dev/null +++ b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSampler.java @@ -0,0 +1,161 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel; + +import static java.util.Objects.requireNonNull; + +import dev.cel.common.types.CelProtoTypes; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This sampler accepts a list of {@link CelBasedSamplingExpression}s and tries to match every + * proposed span against those rules. Every rule describes a span's attribute, a pattern against + * which to match attribute's value, and a sampler that will make a decision about given span if + * match was successful. + * + *

Matching is performed by CEL expression evaluation. + * + *

Provided span kind is checked first and if differs from the one given to {@link + * #builder(Sampler)}, the default fallback sampler will make a decision. + * + *

Note that only attributes that were set on {@link SpanBuilder} will be taken into account, + * attributes set after the span has been started are not used + * + *

If none of the rules matched, the default fallback sampler will make a decision. + */ +public final class CelBasedSampler implements Sampler { + + private static final Logger logger = Logger.getLogger(CelBasedSampler.class.getName()); + + static final CelCompiler celCompiler = + CelCompilerFactory.standardCelCompilerBuilder() + .addVar("name", SimpleType.STRING) + .addVar("traceId", SimpleType.STRING) + .addVar("spanKind", SimpleType.STRING) + .addVar("attribute", CelProtoTypes.createMap(CelProtoTypes.STRING, CelProtoTypes.DYN)) + .setResultType(SimpleType.BOOL) + .build(); + + private final CelRuntime celRuntime; + private final List expressions; + private final Sampler fallback; + + /** + * Creates a new CEL-based sampler. + * + * @param expressions The list of CEL expressions to evaluate + * @param fallback The fallback sampler to use when no expressions match + */ + public CelBasedSampler(List expressions, Sampler fallback) { + this.expressions = + Collections.unmodifiableList( + new ArrayList<>(requireNonNull(expressions, "expressions must not be null"))); + this.expressions.forEach( + expr -> { + if (!expr.getAbstractSyntaxTree().isChecked()) { + throw new IllegalArgumentException( + "Expression and its AST is not checked: " + expr.getExpression()); + } + }); + this.fallback = requireNonNull(fallback, "fallback must not be null"); + this.celRuntime = CelRuntimeFactory.standardCelRuntimeBuilder().build(); + } + + /** + * Creates a new builder for CEL-based sampler. + * + * @param fallback The fallback sampler to use when no expressions match + * @return A new builder instance + */ + public static CelBasedSamplerBuilder builder(Sampler fallback) { + return new CelBasedSamplerBuilder( + requireNonNull(fallback, "fallback sampler must not be null"), celCompiler); + } + + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + + // Prepare the evaluation context with span data + Map evaluationContext = new HashMap<>(); + evaluationContext.put("name", name); + evaluationContext.put("traceId", traceId); + evaluationContext.put("spanKind", spanKind.name()); + evaluationContext.put("attribute", convertAttributesToMap(attributes)); + + for (CelBasedSamplingExpression expression : expressions) { + try { + CelRuntime.Program program = celRuntime.createProgram(expression.getAbstractSyntaxTree()); + Object result = program.eval(evaluationContext); + // Happy path: Perform sampling based on the boolean result + if (Boolean.TRUE.equals(result)) { + return expression + .getDelegate() + .shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks); + } + // If result is not boolean, treat as false + logger.log( + Level.FINE, + "Expression '" + + expression.getExpression() + + "' returned non-boolean result: " + + result); + } catch (CelEvaluationException e) { + logger.log( + Level.FINE, + "Expression '" + expression.getExpression() + "' evaluation error: " + e.getMessage()); + } + } + + return fallback.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks); + } + + /** + * Convert OpenTelemetry Attributes to a Map that CEL can work with. + * + * @param attributes The OpenTelemetry attributes + * @return A map representation of the attributes + */ + private static Map convertAttributesToMap(Attributes attributes) { + Map map = new HashMap<>(); + attributes.forEach((key, value) -> map.put(key.getKey(), value)); + return map; + } + + @Override + public String getDescription() { + return "CelBasedSampler{" + "fallback=" + fallback + ", expressions=" + expressions + '}'; + } + + @Override + public String toString() { + return getDescription(); + } +} diff --git a/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerBuilder.java b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerBuilder.java new file mode 100644 index 000000000..1a6b5074e --- /dev/null +++ b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerBuilder.java @@ -0,0 +1,94 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel; + +import static java.util.Objects.requireNonNull; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.compiler.CelCompiler; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for {@link CelBasedSampler}. + * + *

This builder allows configuring CEL expressions with their associated sampling actions. Each + * expression is evaluated in order, and the first matching expression determines the sampling + * decision for a span. + */ +public final class CelBasedSamplerBuilder { + private final CelCompiler celCompiler; + private final List expressions = new ArrayList<>(); + private final Sampler defaultDelegate; + + /** + * Creates a new builder with the specified fallback sampler and CEL compiler. + * + * @param defaultDelegate The fallback sampler to use when no expressions match + * @param celCompiler The CEL compiler for compiling expressions + */ + CelBasedSamplerBuilder(Sampler defaultDelegate, CelCompiler celCompiler) { + this.defaultDelegate = defaultDelegate; + this.celCompiler = celCompiler; + } + + /** + * Use the provided sampler when the CEL expression evaluates to true. + * + * @param expression The CEL expression to evaluate + * @param sampler The sampler to use when the expression matches + * @return This builder instance for method chaining + * @throws CelValidationException if the expression cannot be compiled + */ + @CanIgnoreReturnValue + public CelBasedSamplerBuilder customize(String expression, Sampler sampler) + throws CelValidationException { + CelAbstractSyntaxTree abstractSyntaxTree = + celCompiler.compile(requireNonNull(expression, "expression must not be null")).getAst(); + + expressions.add( + new CelBasedSamplingExpression( + requireNonNull(abstractSyntaxTree, "abstractSyntaxTree must not be null"), + requireNonNull(sampler, "sampler must not be null"))); + return this; + } + + /** + * Drop all spans when the CEL expression evaluates to true. + * + * @param expression The CEL expression to evaluate + * @return This builder instance for method chaining + * @throws CelValidationException if the expression cannot be compiled + */ + @CanIgnoreReturnValue + public CelBasedSamplerBuilder drop(String expression) throws CelValidationException { + return customize(expression, Sampler.alwaysOff()); + } + + /** + * Record and sample all spans when the CEL expression evaluates to true. + * + * @param expression The CEL expression to evaluate + * @return This builder instance for method chaining + * @throws CelValidationException if the expression cannot be compiled + */ + @CanIgnoreReturnValue + public CelBasedSamplerBuilder recordAndSample(String expression) throws CelValidationException { + return customize(expression, Sampler.alwaysOn()); + } + + /** + * Build the sampler based on the configured expressions. + * + * @return a new {@link CelBasedSampler} instance + */ + public CelBasedSampler build() { + return new CelBasedSampler(expressions, defaultDelegate); + } +} diff --git a/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpression.java b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpression.java new file mode 100644 index 000000000..0b592febe --- /dev/null +++ b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpression.java @@ -0,0 +1,93 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel; + +import static java.util.Objects.requireNonNull; + +import dev.cel.common.CelAbstractSyntaxTree; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Represents a CEL-based sampling expression that contains a compiled CEL expression and its + * associated sampler delegate. + * + *

This class is used internally by {@link CelBasedSampler} to store and evaluate CEL expressions + * for sampling decisions. + */ +public final class CelBasedSamplingExpression { + private final CelAbstractSyntaxTree abstractSyntaxTree; + private final Sampler delegate; + + /** + * Creates a new CEL-based sampling expression. + * + * @param abstractSyntaxTree The compiled CEL abstract syntax tree + * @param delegate The sampler to use when this expression evaluates to true + */ + CelBasedSamplingExpression(CelAbstractSyntaxTree abstractSyntaxTree, Sampler delegate) { + this.abstractSyntaxTree = + requireNonNull(abstractSyntaxTree, "abstractSyntaxTree must not be null"); + this.delegate = requireNonNull(delegate, "delegate must not be null"); + } + + /** + * Returns the compiled CEL abstract syntax tree. + * + * @return The abstract syntax tree + */ + CelAbstractSyntaxTree getAbstractSyntaxTree() { + return abstractSyntaxTree; + } + + /** + * Returns the string representation of the CEL expression. + * + * @return The expression string + */ + String getExpression() { + return abstractSyntaxTree.getSource().getContent().toString(); + } + + /** + * Returns the sampler delegate. + * + * @return The sampler delegate + */ + Sampler getDelegate() { + return delegate; + } + + @Override + public String toString() { + return "CelBasedSamplingExpression{" + + "expression='" + + getExpression() + + "', delegate=" + + delegate + + "}"; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CelBasedSamplingExpression)) { + return false; + } + CelBasedSamplingExpression that = (CelBasedSamplingExpression) o; + return Objects.equals(abstractSyntaxTree, that.abstractSyntaxTree) + && Objects.equals(getExpression(), that.getExpression()) + && Objects.equals(delegate, that.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(abstractSyntaxTree, getExpression(), delegate); + } +} diff --git a/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProvider.java b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProvider.java new file mode 100644 index 000000000..c5bfff689 --- /dev/null +++ b/cel-sampler/src/main/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProvider.java @@ -0,0 +1,96 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel.internal; + +import dev.cel.common.CelValidationException; +import io.opentelemetry.api.incubator.config.DeclarativeConfigException; +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.contrib.sampler.cel.CelBasedSampler; +import io.opentelemetry.contrib.sampler.cel.CelBasedSamplerBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider; +import io.opentelemetry.sdk.extension.incubator.fileconfig.DeclarativeConfiguration; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.List; + +/** + * Declarative configuration SPI implementation for {@link CelBasedSampler}. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class CelBasedSamplerComponentProvider implements ComponentProvider { + + private static final String ACTION_RECORD_AND_SAMPLE = "RECORD_AND_SAMPLE"; + private static final String ACTION_DROP = "DROP"; + + @Override + public Class getType() { + return Sampler.class; + } + + @Override + public String getName() { + return "cel_based"; + } + + @Override + public Sampler create(DeclarativeConfigProperties config) { + List expressions = config.getStructuredList("expressions"); + if (expressions == null || expressions.isEmpty()) { + throw new DeclarativeConfigException("cel_based sampler .expressions is required"); + } + + CelBasedSamplerBuilder builder = CelBasedSampler.builder(getFallbackSampler(config)); + + for (DeclarativeConfigProperties expressionConfig : expressions) { + String expression = expressionConfig.getString("expression"); + if (expression == null) { + throw new DeclarativeConfigException( + "cel_based sampler .expressions[].expression is required"); + } + + String action = expressionConfig.getString("action"); + if (action == null) { + throw new DeclarativeConfigException("cel_based sampler .expressions[].action is required"); + } + + try { + if (action.equals(ACTION_RECORD_AND_SAMPLE)) { + builder.recordAndSample(expression); + } else if (action.equals(ACTION_DROP)) { + builder.drop(expression); + } else { + throw new DeclarativeConfigException( + "cel_based sampler .expressions[].action must be " + + ACTION_RECORD_AND_SAMPLE + + " or " + + ACTION_DROP); + } + } catch (CelValidationException e) { + throw new DeclarativeConfigException( + "Failed to compile CEL expression: '" + expression + "'. CEL error: " + e.getMessage(), + e); + } + } + return builder.build(); + } + + private static Sampler getFallbackSampler(DeclarativeConfigProperties config) { + DeclarativeConfigProperties fallbackModel = config.getStructured("fallback_sampler"); + if (fallbackModel == null) { + throw new DeclarativeConfigException( + "cel_based sampler .fallback_sampler is required but is null"); + } + Sampler fallbackSampler; + try { + fallbackSampler = DeclarativeConfiguration.createSampler(fallbackModel); + } catch (DeclarativeConfigException e) { + throw new DeclarativeConfigException( + "cel_based sampler failed to create .fallback_sampler sampler", e); + } + return fallbackSampler; + } +} diff --git a/cel-sampler/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider b/cel-sampler/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider new file mode 100644 index 000000000..020ee79f0 --- /dev/null +++ b/cel-sampler/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider @@ -0,0 +1 @@ +io.opentelemetry.contrib.sampler.cel.internal.CelBasedSamplerComponentProvider diff --git a/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerTest.java b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerTest.java new file mode 100644 index 000000000..fcddb5a52 --- /dev/null +++ b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplerTest.java @@ -0,0 +1,282 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel; + +import static io.opentelemetry.semconv.HttpAttributes.HTTP_RESPONSE_HEADER; +import static io.opentelemetry.semconv.UrlAttributes.URL_FULL; +import static io.opentelemetry.semconv.UrlAttributes.URL_PATH; +import static io.opentelemetry.semconv.incubating.ThreadIncubatingAttributes.THREAD_NAME; +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.cel.common.CelValidationException; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +final class CelBasedSamplerTest { + + private final IdGenerator idsGenerator = IdGenerator.random(); + private final String traceId = idsGenerator.generateTraceId(); + private final String parentSpanId = idsGenerator.generateSpanId(); + private final SpanContext sampledSpanContext = + SpanContext.create(traceId, parentSpanId, TraceFlags.getSampled(), TraceState.getDefault()); + private final Context parentContext = Context.root().with(Span.wrap(sampledSpanContext)); + private final List expressions = new ArrayList<>(); + + @Mock(strictness = Mock.Strictness.LENIENT) + private Sampler delegate; + + @BeforeEach + void setup() throws CelValidationException { + when(delegate.shouldSample(any(), any(), any(), any(), any(), any())) + .thenReturn(SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE)); + + expressions.add( + new CelBasedSamplingExpression( + CelBasedSampler.celCompiler + .compile( + "spanKind == 'SERVER' && attribute[\"" + + URL_FULL.getKey() + + "\"].matches(\"/actuator\")") + .getAst(), + Sampler.alwaysOff())); + expressions.add( + new CelBasedSamplingExpression( + CelBasedSampler.celCompiler + .compile( + "spanKind == 'SERVER' && attribute[\"" + + URL_FULL.getKey() + + "\"].matches(\".*/healthcheck\")") + .getAst(), + Sampler.alwaysOff())); + } + + @Test + void testThatThrowsOnNullParameter() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new CelBasedSampler(expressions, null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new CelBasedSampler(null, delegate)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> CelBasedSampler.builder(null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> CelBasedSampler.builder(delegate).drop(null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> CelBasedSampler.builder(delegate).recordAndSample(null)); + } + + @Test + void testThatDelegatesIfNoExpressionGiven() { + CelBasedSampler sampler = CelBasedSampler.builder(delegate).build(); + + // no http.url attribute + Attributes attributes = Attributes.empty(); + sampler.shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + verify(delegate) + .shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + + clearInvocations(delegate); + + // with http.url attribute + attributes = Attributes.of(URL_FULL, "https://example.com"); + sampler.shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + verify(delegate) + .shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + } + + @Test + void testDropOnExactMatch() throws CelValidationException { + CelBasedSampler sampler = addRules(CelBasedSampler.builder(delegate)).build(); + assertThat(shouldSample(sampler, "https://example.com/healthcheck").getDecision()) + .isEqualTo(SamplingDecision.DROP); + } + + @Test + void testDelegateOnDifferentKind() throws CelValidationException { + CelBasedSampler sampler = + addRules(CelBasedSampler.builder(delegate), SpanKind.CLIENT.name()).build(); + assertThat(shouldSample(sampler, "https://example.com/healthcheck").getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + verify(delegate).shouldSample(any(), any(), any(), any(), any(), any()); + } + + @Test + void testDelegateOnNoMatch() throws CelValidationException { + CelBasedSampler sampler = addRules(CelBasedSampler.builder(delegate)).build(); + assertThat(shouldSample(sampler, "https://example.com/customers").getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + verify(delegate).shouldSample(any(), any(), any(), any(), any(), any()); + } + + @Test + void testDelegateOnMalformedUrl() throws CelValidationException { + CelBasedSampler sampler = addRules(CelBasedSampler.builder(delegate)).build(); + assertThat(shouldSample(sampler, "abracadabra").getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + verify(delegate).shouldSample(any(), any(), any(), any(), any(), any()); + + clearInvocations(delegate); + + assertThat(shouldSample(sampler, "healthcheck").getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + verify(delegate).shouldSample(any(), any(), any(), any(), any(), any()); + } + + @Test + void testVerifiesAllGivenAttributes() throws CelValidationException { + CelBasedSampler sampler = addRules(CelBasedSampler.builder(delegate)).build(); + Attributes attributes = Attributes.of(URL_PATH, "/actuator/info"); + assertThat( + sampler + .shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()) + .getDecision()) + .isEqualTo(SamplingDecision.DROP); + } + + @Test + void customSampler() throws CelValidationException { + Attributes attributes = Attributes.of(URL_PATH, "/test"); + CelBasedSampler testSampler = + CelBasedSampler.builder(delegate) + .customize( + "attribute[\"" + URL_PATH + "\"].matches(\".*test\")", new AlternatingSampler()) + .build(); + assertThat( + testSampler + .shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()) + .getDecision()) + .isEqualTo(SamplingDecision.DROP); + assertThat( + testSampler + .shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()) + .getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + } + + @Test + void testThreadNameSampler() throws CelValidationException { + expressions.add( + new CelBasedSamplingExpression( + CelBasedSampler.celCompiler + .compile( + "spanKind == 'SERVER' && attribute[\"" + + THREAD_NAME + + "\"].matches(\"Test.*\")") + .getAst(), + Sampler.alwaysOff())); + Attributes attributes = Attributes.of(THREAD_NAME, "Test worker"); + CelBasedSampler sampler = new CelBasedSampler(expressions, delegate); + SamplingResult samplingResult = + sampler.shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + assertThat(samplingResult.getDecision()).isEqualTo(SamplingDecision.DROP); + } + + @Test + void testComplexAttributeSampler() throws CelValidationException { + expressions.add( + new CelBasedSamplingExpression( + CelBasedSampler.celCompiler + .compile( + "\"example.com\" in attribute[\"" + + HTTP_RESPONSE_HEADER.getAttributeKey("host") + + "\"]") + .getAst(), + Sampler.alwaysOff())); + Attributes attributes = + Attributes.of( + HTTP_RESPONSE_HEADER.getAttributeKey("host"), + Arrays.asList("example.com", "example.org")); + CelBasedSampler sampler = new CelBasedSampler(expressions, delegate); + SamplingResult samplingResult = + sampler.shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + assertThat(samplingResult.getDecision()).isEqualTo(SamplingDecision.DROP); + } + + private SamplingResult shouldSample(Sampler sampler, String url) { + Attributes attributes = Attributes.of(URL_FULL, url); + return sampler.shouldSample( + parentContext, traceId, "MySpanName", SpanKind.SERVER, attributes, emptyList()); + } + + private static CelBasedSamplerBuilder addRules(CelBasedSamplerBuilder builder, String kind) + throws CelValidationException { + return builder + .drop( + "attribute[\"" + + URL_FULL.getKey() + + "\"].matches(\".*/healthcheck\") && spanKind == '" + + kind + + "'") + .drop( + "attribute[\"" + URL_PATH + "\"].matches(\"/actuator\") && spanKind == '" + kind + "'"); + } + + private static CelBasedSamplerBuilder addRules(CelBasedSamplerBuilder builder) + throws CelValidationException { + return addRules(builder, SpanKind.SERVER.name()); + } + + /** Silly sampler that alternates decisions for testing. */ + private static final class AlternatingSampler implements Sampler { + private final AtomicBoolean switcher = new AtomicBoolean(); + + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + return switcher.getAndSet(!switcher.get()) + ? SamplingResult.recordAndSample() + : SamplingResult.drop(); + } + + @Override + public String getDescription() { + return "weird switching sampler for testing"; + } + } +} diff --git a/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpressionTest.java b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpressionTest.java new file mode 100644 index 000000000..9c69a620b --- /dev/null +++ b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/CelBasedSamplingExpressionTest.java @@ -0,0 +1,90 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.compiler.CelCompilerFactory; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import org.junit.jupiter.api.Test; + +final class CelBasedSamplingExpressionTest { + + @Test + void testThatThrowsOnNullParameter() throws CelValidationException { + CelAbstractSyntaxTree ast = + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(); + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new CelBasedSamplingExpression(ast, null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new CelBasedSamplingExpression(null, Sampler.alwaysOn())); + } + + @Test + void testToString() throws CelValidationException { + CelAbstractSyntaxTree ast = + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(); + CelBasedSamplingExpression celExpression = + new CelBasedSamplingExpression(ast, Sampler.alwaysOn()); + String expected = "CelBasedSamplingExpression{expression='1 == 1', delegate=AlwaysOnSampler}"; + assertEquals(expected, celExpression.toString()); + } + + @Test + void testEquals() throws CelValidationException { + CelBasedSamplingExpression celExpressionOneEqualsOne1 = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(), + Sampler.alwaysOn()); + + assertEquals(celExpressionOneEqualsOne1, celExpressionOneEqualsOne1); + assertNotEquals(celExpressionOneEqualsOne1, null); + + CelBasedSamplingExpression celExpressionOneEqualsOne2 = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(), + Sampler.alwaysOn()); + + assertEquals(celExpressionOneEqualsOne1, celExpressionOneEqualsOne2); + + CelBasedSamplingExpression celExpressionTwoEqualsTwo = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("2 == 2").getAst(), + Sampler.alwaysOn()); + + assertNotEquals(celExpressionOneEqualsOne1, celExpressionTwoEqualsTwo); + + CelBasedSamplingExpression celExpressionOneEqualsOneSamplerOff = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(), + Sampler.alwaysOff()); + assertNotEquals(celExpressionOneEqualsOne1, celExpressionOneEqualsOneSamplerOff); + } + + @Test + void testHashCode() throws CelValidationException { + CelBasedSamplingExpression celExpression1 = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(), + Sampler.alwaysOn()); + int expectedHashCode1 = celExpression1.hashCode(); + int expectedHashCode2 = celExpression1.hashCode(); + + assertEquals(expectedHashCode1, expectedHashCode2); + + CelBasedSamplingExpression celExpression2 = + new CelBasedSamplingExpression( + CelCompilerFactory.standardCelCompilerBuilder().build().compile("1 == 1").getAst(), + Sampler.alwaysOn()); + + assertEquals(expectedHashCode1, celExpression2.hashCode()); + } +} diff --git a/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProviderTest.java b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProviderTest.java new file mode 100644 index 000000000..a9a0ab620 --- /dev/null +++ b/cel-sampler/src/test/java/io/opentelemetry/contrib/sampler/cel/internal/CelBasedSamplerComponentProviderTest.java @@ -0,0 +1,359 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.sampler.cel.internal; + +import static io.opentelemetry.sdk.trace.samplers.SamplingResult.recordAndSample; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + +import dev.cel.common.CelValidationException; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.config.DeclarativeConfigException; +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.contrib.sampler.cel.CelBasedSampler; +import io.opentelemetry.contrib.sampler.cel.CelBasedSamplerBuilder; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.extension.incubator.fileconfig.DeclarativeConfiguration; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import io.opentelemetry.semconv.HttpAttributes; +import java.io.InputStream; +import java.util.Collections; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class CelBasedSamplerComponentProviderTest { + + private static final CelBasedSamplerComponentProvider PROVIDER = + new CelBasedSamplerComponentProvider(); + + private static InputStream loadResource(String resourcePath) { + return CelBasedSamplerComponentProviderTest.class.getResourceAsStream("/" + resourcePath); + } + + @Test + void shouldCreateSamplerFromYamlConfiguration() { + // Load YAML configuration from resource file + InputStream configStream = loadResource("cel-sampler-config.yaml"); + + OpenTelemetrySdk openTelemetrySdk = DeclarativeConfiguration.parseAndCreate(configStream); + Sampler sampler = openTelemetrySdk.getSdkTracerProvider().getSampler(); + + // Create expected sampler for comparison + CelBasedSampler expectedSampler; + try { + expectedSampler = + CelBasedSampler.builder(Sampler.alwaysOn()) + .drop( + "\"example.com\" in attribute[\"http.response.header.host\"] && attribute[\"http.response.status_code\"] == 200") + .drop("spanKind == \"SERVER\" && attribute[\"url.path\"].matches(\"/actuator.*\")") + .recordAndSample( + "spanKind == \"SERVER\" && attribute[\"url.path\"].matches(\"/actuator.*\")") + .build(); + } catch (CelValidationException e) { + throw new RuntimeException("Failed to create expected sampler", e); + } + + // Verify that the sampler behaves correctly with actual sampling calls + verifySamplerBehavior(sampler); + + Sampler expectedParentBasedSampler = Sampler.parentBasedBuilder(expectedSampler).build(); + // Verify that the sampler configuration matches the expected configuration + verifySamplerEquality(sampler, expectedParentBasedSampler); + } + + static void verifySamplerEqualityForValidTests(CelBasedSampler actual, CelBasedSampler expected) { + // Compare descriptions which contain the configuration details + assertThat(actual.getDescription()).isEqualTo(expected.getDescription()); + + // Test with various scenarios to ensure behavior equivalence + Context context = Context.root(); + String traceId = IdGenerator.random().generateTraceId(); + + // Test scenario 1: CLIENT span kind for drop test + Attributes clientAttrs = Attributes.builder().put("url.path", "/test").build(); + + assertThat( + actual.shouldSample( + context, + traceId, + "GET /test", + SpanKind.CLIENT, + clientAttrs, + Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, + traceId, + "GET /test", + SpanKind.CLIENT, + clientAttrs, + Collections.emptyList())); + + // Test scenario 2: SERVER span with specific path for record test + Attributes serverAttrs = Attributes.builder().put("url.path", "/v1/user").build(); + + assertThat( + actual.shouldSample( + context, + traceId, + "GET /v1/user", + SpanKind.SERVER, + serverAttrs, + Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, + traceId, + "GET /v1/user", + SpanKind.SERVER, + serverAttrs, + Collections.emptyList())); + + // Test scenario 3: Multiple expressions test with GET method + Attributes multiAttrs = + Attributes.builder().put("http.request.method", "GET").put("url.path", "/foo/bar").build(); + + assertThat( + actual.shouldSample( + context, + traceId, + "GET /foo/bar", + SpanKind.SERVER, + multiAttrs, + Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, + traceId, + "GET /foo/bar", + SpanKind.SERVER, + multiAttrs, + Collections.emptyList())); + } + + static void verifySamplerEquality(Sampler actual, Sampler expected) { + // Compare descriptions which contain the configuration details + assertThat(actual.getDescription()).isEqualTo(expected.getDescription()); + + // Test with various scenarios to ensure behavior equivalence + Context context = Context.root(); + String traceId = IdGenerator.random().generateTraceId(); + + // Test scenario 1: Should drop due to host header and status code + Attributes attrs1 = + Attributes.builder() + .put(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 200) + .put(HttpAttributes.HTTP_RESPONSE_HEADER.getAttributeKey("host"), "example.com") + .build(); + + assertThat( + actual.shouldSample( + context, traceId, "GET /test", SpanKind.SERVER, attrs1, Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, traceId, "GET /test", SpanKind.SERVER, attrs1, Collections.emptyList())); + + // Test scenario 2: Should drop due to actuator path + Attributes attrs2 = Attributes.builder().put("url.path", "/actuator/health").build(); + + assertThat( + actual.shouldSample( + context, + traceId, + "GET /actuator/health", + SpanKind.SERVER, + attrs2, + Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, + traceId, + "GET /actuator/health", + SpanKind.SERVER, + attrs2, + Collections.emptyList())); + + // Test scenario 3: Should record and sample for regular paths + Attributes attrs3 = Attributes.builder().put("url.path", "/v1/users").build(); + + assertThat( + actual.shouldSample( + context, + traceId, + "GET /v1/users", + SpanKind.SERVER, + attrs3, + Collections.emptyList())) + .isEqualTo( + expected.shouldSample( + context, + traceId, + "GET /v1/users", + SpanKind.SERVER, + attrs3, + Collections.emptyList())); + } + + static void verifySamplerBehavior(Sampler sampler) { + // SERVER span matching host and status code should be dropped + assertThat( + sampler.shouldSample( + Context.root(), + IdGenerator.random().generateTraceId(), + "GET /v1/users", + SpanKind.SERVER, + Attributes.builder() + .put(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 200) + .put(HttpAttributes.HTTP_RESPONSE_HEADER.getAttributeKey("host"), "example.com") + .build(), + Collections.emptyList())) + .isEqualTo(SamplingResult.drop()); + + // SERVER span to /actuator.* path should be dropped (first matching expression) + assertThat( + sampler.shouldSample( + Context.root(), + IdGenerator.random().generateTraceId(), + "GET /actuator/health", + SpanKind.SERVER, + Attributes.builder().put("url.path", "/actuator/health").build(), + Collections.emptyList())) + .isEqualTo(SamplingResult.drop()); + + // SERVER span to other path should be recorded and sampled + assertThat( + sampler.shouldSample( + Context.root(), + IdGenerator.random().generateTraceId(), + "GET /v1/users", + SpanKind.SERVER, + Attributes.builder().put("url.path", "/v1/users").build(), + Collections.emptyList())) + .isEqualTo(recordAndSample()); + } + + static Sampler dropSampler(Sampler fallback, String... expressions) { + CelBasedSamplerBuilder builder = CelBasedSampler.builder(fallback); + for (String expression : expressions) { + try { + builder.drop(expression); + } catch (CelValidationException e) { + fail(); + } + } + return builder.build(); + } + + static Sampler recordAndSampleSampler(Sampler fallback, String... expressions) { + CelBasedSamplerBuilder builder = CelBasedSampler.builder(fallback); + for (String expression : expressions) { + try { + builder.recordAndSample(expression); + } catch (CelValidationException e) { + fail(); + } + } + return builder.build(); + } + + @ParameterizedTest + @MethodSource("validSamplerConfigurations") + void shouldCreateValidSamplerConfigurations( + InputStream yamlStream, CelBasedSampler expectedSampler) { + DeclarativeConfigProperties configProperties = + DeclarativeConfiguration.toConfigProperties(yamlStream); + + Sampler sampler = PROVIDER.create(configProperties); + assertThat(sampler).isInstanceOf(CelBasedSampler.class); + + CelBasedSampler actualSampler = (CelBasedSampler) sampler; + + verifySamplerEqualityForValidTests(actualSampler, expectedSampler); + } + + static Stream validSamplerConfigurations() { + return Stream.of( + Arguments.of( + loadResource("valid/drop-client-spans.yaml"), + dropSampler(Sampler.alwaysOn(), "spanKind == \"CLIENT\"")), + Arguments.of( + loadResource("valid/record-specific-path.yaml"), + recordAndSampleSampler(Sampler.alwaysOff(), "attribute[\"url.path\"] == \"/v1/user\"")), + Arguments.of( + loadResource("valid/multiple-drop-expressions.yaml"), + dropSampler( + Sampler.alwaysOff(), + "attribute[\"http.request.method\"] == \"GET\"", + "attribute[\"url.path\"] == \"/foo/bar\""))); + } + + @ParameterizedTest + @MethodSource("inValidSamplerConfigurations") + void shouldFailWithAnInValidSamplerConfigurations( + InputStream yamlStream, String expectedErrorMessage) { + DeclarativeConfigProperties configProperties = + DeclarativeConfiguration.toConfigProperties(yamlStream); + + assertThatThrownBy(() -> PROVIDER.create(configProperties)) + .isInstanceOf(DeclarativeConfigException.class) + .hasMessage(expectedErrorMessage); + } + + static Stream inValidSamplerConfigurations() { + return Stream.of( + Arguments.of( + loadResource("invalid/missing-fallback-sampler.yaml"), + "cel_based sampler .fallback_sampler is required but is null"), + Arguments.of( + loadResource("invalid/invalid-fallback-sampler.yaml"), + "cel_based sampler failed to create .fallback_sampler sampler"), + Arguments.of( + loadResource("invalid/missing-expressions.yaml"), + "cel_based sampler .expressions is required"), + Arguments.of( + loadResource("invalid/empty-expressions.yaml"), + "cel_based sampler .expressions is required"), + Arguments.of( + loadResource("invalid/missing-expression.yaml"), + "cel_based sampler .expressions[].expression is required"), + Arguments.of( + loadResource("invalid/missing-action.yaml"), + "cel_based sampler .expressions[].action is required"), + Arguments.of( + loadResource("invalid/invalid-action.yaml"), + "cel_based sampler .expressions[].action must be RECORD_AND_SAMPLE or DROP")); + } + + @ParameterizedTest + @MethodSource("inValidExpressionSamplerConfigurations") + void shouldFailWithAnInValidExpressionSamplerConfigurations( + InputStream yamlStream, String expectedErrorMessage) { + DeclarativeConfigProperties configProperties = + DeclarativeConfiguration.toConfigProperties(yamlStream); + + assertThatThrownBy(() -> PROVIDER.create(configProperties)) + .isInstanceOf(DeclarativeConfigException.class) + .hasMessageMatching(expectedErrorMessage); + } + + static Stream inValidExpressionSamplerConfigurations() { + return Stream.of( + Arguments.of( + loadResource("invalid/empty-expression-value.yaml"), + "Failed to compile CEL expression: ''\\. CEL error: ERROR: :1:1: mismatched input '' expecting(?s).*"), + Arguments.of( + loadResource("invalid/invalid-cel-expression.yaml"), + "Failed to compile CEL expression: 'invalid cel expression!'\\. CEL error: ERROR: :1:9: mismatched input 'cel' expecting(?s).*")); + } +} diff --git a/cel-sampler/src/test/resources/cel-sampler-config.yaml b/cel-sampler/src/test/resources/cel-sampler-config.yaml new file mode 100644 index 000000000..ac33f1fa6 --- /dev/null +++ b/cel-sampler/src/test/resources/cel-sampler-config.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://github.com/open-telemetry/opentelemetry-configuration/raw/refs/tags/v1.0.0-rc.2/schema/opentelemetry_configuration.json +file_format: 1.0-rc.2 +tracer_provider: + sampler: + parent_based: + root: + cel_based: + fallback_sampler: + always_on: + expressions: + - action: DROP + expression: '"example.com" in attribute["http.response.header.host"] && attribute["http.response.status_code"] == 200' + - action: DROP + expression: spanKind == "SERVER" && attribute["url.path"].matches("/actuator.*") + - action: RECORD_AND_SAMPLE + expression: spanKind == "SERVER" && attribute["url.path"].matches("/actuator.*") diff --git a/cel-sampler/src/test/resources/invalid/empty-expression-value.yaml b/cel-sampler/src/test/resources/invalid/empty-expression-value.yaml new file mode 100644 index 000000000..5ad52da1d --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/empty-expression-value.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + always_on: +expressions: + - action: DROP + expression: '' diff --git a/cel-sampler/src/test/resources/invalid/empty-expressions.yaml b/cel-sampler/src/test/resources/invalid/empty-expressions.yaml new file mode 100644 index 000000000..62a87e46d --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/empty-expressions.yaml @@ -0,0 +1,3 @@ +fallback_sampler: + always_on: +expressions: [ ] diff --git a/cel-sampler/src/test/resources/invalid/invalid-action.yaml b/cel-sampler/src/test/resources/invalid/invalid-action.yaml new file mode 100644 index 000000000..c61924cc4 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/invalid-action.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + always_on: +expressions: + - action: INVALID + expression: 'spanKind == "CLIENT"' diff --git a/cel-sampler/src/test/resources/invalid/invalid-cel-expression.yaml b/cel-sampler/src/test/resources/invalid/invalid-cel-expression.yaml new file mode 100644 index 000000000..be8a41167 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/invalid-cel-expression.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + always_on: +expressions: + - action: DROP + expression: 'invalid cel expression!' diff --git a/cel-sampler/src/test/resources/invalid/invalid-fallback-sampler.yaml b/cel-sampler/src/test/resources/invalid/invalid-fallback-sampler.yaml new file mode 100644 index 000000000..ebf4b6dbd --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/invalid-fallback-sampler.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + foo: +expressions: + - action: DROP + expression: 'spanKind == "CLIENT"' diff --git a/cel-sampler/src/test/resources/invalid/missing-action.yaml b/cel-sampler/src/test/resources/invalid/missing-action.yaml new file mode 100644 index 000000000..18fd8cb58 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/missing-action.yaml @@ -0,0 +1,4 @@ +fallback_sampler: + always_on: +expressions: + - expression: 'spanKind == \"CLIENT\"' diff --git a/cel-sampler/src/test/resources/invalid/missing-expression.yaml b/cel-sampler/src/test/resources/invalid/missing-expression.yaml new file mode 100644 index 000000000..c5b14d295 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/missing-expression.yaml @@ -0,0 +1,4 @@ +fallback_sampler: + always_on: +expressions: + - action: DROP diff --git a/cel-sampler/src/test/resources/invalid/missing-expressions.yaml b/cel-sampler/src/test/resources/invalid/missing-expressions.yaml new file mode 100644 index 000000000..8fdd200c8 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/missing-expressions.yaml @@ -0,0 +1,2 @@ +fallback_sampler: + always_on: diff --git a/cel-sampler/src/test/resources/invalid/missing-fallback-sampler.yaml b/cel-sampler/src/test/resources/invalid/missing-fallback-sampler.yaml new file mode 100644 index 000000000..f688c5c61 --- /dev/null +++ b/cel-sampler/src/test/resources/invalid/missing-fallback-sampler.yaml @@ -0,0 +1,3 @@ +expressions: + - action: DROP + expression: 'spanKind == "CLIENT"' diff --git a/cel-sampler/src/test/resources/valid/drop-client-spans.yaml b/cel-sampler/src/test/resources/valid/drop-client-spans.yaml new file mode 100644 index 000000000..582c8a1ff --- /dev/null +++ b/cel-sampler/src/test/resources/valid/drop-client-spans.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + always_on: +expressions: + - action: DROP + expression: 'spanKind == "CLIENT"' diff --git a/cel-sampler/src/test/resources/valid/multiple-drop-expressions.yaml b/cel-sampler/src/test/resources/valid/multiple-drop-expressions.yaml new file mode 100644 index 000000000..27cfe38f4 --- /dev/null +++ b/cel-sampler/src/test/resources/valid/multiple-drop-expressions.yaml @@ -0,0 +1,7 @@ +fallback_sampler: + always_off: +expressions: + - action: DROP + expression: 'attribute["http.request.method"] == "GET"' + - action: DROP + expression: 'attribute["url.path"] == "/foo/bar"' diff --git a/cel-sampler/src/test/resources/valid/record-specific-path.yaml b/cel-sampler/src/test/resources/valid/record-specific-path.yaml new file mode 100644 index 000000000..4b6fcc782 --- /dev/null +++ b/cel-sampler/src/test/resources/valid/record-specific-path.yaml @@ -0,0 +1,5 @@ +fallback_sampler: + always_off: +expressions: + - action: RECORD_AND_SAMPLE + expression: 'attribute["url.path"] == "/v1/user"' diff --git a/settings.gradle.kts b/settings.gradle.kts index 1df4d33ad..8d6548de3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include(":aws-xray") include(":aws-xray-propagator") include(":azure-resources") include(":baggage-processor") +include(":cel-sampler") include(":compressors:compressor-zstd") include(":cloudfoundry-resources") include(":consistent-sampling")