Skip to content

Commit c930856

Browse files
committed
feat(Samplers): New CEL based sampler with declarative config support
Adds new CelBasedSampler to the OpenTelemetry Java SDK, enabling advanced sampling rules using the Common Expression Language (CEL).
1 parent 2c4284b commit c930856

25 files changed

+1346
-0
lines changed

.github/component_owners.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ components:
2525
baggage-processor:
2626
- mikegoldsmith
2727
- zeitlinger
28+
cel-sampler:
29+
- dol
2830
cloudfoundry-resources:
2931
- KarstenSchnitter
3032
compressors:

cel-sampler/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CEL-Based Sampler
2+
3+
## Declarative configuration
4+
5+
The `CelBasedSampler` supports [declarative configuration](https://opentelemetry.io/docs/languages/java/configuration/#declarative-configuration).
6+
7+
To use:
8+
9+
* Add a dependency on `io.opentelemetry.contrib:opentelemetry-cel-sampler:<version>`
10+
* 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.
11+
* Configure the `.tracer_provider.sampler` to include the `cel_based` sampler.
12+
13+
Support is now available for the java agent, see an [example here](https://github.com/open-telemetry/opentelemetry-java-examples/blob/main/javaagent).
14+
15+
## Overview
16+
17+
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.
18+
19+
## Schema
20+
21+
Schema for `cel_based` sampler:
22+
23+
```yaml
24+
# The fallback sampler to use if no expressions match.
25+
fallback_sampler:
26+
always_on:
27+
# List of CEL expressions to evaluate. Expressions are evaluated in order.
28+
expressions:
29+
# The action to take when the expression evaluates to true. Must be one of: DROP, RECORD_AND_SAMPLE.
30+
- action: DROP
31+
# The CEL expression to evaluate. Must return a boolean.
32+
expression: attribute['url.path'].startsWith('/actuator')
33+
- action: RECORD_AND_SAMPLE
34+
expression: attribute['http.method'] == 'GET' && attribute['http.status_code'] < 400
35+
```
36+
37+
## Available variables
38+
39+
Available variables in CEL expressions:
40+
41+
* `name` (string): The span name
42+
* `spanKind` (string): The span kind (e.g., "SERVER", "CLIENT")
43+
* `attribute` (map): A map of span attributes
44+
45+
## Example configuration
46+
47+
Example of using `cel_based` sampler as the root sampler in `parent_based` sampler configuration:
48+
49+
```yaml
50+
tracer_provider:
51+
sampler:
52+
parent_based:
53+
root:
54+
cel_based:
55+
fallback_sampler:
56+
always_on:
57+
expressions:
58+
# Drop health check endpoints
59+
- action: DROP
60+
expression: spanKind == 'SERVER' && attribute['url.path'].startsWith('/health')
61+
# Drop actuator endpoints
62+
- action: DROP
63+
expression: spanKind == 'SERVER' && attribute['url.path'].startsWith('/actuator')
64+
# Sample only HTTP GET requests with successful responses
65+
- action: RECORD_AND_SAMPLE
66+
expression: spanKind == 'SERVER' && attribute['http.method'] == 'GET' && attribute['http.status_code'] < 400
67+
# Selectively sample based on span name
68+
- action: RECORD_AND_SAMPLE
69+
expression: name.contains('checkout') || name.contains('payment')
70+
# Drop spans with specific name patterns
71+
- action: DROP
72+
expression: name.matches('.*internal.*') && spanKind == 'INTERNAL'
73+
```
74+
75+
## Component owners
76+
77+
* [Dominic Lüchinger](https://github.com/dol), SIX Group
78+
* TBD
79+
80+
Learn more about component owners in [component_owners.yml](../.github/component_owners.yml).

cel-sampler/build.gradle.kts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
plugins {
2+
id("otel.java-conventions")
3+
id("otel.publish-conventions")
4+
}
5+
6+
description = "Sampler which makes its decision based on semantic attributes values"
7+
otelJava.moduleName.set("io.opentelemetry.contrib.sampler.cel")
8+
9+
dependencies {
10+
api("io.opentelemetry:opentelemetry-sdk")
11+
12+
implementation("dev.cel:cel:0.11.0")
13+
14+
compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi")
15+
compileOnly("io.opentelemetry:opentelemetry-sdk-extension-incubator")
16+
17+
testImplementation("io.opentelemetry.semconv:opentelemetry-semconv-incubating")
18+
testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
19+
testImplementation("io.opentelemetry:opentelemetry-sdk-extension-incubator")
20+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.sampler.cel;
7+
8+
import static java.util.Objects.requireNonNull;
9+
10+
import dev.cel.common.types.CelProtoTypes;
11+
import dev.cel.common.types.SimpleType;
12+
import dev.cel.compiler.CelCompiler;
13+
import dev.cel.compiler.CelCompilerFactory;
14+
import dev.cel.runtime.CelEvaluationException;
15+
import dev.cel.runtime.CelRuntime;
16+
import dev.cel.runtime.CelRuntimeFactory;
17+
import io.opentelemetry.api.common.Attributes;
18+
import io.opentelemetry.api.trace.SpanBuilder;
19+
import io.opentelemetry.api.trace.SpanKind;
20+
import io.opentelemetry.context.Context;
21+
import io.opentelemetry.sdk.trace.data.LinkData;
22+
import io.opentelemetry.sdk.trace.samplers.Sampler;
23+
import io.opentelemetry.sdk.trace.samplers.SamplingResult;
24+
import java.util.HashMap;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.logging.Level;
28+
import java.util.logging.Logger;
29+
30+
/**
31+
* This sampler accepts a list of {@link CelBasedSamplingExpression}s and tries to match every
32+
* proposed span against those rules. Every rule describes a span's attribute, a pattern against
33+
* which to match attribute's value, and a sampler that will make a decision about given span if
34+
* match was successful.
35+
*
36+
* <p>Matching is performed by CEL expression evaluation.
37+
*
38+
* <p>Provided span kind is checked first and if differs from the one given to {@link
39+
* #builder(Sampler)}, the default fallback sampler will make a decision.
40+
*
41+
* <p>Note that only attributes that were set on {@link SpanBuilder} will be taken into account,
42+
* attributes set after the span has been started are not used
43+
*
44+
* <p>If none of the rules matched, the default fallback sampler will make a decision.
45+
*/
46+
public final class CelBasedSampler implements Sampler {
47+
48+
private static final Logger logger = Logger.getLogger(CelBasedSampler.class.getName());
49+
50+
static final CelCompiler celCompiler =
51+
CelCompilerFactory.standardCelCompilerBuilder()
52+
.addVar("name", SimpleType.STRING)
53+
.addVar("traceId", SimpleType.STRING)
54+
.addVar("spanKind", SimpleType.STRING)
55+
.addVar("attribute", CelProtoTypes.createMap(CelProtoTypes.STRING, CelProtoTypes.DYN))
56+
.setResultType(SimpleType.BOOL)
57+
.build();
58+
59+
private final CelRuntime celRuntime;
60+
private final List<CelBasedSamplingExpression> expressions;
61+
private final Sampler fallback;
62+
63+
/**
64+
* Creates a new CEL-based sampler.
65+
*
66+
* @param expressions The list of CEL expressions to evaluate
67+
* @param fallback The fallback sampler to use when no expressions match
68+
*/
69+
public CelBasedSampler(List<CelBasedSamplingExpression> expressions, Sampler fallback) {
70+
this.expressions = requireNonNull(expressions, "expressions must not be null");
71+
this.expressions.forEach(
72+
expr -> {
73+
if (!expr.getAbstractSyntaxTree().isChecked()) {
74+
throw new IllegalArgumentException(
75+
"Expression and its AST is not checked: " + expr.getExpression());
76+
}
77+
});
78+
this.fallback = requireNonNull(fallback, "fallback must not be null");
79+
this.celRuntime = CelRuntimeFactory.standardCelRuntimeBuilder().build();
80+
}
81+
82+
/**
83+
* Creates a new builder for CEL-based sampler.
84+
*
85+
* @param fallback The fallback sampler to use when no expressions match
86+
* @return A new builder instance
87+
*/
88+
public static CelBasedSamplerBuilder builder(Sampler fallback) {
89+
return new CelBasedSamplerBuilder(
90+
requireNonNull(fallback, "fallback sampler must not be null"), celCompiler);
91+
}
92+
93+
@Override
94+
public SamplingResult shouldSample(
95+
Context parentContext,
96+
String traceId,
97+
String name,
98+
SpanKind spanKind,
99+
Attributes attributes,
100+
List<LinkData> parentLinks) {
101+
102+
// Prepare the evaluation context with span data
103+
Map<String, Object> evaluationContext = new HashMap<>();
104+
evaluationContext.put("name", name);
105+
evaluationContext.put("traceId", traceId);
106+
evaluationContext.put("spanKind", spanKind.name());
107+
evaluationContext.put("attribute", convertAttributesToMap(attributes));
108+
109+
for (CelBasedSamplingExpression expression : expressions) {
110+
try {
111+
CelRuntime.Program program = celRuntime.createProgram(expression.getAbstractSyntaxTree());
112+
Object result = program.eval(evaluationContext);
113+
// Happy path: Perform sampling based on the boolean result
114+
if (result instanceof Boolean && ((Boolean) result)) {
115+
return expression
116+
.getDelegate()
117+
.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks);
118+
}
119+
// If result is not boolean, treat as false
120+
logger.log(
121+
Level.FINE,
122+
"Expression '"
123+
+ expression.getExpression()
124+
+ "' returned non-boolean result: "
125+
+ result);
126+
} catch (CelEvaluationException e) {
127+
logger.log(
128+
Level.FINE,
129+
"Expression '" + expression.getExpression() + "' evaluation error: " + e.getMessage());
130+
}
131+
}
132+
133+
return fallback.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks);
134+
}
135+
136+
/**
137+
* Convert OpenTelemetry Attributes to a Map that CEL can work with.
138+
*
139+
* @param attributes The OpenTelemetry attributes
140+
* @return A map representation of the attributes
141+
*/
142+
private static Map<String, Object> convertAttributesToMap(Attributes attributes) {
143+
Map<String, Object> map = new HashMap<>();
144+
attributes.forEach((key, value) -> map.put(key.getKey(), value));
145+
return map;
146+
}
147+
148+
@Override
149+
public String getDescription() {
150+
return "CelBasedSampler{" + "fallback=" + fallback + ", expressions=" + expressions + '}';
151+
}
152+
153+
@Override
154+
public String toString() {
155+
return getDescription();
156+
}
157+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.sampler.cel;
7+
8+
import static java.util.Objects.requireNonNull;
9+
10+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
11+
import dev.cel.common.CelAbstractSyntaxTree;
12+
import dev.cel.common.CelValidationException;
13+
import dev.cel.compiler.CelCompiler;
14+
import io.opentelemetry.sdk.trace.samplers.Sampler;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
18+
/**
19+
* Builder for {@link CelBasedSampler}.
20+
*
21+
* <p>This builder allows configuring CEL expressions with their associated sampling actions. Each
22+
* expression is evaluated in order, and the first matching expression determines the sampling
23+
* decision for a span.
24+
*/
25+
public final class CelBasedSamplerBuilder {
26+
private final CelCompiler celCompiler;
27+
private final List<CelBasedSamplingExpression> expressions = new ArrayList<>();
28+
private final Sampler defaultDelegate;
29+
30+
/**
31+
* Creates a new builder with the specified fallback sampler and CEL compiler.
32+
*
33+
* @param defaultDelegate The fallback sampler to use when no expressions match
34+
* @param celCompiler The CEL compiler for compiling expressions
35+
*/
36+
CelBasedSamplerBuilder(Sampler defaultDelegate, CelCompiler celCompiler) {
37+
this.defaultDelegate = defaultDelegate;
38+
this.celCompiler = celCompiler;
39+
}
40+
41+
/**
42+
* Use the provided sampler when the CEL expression evaluates to true.
43+
*
44+
* @param expression The CEL expression to evaluate
45+
* @param sampler The sampler to use when the expression matches
46+
* @return This builder instance for method chaining
47+
* @throws CelValidationException if the expression cannot be compiled
48+
*/
49+
@CanIgnoreReturnValue
50+
public CelBasedSamplerBuilder customize(String expression, Sampler sampler)
51+
throws CelValidationException {
52+
CelAbstractSyntaxTree abstractSyntaxTree =
53+
celCompiler.compile(requireNonNull(expression, "expression must not be null")).getAst();
54+
55+
expressions.add(
56+
new CelBasedSamplingExpression(
57+
requireNonNull(abstractSyntaxTree, "abstractSyntaxTree must not be null"),
58+
requireNonNull(sampler, "sampler must not be null")));
59+
return this;
60+
}
61+
62+
/**
63+
* Drop all spans when the CEL expression evaluates to true.
64+
*
65+
* @param expression The CEL expression to evaluate
66+
* @return This builder instance for method chaining
67+
* @throws CelValidationException if the expression cannot be compiled
68+
*/
69+
@CanIgnoreReturnValue
70+
public CelBasedSamplerBuilder drop(String expression) throws CelValidationException {
71+
return customize(expression, Sampler.alwaysOff());
72+
}
73+
74+
/**
75+
* Record and sample all spans when the CEL expression evaluates to true.
76+
*
77+
* @param expression The CEL expression to evaluate
78+
* @return This builder instance for method chaining
79+
* @throws CelValidationException if the expression cannot be compiled
80+
*/
81+
@CanIgnoreReturnValue
82+
public CelBasedSamplerBuilder recordAndSample(String expression) throws CelValidationException {
83+
return customize(expression, Sampler.alwaysOn());
84+
}
85+
86+
/**
87+
* Build the sampler based on the configured expressions.
88+
*
89+
* @return a new {@link CelBasedSampler} instance
90+
*/
91+
public CelBasedSampler build() {
92+
return new CelBasedSampler(expressions, defaultDelegate);
93+
}
94+
}

0 commit comments

Comments
 (0)