Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
Expand Down Expand Up @@ -80,7 +82,8 @@ public abstract sealed class AutomationRuleEvaluator<T, E extends Filter> implem
@NotBlank @Size(max = 150, message = "cannot exceed 150 characters") private final String name;

@JsonView({View.Public.class, View.Write.class})
private final float samplingRate;
@Schema(description = "Fraction of production (SDK-logged) items this rule scores, from 0 to 1. Trace rules ignore this value for experiment, playground and optimization traces and score them in full; span and thread rules only ever evaluate SDK-logged data.")
@DecimalMin("0") @DecimalMax("1") private final float samplingRate;
Comment thread
jverre marked this conversation as resolved.

@JsonView({View.Public.class, View.Write.class})
@Builder.Default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
Expand Down Expand Up @@ -54,7 +56,8 @@ public abstract sealed class AutomationRuleEvaluatorUpdate<T, E extends Filter>
// the API boundary instead of failing the update (OPIK-7371).
@NotBlank @Size(max = 150, message = "cannot exceed 150 characters") private final String name;

private final float samplingRate;
@Schema(description = "Fraction of production (SDK-logged) items this rule scores, from 0 to 1. Trace rules ignore this value for experiment, playground and optimization traces and score them in full; span and thread rules only ever evaluate SDK-logged data.")
@DecimalMin("0") @DecimalMax("1") private final float samplingRate;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] New validation guard has no test on the update path

The new @DecimalMin("0")/@DecimalMax("1") guard is a semantically distinct validation branch on a public endpoint and nothing exercises it: no test asserts 422 for sampling_rate < 0 or > 1, nor 2xx for the inclusive boundaries 0 and 1. The sibling constraint on this very class (@SiZe(max = 150) on name, line 57) got dedicated 422 assertions for both create and update (AutomationRuleEvaluatorsResourceTest.java:959 and :979), so this change lacks the parity the team already established.

💡 Add a @ParameterizedTest over {-0.1, 1.1} asserting SC_UNPROCESSABLE_ENTITY (with the violation message) and over {0f, 1f} asserting success, for both POST /v1/private/automations/evaluators and PATCH/PUT of an existing rule.

rule: backend-testing-cover-every-new-branch-guard-and-edge-case-a-change-introduc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 9fd8f74 addressed this comment by adding parameterized create and update tests for out-of-range sampling rates and inclusive 0/1 boundaries, including validation error and success assertions.

Comment thread
jverre marked this conversation as resolved.

@Builder.Default
private final boolean enabled = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,19 @@ private void recordDecision(String workspaceId, String workspaceName, Automation
private boolean skip(String workspaceId, String workspaceName, AutomationRuleEvaluator<?, ?> evaluator, Trace trace,
String decision, String message, Object... args) {
recordDecision(workspaceId, workspaceName, evaluator, decision, 1);
logForUser(workspaceId, evaluator, trace, message, args);
return false;
}

/**
* Emits one line on the rule's user-facing log stream for the given trace.
*/
private void logForUser(String workspaceId, AutomationRuleEvaluator<?, ?> evaluator, Trace trace,
String message, Object... args) {
// Important to set the workspaceId for logging purposes
try (var logContext = createTraceLoggingContext(workspaceId, evaluator, trace)) {
userFacingLogger.info(message, args);
}
return false;
}

/**
Expand Down Expand Up @@ -354,6 +362,15 @@ private boolean shouldSampleTrace(AutomationRuleEvaluator<?, ?> evaluator, Strin
trace.id(), evaluator.getName());
}

// The sampling rate thins a production stream, so it applies to SDK traces only.
Comment thread
jverre marked this conversation as resolved.
if (!Source.isLoggingSource(trace.source())) {
logForUser(workspaceId, evaluator, trace,
"The traceId '{}' is not subject to the sampling rate '{}' for rule: '{}',"
+ " as the rate applies to production traces only",
trace.id(), evaluator.getSamplingRate(), evaluator.getName());
return true;
Comment thread
jverre marked this conversation as resolved.
}

if (secureRandom.nextFloat() >= evaluator.getSamplingRate()) {
return skip(workspaceId, workspaceName, evaluator, trace, DECISION_SKIPPED_SAMPLING,
"The traceId '{}' was skipped for rule: '{}' and per the sampling rate '{}'",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,7 @@ void processesPythonEvaluatorWhenToggleEnabled() {

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

var expectedMessage = TraceToScoreUserDefinedMetricPython.builder()
.trace(trace)
.ruleId(evaluator.getId())
.ruleName(evaluator.getName())
.code(evaluator.getCode())
.workspaceId(workspaceId)
.userName(userName)
.build();
verify(onlineScorePublisher).enqueueMessage(List.of(expectedMessage),
verify(onlineScorePublisher).enqueueMessage(List.of(toPythonMessage(evaluator, trace)),
AutomationRuleEvaluatorType.USER_DEFINED_METRIC_PYTHON);
}

Expand Down Expand Up @@ -475,6 +467,87 @@ void samplesAllTracesWhenSamplingRateIsOne() {
List.of(toLlmMessage(evaluator, trace1), toLlmMessage(evaluator, trace2)),
AutomationRuleEvaluatorType.LLM_AS_JUDGE);
}

@Test
void scoresExperimentTracesWhenSamplingRateIsZero() {
var trace = createTrace(Source.EXPERIMENT);
var evaluator = createLlmEvaluator(true, 0.0f, List.of());
whenFindAllLlmEvaluators(evaluator);

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

verify(onlineScorePublisher).enqueueMessage(List.of(toLlmMessage(evaluator, trace)),
AutomationRuleEvaluatorType.LLM_AS_JUDGE);
}

@Test
void scoresExperimentTracesButSkipsProductionTracesWhenSamplingRateIsZero() {
var sdkTrace = createTrace(Source.SDK);
var experimentTrace = createTrace(Source.EXPERIMENT);
var evaluator = createLlmEvaluator(true, 0.0f, List.of());
whenFindAllLlmEvaluators(evaluator);

onlineScoringSampler
.onTracesCreated(new TracesCreated(List.of(sdkTrace, experimentTrace), workspaceId, userName));

verify(onlineScorePublisher).enqueueMessage(List.of(toLlmMessage(evaluator, experimentTrace)),
AutomationRuleEvaluatorType.LLM_AS_JUDGE);
}

@ParameterizedTest
@EnumSource(value = Source.class, mode = EnumSource.Mode.EXCLUDE, names = {"SDK", "EXPERIMENT"})
void scoresSelectedRuleTracesWhenSamplingRateIsZero(Source source) {
var evaluator = createLlmEvaluator(true, 0.0f, List.of());
var trace = createTrace(source).toBuilder()
.metadata(metadataWithRuleIds(evaluator.getId()))
.build();
whenFindAllLlmEvaluators(evaluator);

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

verify(onlineScorePublisher).enqueueMessage(List.of(toLlmMessage(evaluator, trace)),
AutomationRuleEvaluatorType.LLM_AS_JUDGE);
}

@Test
void scoresExperimentTracesWhenSamplingRateIsZeroForPythonEvaluator() {
when(serviceTogglesConfig.isPythonEvaluatorEnabled()).thenReturn(true);
var trace = createTrace(Source.EXPERIMENT);
var evaluator = createPythonEvaluator(0.0f);
whenFindAllPythonEvaluators(evaluator);

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

verify(onlineScorePublisher).enqueueMessage(List.of(toPythonMessage(evaluator, trace)),
AutomationRuleEvaluatorType.USER_DEFINED_METRIC_PYTHON);
}

@Test
void stillHonoursFiltersOnExperimentTracesWhenSamplingIsBypassed() {
var trace = createTrace(Source.EXPERIMENT).toBuilder().name("no-match").build();
var filter = TraceFilter.builder()
.field(TraceField.NAME)
.operator(Operator.EQUAL)
.value("expected-name")
.build();
var evaluator = createLlmEvaluator(true, 0.0f, List.of(filter));
whenFindAllLlmEvaluators(evaluator);

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

verify(onlineScorePublisher, never()).enqueueMessage(any(), any());
}

@Test
void stillHonoursDisabledRuleOnExperimentTracesWhenSamplingIsBypassed() {
var trace = createTrace(Source.EXPERIMENT);
var evaluator = createLlmEvaluator(false, 0.0f, List.of());
whenFindAllLlmEvaluators(evaluator);

onlineScoringSampler.onTracesCreated(new TracesCreated(List.of(trace), workspaceId, userName));

verify(onlineScorePublisher, never()).enqueueMessage(any(), any());
}
}

@Nested
Expand Down Expand Up @@ -732,6 +805,18 @@ private TraceToScoreLlmAsJudge toLlmMessage(AutomationRuleEvaluatorLlmAsJudge ev
.build();
}

private TraceToScoreUserDefinedMetricPython toPythonMessage(
AutomationRuleEvaluatorUserDefinedMetricPython evaluator, Trace trace) {
return TraceToScoreUserDefinedMetricPython.builder()
.trace(trace)
.ruleId(evaluator.getId())
.ruleName(evaluator.getName())
.code(evaluator.getCode())
.workspaceId(workspaceId)
.userName(userName)
.build();
}

private ObjectNode metadataWithRuleIds(UUID... ruleIds) {
var metadata = JsonUtils.createObjectNode();
var array = metadata.putArray("selected_rule_ids");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,73 @@ void updateEvaluator__whenNameExceedsColumnLength__thenReturnUnprocessableEntity
}
}

@ParameterizedTest
@ValueSource(floats = {-0.1f, 1.1f})
@DisplayName("create evaluator: when the sampling rate is outside [0,1], then reject at the API boundary")
void createEvaluator__whenSamplingRateOutOfRange__thenReturnUnprocessableEntity(float samplingRate) {
var projectId = createProject();
var evaluator = factory.manufacturePojo(AutomationRuleEvaluatorLlmAsJudge.class).toBuilder()
.samplingRate(samplingRate)
.projectIds(Set.of(projectId))
.build();

try (var response = evaluatorsResourceClient.createEvaluator(
evaluator, WORKSPACE_NAME, API_KEY, HttpStatus.SC_UNPROCESSABLE_ENTITY)) {
assertThat(response.readEntity(com.comet.opik.api.error.ErrorMessage.class).errors())
.anyMatch(error -> error.contains("samplingRate"));
}
}

@ParameterizedTest
@ValueSource(floats = {0f, 1f})
@DisplayName("create evaluator: the inclusive sampling rate bounds are accepted")
void createEvaluator__whenSamplingRateOnBounds__thenSucceed(float samplingRate) {
var projectId = createProject();
var evaluator = factory.manufacturePojo(AutomationRuleEvaluatorLlmAsJudge.class).toBuilder()
.samplingRate(samplingRate)
.projectIds(Set.of(projectId))
.build();

assertThat(evaluatorsResourceClient.createEvaluator(evaluator, WORKSPACE_NAME, API_KEY)).isNotNull();
}

@ParameterizedTest
@ValueSource(floats = {-0.1f, 1.1f})
@DisplayName("update evaluator: when the sampling rate is outside [0,1], then reject at the API boundary")
void updateEvaluator__whenSamplingRateOutOfRange__thenReturnUnprocessableEntity(float samplingRate) {
var projectId = createProject();
var id = createLlmRule("Resample me " + UUID.randomUUID(), projectId);

var update = factory.manufacturePojo(AutomationRuleEvaluatorUpdateLlmAsJudge.class).toBuilder()
.samplingRate(samplingRate)
.projectIds(Set.of(projectId))
.build();

try (var response = evaluatorsResourceClient.callUpdateEvaluator(id, WORKSPACE_NAME, update, API_KEY)) {
assertThat(response.getStatusInfo().getStatusCode())
.isEqualTo(HttpStatus.SC_UNPROCESSABLE_ENTITY);
assertThat(response.readEntity(com.comet.opik.api.error.ErrorMessage.class).errors())
.anyMatch(error -> error.contains("samplingRate"));
}
}

@ParameterizedTest
@ValueSource(floats = {0f, 1f})
@DisplayName("update evaluator: the inclusive sampling rate bounds are accepted")
void updateEvaluator__whenSamplingRateOnBounds__thenSucceed(float samplingRate) {
var projectId = createProject();
var id = createLlmRule("Resample me " + UUID.randomUUID(), projectId);

var update = factory.manufacturePojo(AutomationRuleEvaluatorUpdateLlmAsJudge.class).toBuilder()
.samplingRate(samplingRate)
.projectIds(Set.of(projectId))
.build();

try (var response = evaluatorsResourceClient.callUpdateEvaluator(id, WORKSPACE_NAME, update, API_KEY)) {
assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT);
}
}

@Test
@DisplayName("a rule literally named '%' does not swallow unrelated names in the same project")
void whenAWildcardNamedRuleExists__thenUnrelatedNamesAreNotSuffixed() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ To create a new scoring metric in the UI, first navigate to the project you woul
When creating a new rule, you will be presented with the following options:

1. **Name:** The name of the rule
2. **Sampling rate:** The percentage of traces to score. When set to `100%`, all traces will be scored.
2. **Sampling rate:** The percentage of production traces to score. When set to `100%`, all production traces will be scored. For trace rules the rate applies to production (SDK-logged) traces only: traces from experiments ignore it and are always scored when the rule matches, and traces from the playground or an optimization run ignore it too but are only evaluated when you explicitly select the rule for that run. Thread and span rules only ever run on production (SDK-logged) data — threads and spans from experiments, the playground and optimization runs are excluded from online evaluation entirely.
3. **Model:** The model to use to run the LLM as a Judge metric. For evaluating traces with images, make sure to select a model that supports vision capabilities.
4. **Prompt:** The LLM as a Judge prompt to use. Opik provides a set of base prompts (Hallucination, Moderation, Answer Relevance) that you can use or you can define your own. Variables in the prompt should be in `{{variable_name}}` format.
5. **Variable mapping:** This is the mapping of the variables in the prompt to the values from the trace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ To create a new scoring metric in the UI, first navigate to the project you woul
When creating a new rule, you will be presented with the following options:

1. **Name:** The name of the rule
2. **Sampling rate:** The percentage of traces to score. When set to `100%`, all traces will be scored.
2. **Sampling rate:** The percentage of production traces to score. When set to `100%`, all production traces will be scored. The rate applies to production (SDK-logged) traces only. Traces from experiments ignore it and are always scored when the rule matches. Traces from the playground or an optimization run ignore it too, but are only evaluated when you explicitly select the rule for that run.
3. **Model:** The model to use to run the LLM as a Judge metric. For evaluating traces with images, make sure to select a model that supports vision capabilities.
4. **Prompt:** The LLM as a Judge prompt to use. Opik provides a set of base prompts (Hallucination, Moderation, Answer Relevance) that you can use or you can define your own. Variables in the prompt should be in `{{variable_name}}` format.
5. **Variable mapping:** This is the mapping of the variables in the prompt to the values from the trace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,13 @@ const RuleFilteringSection: React.FC<RuleFilteringSectionProps> = ({
}
id="sampling_rate"
label="Sampling rate"
tooltip="Percentage of traces to evaluate"
tooltip={
isTraceScope
? "Percentage of production (SDK-logged) traces to evaluate. Traces from experiments, the playground and optimization runs ignore this rate."
: `Percentage of production (SDK-logged) ${
isThreadScope ? "threads" : "spans"
} to evaluate. Only SDK-logged data is evaluated.`
}
suffix="%"
/>
)}
Expand Down
Loading