diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index af53db6b2..1d5a9af8b 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -85,6 +85,7 @@ dependencies { testImplementation("org.awaitility:awaitility") testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") + testImplementation("org.mockito:mockito-core") testImplementation("org.mockito:mockito-junit-jupiter") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") diff --git a/contrib-samplers/build.gradle.kts b/contrib-samplers/build.gradle.kts new file mode 100644 index 000000000..fa4bad2a7 --- /dev/null +++ b/contrib-samplers/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("otel.java-conventions") + id("otel.publish-conventions") +} + +description = "Sampler which makes its decision based on semantic attributes values" + +dependencies { + api("io.opentelemetry:opentelemetry-sdk") + api("io.opentelemetry:opentelemetry-semconv") +} \ No newline at end of file diff --git a/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSampler.java b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSampler.java new file mode 100644 index 000000000..eaa613b95 --- /dev/null +++ b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSampler.java @@ -0,0 +1,90 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ +package io.opentelemetry.contrib.samplers; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.api.common.Attributes; +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.List; + +/** + * This sampler accepts a list of {@link SamplingRule}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 {@link java.util.regex.Pattern}. + * + *

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

Note that only attributes that were set on {@link io.opentelemetry.api.trace.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 RuleBasedRoutingSampler implements Sampler { + private final List rules; + private final SpanKind kind; + private final Sampler fallback; + + RuleBasedRoutingSampler(List rules, SpanKind kind, Sampler fallback) { + this.kind = requireNonNull(kind); + this.fallback = requireNonNull(fallback); + this.rules = requireNonNull(rules); + } + + public static RuleBasedRoutingSamplerBuilder builder(SpanKind kind, Sampler fallback) { + return new RuleBasedRoutingSamplerBuilder( + requireNonNull(kind, "span kind must not be null"), + requireNonNull(fallback, "fallback sampler must not be null")); + } + + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + if (kind != spanKind) { + return fallback.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks); + } + for (SamplingRule samplingRule : rules) { + String attributeValue = attributes.get(samplingRule.attributeKey); + if (attributeValue == null) { + continue; + } + if (samplingRule.pattern.matcher(attributeValue).find()) { + return samplingRule.delegate.shouldSample( + parentContext, traceId, name, spanKind, attributes, parentLinks); + } + } + return fallback.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks); + } + + @Override + public String getDescription() { + return "RuleBasedRoutingSampler{" + + "rules=" + + rules + + ", kind=" + + kind + + ", fallback=" + + fallback + + '}'; + } + + @Override + public String toString() { + return getDescription(); + } +} diff --git a/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerBuilder.java b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerBuilder.java new file mode 100644 index 000000000..5d3e6961d --- /dev/null +++ b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerBuilder.java @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ +package io.opentelemetry.contrib.samplers; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.ArrayList; +import java.util.List; + +public final class RuleBasedRoutingSamplerBuilder { + private final List rules = new ArrayList<>(); + private final SpanKind kind; + private final Sampler defaultDelegate; + + RuleBasedRoutingSamplerBuilder(SpanKind kind, Sampler defaultDelegate) { + this.kind = kind; + this.defaultDelegate = defaultDelegate; + } + + public RuleBasedRoutingSamplerBuilder drop(AttributeKey attributeKey, String pattern) { + rules.add( + new SamplingRule( + requireNonNull(attributeKey, "attributeKey must not be null"), + requireNonNull(pattern, "pattern must not be null"), + Sampler.alwaysOff())); + return this; + } + + public RuleBasedRoutingSamplerBuilder recordAndSample( + AttributeKey attributeKey, String pattern) { + rules.add( + new SamplingRule( + requireNonNull(attributeKey, "attributeKey must not be null"), + requireNonNull(pattern, "pattern must not be null"), + Sampler.alwaysOn())); + return this; + } + + public RuleBasedRoutingSampler build() { + return new RuleBasedRoutingSampler(rules, kind, defaultDelegate); + } +} diff --git a/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/SamplingRule.java b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/SamplingRule.java new file mode 100644 index 000000000..d131e3a5e --- /dev/null +++ b/contrib-samplers/src/main/java/io/opentelemetry/contrib/samplers/SamplingRule.java @@ -0,0 +1,48 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ +package io.opentelemetry.contrib.samplers; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.Objects; +import java.util.regex.Pattern; + +/** @see RuleBasedRoutingSampler */ +class SamplingRule { + final AttributeKey attributeKey; + final Sampler delegate; + final Pattern pattern; + + SamplingRule(AttributeKey attributeKey, String pattern, Sampler delegate) { + this.attributeKey = attributeKey; + this.pattern = Pattern.compile(pattern); + this.delegate = delegate; + } + + @Override + public String toString() { + return "SamplingRule{" + + "attributeKey=" + + attributeKey + + ", delegate=" + + delegate + + ", pattern=" + + pattern + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SamplingRule)) return false; + SamplingRule that = (SamplingRule) o; + return attributeKey.equals(that.attributeKey) && pattern.equals(that.pattern); + } + + @Override + public int hashCode() { + return Objects.hash(attributeKey, pattern); + } +} diff --git a/contrib-samplers/src/test/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerTest.java b/contrib-samplers/src/test/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerTest.java new file mode 100644 index 000000000..c7ecd8fb2 --- /dev/null +++ b/contrib-samplers/src/test/java/io/opentelemetry/contrib/samplers/RuleBasedRoutingSamplerTest.java @@ -0,0 +1,177 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ +package io.opentelemetry.contrib.samplers; + +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_TARGET; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_URL; +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 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.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.ArrayList; +import java.util.List; +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) +class RuleBasedRoutingSamplerTest { + private static final String SPAN_NAME = "MySpanName"; + private static final SpanKind SPAN_KIND = SpanKind.SERVER; + 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 patterns = new ArrayList<>(); + + @Mock(lenient = true) + private Sampler delegate; + + @BeforeEach + public void setup() { + when(delegate.shouldSample(any(), any(), any(), any(), any(), any())) + .thenReturn(SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE)); + + patterns.add(new SamplingRule(HTTP_URL, ".*/healthcheck", Sampler.alwaysOff())); + patterns.add(new SamplingRule(HTTP_TARGET, "/actuator", Sampler.alwaysOff())); + } + + @Test + public void testThatThrowsOnNullParameter() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new RuleBasedRoutingSampler(patterns, SPAN_KIND, null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new RuleBasedRoutingSampler(null, SPAN_KIND, delegate)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new RuleBasedRoutingSampler(patterns, null, delegate)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> RuleBasedRoutingSampler.builder(SPAN_KIND, null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> RuleBasedRoutingSampler.builder(null, delegate)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> RuleBasedRoutingSampler.builder(SPAN_KIND, delegate).drop(null, "")); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy( + () -> RuleBasedRoutingSampler.builder(SPAN_KIND, delegate).drop(HTTP_URL, null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy( + () -> RuleBasedRoutingSampler.builder(SPAN_KIND, delegate).recordAndSample(null, "")); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy( + () -> + RuleBasedRoutingSampler.builder(SPAN_KIND, delegate) + .recordAndSample(HTTP_URL, null)); + } + + @Test + public void testThatDelegatesIfNoRulesGiven() { + RuleBasedRoutingSampler sampler = RuleBasedRoutingSampler.builder(SPAN_KIND, delegate).build(); + + // no http.url attribute + Attributes attributes = Attributes.empty(); + sampler.shouldSample(parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()); + verify(delegate) + .shouldSample(parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()); + + clearInvocations(delegate); + + // with http.url attribute + attributes = Attributes.of(HTTP_URL, "https://example.com"); + sampler.shouldSample(parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()); + verify(delegate) + .shouldSample(parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()); + } + + @Test + public void testDropOnExactMatch() { + RuleBasedRoutingSampler sampler = + addRules(RuleBasedRoutingSampler.builder(SPAN_KIND, delegate)).build(); + assertThat(shouldSample(sampler, "https://example.com/healthcheck").getDecision()) + .isEqualTo(SamplingDecision.DROP); + } + + @Test + public void testDelegateOnDifferentKind() { + RuleBasedRoutingSampler sampler = + addRules(RuleBasedRoutingSampler.builder(SpanKind.CLIENT, delegate)).build(); + assertThat(shouldSample(sampler, "https://example.com/healthcheck").getDecision()) + .isEqualTo(SamplingDecision.RECORD_AND_SAMPLE); + verify(delegate).shouldSample(any(), any(), any(), any(), any(), any()); + } + + @Test + public void testDelegateOnNoMatch() { + RuleBasedRoutingSampler sampler = + addRules(RuleBasedRoutingSampler.builder(SPAN_KIND, 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 + public void testDelegateOnMalformedUrl() { + RuleBasedRoutingSampler sampler = + addRules(RuleBasedRoutingSampler.builder(SPAN_KIND, 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 + public void testVerifiesAllGivenAttributes() { + RuleBasedRoutingSampler sampler = + addRules(RuleBasedRoutingSampler.builder(SPAN_KIND, delegate)).build(); + Attributes attributes = Attributes.of(HTTP_TARGET, "/actuator/info"); + assertThat( + sampler + .shouldSample(parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()) + .getDecision()) + .isEqualTo(SamplingDecision.DROP); + } + + private SamplingResult shouldSample(Sampler sampler, String url) { + Attributes attributes = Attributes.of(HTTP_URL, url); + return sampler.shouldSample( + parentContext, traceId, SPAN_NAME, SPAN_KIND, attributes, emptyList()); + } + + private RuleBasedRoutingSamplerBuilder addRules(RuleBasedRoutingSamplerBuilder builder) { + return builder.drop(HTTP_URL, ".*/healthcheck").drop(HTTP_TARGET, "/actuator"); + } +} diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 9dc2d328a..20ddf20d2 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -40,7 +40,7 @@ val DEPENDENCY_SETS = listOf( ), DependencySet( "org.mockito", - "3.10.0", + "3.11.1", listOf("mockito-core", "mockito-junit-jupiter") ), DependencySet( diff --git a/settings.gradle.kts b/settings.gradle.kts index f020c22d5..6e9074b9b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ rootProject.name = "opentelemetry-java-contrib" include(":all") include(":aws-xray") +include(":contrib-samplers") include(":dependencyManagement") include(":example") include(":jmx-metrics")