Skip to content

[OPIK-6741] [BE][FE] feat: add EvalTriggerScope to evaluation rules - #7439

Merged
miguelgrc merged 3 commits into
mainfrom
miguelg/OPIK-6741-add-eval-trigger-scope
Jul 22, 2026
Merged

[OPIK-6741] [BE][FE] feat: add EvalTriggerScope to evaluation rules#7439
miguelgrc merged 3 commits into
mainfrom
miguelg/OPIK-6741-add-eval-trigger-scope

Conversation

@miguelgrc

Copy link
Copy Markdown
Contributor

Details

Add a trigger_scope field to online evaluation rules with three options: production (default), experiment, and both. This controls whether a rule fires on production traces, experiment traces, or both — enabling customers to define one set of LLM-judge metrics as the single source of truth across experiments and production monitoring.

  • BE: new EvalTriggerScope enum, DB migration (000090), field threaded through API/model/DAO/service/mapper layers, OnlineScoringSampler updated to let experiment traces through and filter per-evaluator by scope. Null-safe defaulting to PRODUCTION for backward compatibility.
  • FE: ToggleGroup control on the v2 rule create/edit dialog (production / experiment / both), wired into form schema and API payload.
  • Tests: dedicated TriggerScopeTests in sampler, updated TracesUpdated path coverage, updated integration tests.

Change checklist

  • User facing
  • Documentation update

Issues

  • OPIK-6741

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Opus 4.6
  • Scope: full implementation (BE + FE + tests)
  • Human verification: code review, manual E2E testing with real LLM scoring pipeline

Testing

  • mvn clean test -Dtest="OnlineScoringSamplerTest" — 42/42 pass (3 new TriggerScope tests + updated existing)
  • mvn clean test -Dtest="AutomationRuleEvaluatorsResourceTest" — 113/113 assertion-pass (0 failures; 2 pre-existing infra timeouts)
  • npx tsc --noEmit — clean TypeScript check
  • E2E script (test_trigger_scope_e2e.py): created 3 rules (production/experiment/both scopes), logged SDK traces + ran real experiment, verified via evaluator logs that each rule scored only its matching traces
  • Manual UI testing: verified ToggleGroup appears on create/edit/clone, persists correct scope on save, defaults to Production

Documentation

@Schema annotation added to triggerScope field on the create DTO — flows into OpenAPI spec and Fern-generated docs. No separate docs page changes needed.

Add a trigger_scope field (PRODUCTION / EXPERIMENT / BOTH) to online
evaluation rules so they can fire on experiment traces, production
traces, or both. Existing rules default to PRODUCTION (backward
compatible). Clients that omit trigger_scope get PRODUCTION silently.

BE: New EvalTriggerScope enum, DB migration 000090, field threaded
through all model/DAO/service/mapper layers, OnlineScoringSampler
now lets experiment traces through and filters per-evaluator based
on trigger scope. Null-safe defaulting in service and sampler.

FE: ToggleGroup control on the v2 rule create/edit dialog for
selecting trigger scope, wired into form schema and API payload.

Tests: 3 dedicated TriggerScope unit tests, TracesUpdated path
coverage for EXPERIMENT source, updated existing sampler and
integration tests.
@github-actions github-actions Bot added 🟠 size/L java Pull requests that update Java code Frontend Backend tests Including test files, or tests related like configuration. typescript *.ts *.tsx labels Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 46.88s
☕ spotless — java backend Format Java code 4.66s
🌐 eslint — frontend Lint + autofix JS/TS 3.47s
Total (3 ran) 55.01s
⏭️ 38 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 13

 50 files   -  2   50 suites   - 2   3m 45s ⏱️ - 1m 24s
253 tests  - 25  251 ✅  - 25  2 💤 ±0  0 ❌ ±0 
248 runs   - 30  246 ✅  - 30  2 💤 ±0  0 ❌ ±0 

Results for commit 8da394b. ± Comparison against base commit d554a6f.

This pull request removes 45 and adds 20 tests. Note that renamed tests count towards both.
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadEmptyFile__rejected
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadEmptyJsonArray__rejected
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadGarbageAsJson__rejected
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadGarbageAsJsonl__rejected
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__invalidId__failsAsync
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__lowercaseFormat__accepted
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__nonObjectElement__failsAsync
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__preservesJsonTypes
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__reservedFieldsRouted
com.comet.opik.api.resources.v1.priv.DatasetsJsonUploadResourceTest ‑ uploadJsonArray__withBom
…
com.comet.opik.api.resources.v1.events.WebhookSubscriberLoggingTest ‑ processEvent_whenSuccessfulWebhook_shouldSendRequestAndCreateLogs
com.comet.opik.api.resources.v1.events.WebhookSubscriberLoggingTest ‑ processEvent_whenWebhookFails_shouldRetryAndCreateErrorLogs
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenAddingToSameAlertAfterConfigChange__shouldUseOriginalWindow
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenConfigChanges__shouldCreateSeparateBucketsWithDifferentWindows
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenFirstEvent__shouldSetTtl
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenFirstEvent__shouldStoreWindowSizeFirstSeenAndWorkspaceId
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenSubsequentEvents__shouldNotRefreshTtl
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenSubsequentEvents__shouldPreserveOriginalWindowSize
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ deleteBucket__shouldRemoveBucketFromRedis
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ getBucketData__shouldReturnAllEventIdsAndPayloads
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.14)

286 tests  ±0   284 ✅ ±0   4m 25s ⏱️ - 1m 15s
  1 suites ±0     2 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit d8b0156. ± Comparison against base commit d554a6f.

♻️ This comment has been updated with latest results.

- Add withTriggerScope to AutomationRuleEvaluatorModel interface + 6 records
- Default null triggerScope to PRODUCTION in service save path
- Add --comment to migration 000090
- Fix DisabledRulesTest by setting explicit triggerScope on test rules
- Add triggerScope to update test expected builder
@miguelgrc
miguelgrc marked this pull request as ready for review July 22, 2026 08:35
@miguelgrc
miguelgrc requested a review from a team as a code owner July 22, 2026 08:35
andriidudar
andriidudar previously approved these changes Jul 22, 2026

@andriidudar andriidudar left a comment

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.

The FE part looks good, approved it.

thiagohora
thiagohora previously approved these changes Jul 22, 2026

@thiagohora thiagohora left a comment

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.

Approving. Cleanly threads EvalTriggerScope (production/experiment/both) through the full BE stack, migration 000090, and the v2 FE dialog; backward-compatible via PRODUCTION default. Satisfies OPIK-6741 (automatic eval-rule execution on experiment traces as a single source of truth). See inline comments for non-blocking follow-ups.

for (var trace : projectTraces) {
if (Source.isLoggingSource(trace.source())) {
// For SDK traces all evaluators apply
if (Source.isLoggingSource(trace.source()) || trace.source() == Source.EXPERIMENT) {

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.

Requirement-critical: the feature works only if experiment-run traces actually carry Source.EXPERIMENT. Unit tests build such traces by hand, so the real-experiment E2E run is what proves the acceptance criterion. (Note: experiment traces now take this scorable branch, so their selected_rule_ids are ignored — intended per the ticket.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good observation — yes, adding Source.EXPERIMENT to the scorableTraces set is required for the trigger scope to take effect. Previously experiment traces were only scorable via selectedRuleIds (the playground flow); now they can also be auto-sampled by rules with EXPERIMENT or BOTH scope. The per-evaluator matchesTriggerScope() filter then gates which rules actually fire.

🤖 Reply posted via /address-github-pr-comments

.toList();
}

private boolean matchesTriggerScope(AutomationRuleEvaluator<?, ?> evaluator, Trace trace) {

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.

matchesTriggerScope is only applied in this trace-level path, but trigger_scope is added to all evaluator types. Confirm thread/span rules either honor the scope or don't surface the toggle where it's a no-op.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Thread and span evaluators have separate scoring paths (OnlineScoringSpanSampler, TraceThreadOnlineScoringSamplerListener) that don't check triggerScope — the toggle was a no-op for those rule types. Fixed by hiding the toggle in the FE when scope is thread or span (d8b0156).

🤖 Reply posted via /address-github-pr-comments

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 d8b0156 addressed this comment by hiding the trigger-scope toggle for thread- and span-scoped rules, where it would be a no-op.

boolean hasVersion1AutomationRules(@Bind("workspaceId") String workspaceId);

@SqlUpdate("INSERT INTO automation_rules(id, workspace_id, `action`, name, sampling_rate, enabled, filters) "
@SqlUpdate("INSERT INTO automation_rules(id, workspace_id, `action`, name, sampling_rate, enabled, trigger_scope, filters) "

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.

INSERT binds the raw enum (PRODUCTION) via @BindMethods (JDBI default BY_NAME), while updateBaseRule binds getValue() (production). Works only because MySQL ENUM assignment is case-insensitive. Consider EvalTriggerScope implements HasValue + an AbstractEnumColumnMapper factory (codebase pattern), or bind getValue() consistently. action has the same pre-existing latent issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8da394bEvalTriggerScope now implements HasValue, with a new EvalTriggerScopeColumnMapper (following the DashboardScopeMapper pattern). Registered via @RegisterArgumentFactory on the DAO, and the UPDATE @Bind param type changed from String to EvalTriggerScope. Both INSERT (@BindMethods) and UPDATE (@Bind) paths now go through the argument factory for consistent lowercase binding.

🤖 Reply posted via /address-github-pr-comments

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 d8b0156 addressed this comment by making EvalTriggerScope implement HasValue, adding its enum column mapper, and registering that mapper for DAO bindings. updateBaseRule now accepts the enum directly, ensuring consistent value binding.

String name = rs.getString("name");
Float samplingRate = rs.getFloat("sampling_rate");
boolean enabled = rs.getBoolean("enabled");
EvalTriggerScope triggerScope = EvalTriggerScope.fromString(rs.getString("trigger_scope"));

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.

fromString throws on null/unknown, failing the whole row map. Safe given NOT NULL DEFAULT, but the shared AbstractEnumColumnMapper.parse() logs + returns null, degrading more gracefully.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8da394b — added null guard before EvalTriggerScope.fromString() in the RowMapper to handle rows where trigger_scope is NULL (legacy data or edge cases).

🤖 Reply posted via /address-github-pr-comments

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 d8b0156 addressed this comment by guarding against null trigger-scope values before calling fromString. Unknown non-null values can still throw, so the concern is only partially addressed.

}
};

if (evaluator.triggerScope() == null) {

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.

Nit: default is applied in 4 places (DTO @Builder.Default, here, update(), and matchesTriggerScope). Aligned with the backward-compat requirement so acceptable; only the sampler guard is strictly needed for robustness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — the @Builder.Default to PRODUCTION on the API model ensures new rules default to production-only scope. The DB migration also has DEFAULT 'production' for existing rows. Keeping both layers aligned per the existing enabled field pattern.

🤖 Reply posted via /address-github-pr-comments

Thread and span evaluators have separate scoring paths that don't
check triggerScope, so the toggle is a no-op for those rule types.
Hide it in the FE to avoid user confusion.
@miguelgrc
miguelgrc dismissed stale reviews from thiagohora and andriidudar via d8b0156 July 22, 2026 09:58
Comment on lines +630 to +641
{!isThreadScope && !isSpanScope && (
<FormField
control={form.control}
name="triggerScope"
render={({ field }) => (
<FormItem>
<Label className="flex items-center">
Trigger scope{" "}
<TooltipWrapper content="Choose whether this rule fires on production traces, experiment traces, or both.">
<Info className="ml-1 size-4 text-light-slate" />
</TooltipWrapper>
</Label>

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.

Thread/span rules stay production-only

triggerScope is part of the shared evaluator payload, but this condition hides it for scope thread/span, so create/clone/edit flows keep submitting the production default from defaultValues/reset and can't switch to experiment or both — can we render it for all scopes and only lock it down when a scope needs a fixed value?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/pages-shared/automations/AddEditRuleDialog/AddEditRuleDialog.tsx
around lines 630-651, the `triggerScope` FormField is currently wrapped in
`!isThreadScope && !isSpanScope`, which hides the trigger-scope selector for thread/span
rules. Refactor this so the `triggerScope` field is rendered for all `scope` values
(thread/trace/span) and only restricts user changes by disabling the control or forcing
a fixed value when that scope truly must not vary. Update the `onValueChange`/`value`
logic to either allow selection for the supported cases or keep it read-only with the
correct fixed value, so create/clone/edit flows can switch thread/span rules to
experiment or both.

@thiagohora thiagohora left a comment

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.

Revision 2 addresses the review feedback:

  • Thread/span rules: trigger-scope toggle now hidden (FE guard).
  • INSERT/UPDATE enum binding: unified via EvalTriggerScope implements HasValue + EvalTriggerScopeColumnMapper registered as argument factory — consistent write path.
  • RowMapper: null-guarded.

All flagged items resolved or reasonably handled; no new issues. LGTM 🚀

for (var trace : projectTraces) {
if (Source.isLoggingSource(trace.source())) {
// For SDK traces all evaluators apply
if (Source.isLoggingSource(trace.source()) || trace.source() == Source.EXPERIMENT) {

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.

💡 suggestion | Performance

Adding Source.EXPERIMENT here means experiment traces now always populate scorableTraces, so a project receiving only experiment traffic no longer hits the scorableTraces.isEmpty() early-out below — it always runs ruleEvaluatorService.findAll(...) (Redis-cached, so no extra DB load) plus a parallelStream over every evaluator, evaluating matchesTriggerScope/isEvaluatorSelectedForTrace/shouldSampleTrace per (evaluator × trace), even when no rule in the project has experiment/both scope.

Not a correctness issue and probably fine, but for experiment-heavy workspaces with no experiment-scoped rules it's wasted CPU. If that ever matters, a cheap guard (skip when all scorable traces are experiment-source and no evaluator has EXPERIMENT/BOTH scope) would restore the old fast path.

🤖 Review posted via /review-github-pr

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. The evaluator fetch is Redis-cached (ruleEvaluatorService.findAll) so there's no extra DB load, and the per-evaluator matchesTriggerScope check is a cheap enum comparison. For experiment-heavy workspaces with no experiment-scoped rules, the overhead is the parallelStream iteration over evaluators that all get filtered out — negligible in practice.

If profiling ever shows this matters, the guard you suggest (skip when all scorable traces are experiment-source and no evaluator has EXPERIMENT/BOTH scope) would be straightforward to add. Leaving as-is for now to keep the logic simple.

🤖 Reply posted via /address-github-pr-comments

@JetoPistola

Copy link
Copy Markdown
Contributor

question | Documentation

The "Documentation update" checklist item is unchecked, but this adds a user-facing capability (Trigger scope: production / experiment / both on evaluation rules). The @Schema annotation covers the API/OpenAPI surface, but would a user-facing docs page (evaluation rules / online scoring) help customers discover the experiment-scope option? Just confirming it's intentionally out of scope.

🤖 Review posted via /review-github-pr

@JetoPistola

Copy link
Copy Markdown
Contributor

👋 Review summary

What looks good

  • Clean, disciplined layering — the new EvalTriggerScope is threaded through every layer (API DTOs, model, DAO, mapper, RowMapper, service) with a dedicated HasValue/AbstractEnumColumnMapper pattern that matches existing enum conventions.
  • Backward compatibility is airtight and defended at every layer: NOT NULL DEFAULT 'production' migration, service-side null→PRODUCTION defaulting on both create and update, and a null-safe fallback in matchesTriggerScope. Existing rules keep production-only behavior.
  • Good test coverage — dedicated TriggerScopeTests, the triggerScope field left in the round-trip recursive comparison (so persistence is validated implicitly), and the TracesUpdated / selected-rule-ids paths updated consistently.
  • The isLoggingSource vs EXPERIMENT branches are cleanly non-overlapping, and the feature is a genuinely useful "single source of truth for LLM-judge metrics across experiments and production."

Overall
Solid, well-scoped implementation that's already been through a thorough review cycle with all substantive items resolved. CI is green. The approach makes sense.

Inline comments: 1 performance suggestion + 1 documentation question — nothing blocking.

🤖 Review posted via /review-github-pr

@miguelgrc
miguelgrc merged commit 5a892ce into main Jul 22, 2026
78 of 79 checks passed
@miguelgrc
miguelgrc deleted the miguelg/OPIK-6741-add-eval-trigger-scope branch July 22, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Frontend java Pull requests that update Java code 🟠 size/L tests Including test files, or tests related like configuration. typescript *.ts *.tsx

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants