diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/AlertTriggerConfig.java b/apps/opik-backend/src/main/java/com/comet/opik/api/AlertTriggerConfig.java index 1f38b8e4bbe..c5675e184c2 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/api/AlertTriggerConfig.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/AlertTriggerConfig.java @@ -43,8 +43,18 @@ public record AlertTriggerConfig( public static final String PROJECT_IDS_CONFIG_KEY = "project_ids"; public static final String THRESHOLD_CONFIG_KEY = "threshold"; public static final String WINDOW_CONFIG_KEY = "window"; + // Documented REST alias for WINDOW_CONFIG_KEY; stored configs may use either spelling. + public static final String WINDOW_IN_SECONDS_CONFIG_KEY = "window_in_seconds"; public static final String NAME_CONFIG_KEY = "name"; public static final String OPERATOR_CONFIG_KEY = "operator"; // Comma-separated GuardrailType names (e.g. "PII,TOPIC"); empty/absent means all types. public static final String GUARDRAIL_TYPES_CONFIG_KEY = "guardrail_types"; + + public static String resolveWindow(Map configValue) { + if (configValue == null) { + return null; + } + String window = configValue.get(WINDOW_CONFIG_KEY); + return window != null ? window : configValue.get(WINDOW_IN_SECONDS_CONFIG_KEY); + } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJob.java b/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJob.java index 1e596b6c46e..e845f14d24d 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJob.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJob.java @@ -59,6 +59,7 @@ import static com.comet.opik.api.AlertTriggerConfig.OPERATOR_CONFIG_KEY; import static com.comet.opik.api.AlertTriggerConfig.THRESHOLD_CONFIG_KEY; import static com.comet.opik.api.AlertTriggerConfig.WINDOW_CONFIG_KEY; +import static com.comet.opik.api.AlertTriggerConfig.resolveWindow; /** * Scheduled job for processing metrics-based alerts. @@ -425,7 +426,7 @@ private TriggerConfig buildTriggerConfig(com.comet.opik.api.AlertTriggerConfig c } BigDecimal threshold = new BigDecimal(thresholdString); - var windowString = config.configValue().get(WINDOW_CONFIG_KEY); + var windowString = resolveWindow(config.configValue()); if (windowString == null) { throw new IllegalArgumentException( "Missing config value for key '%s' in trigger of type '%s'" diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJobTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJobTest.java index a2fbe136f9f..07b0dde99ee 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJobTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/jobs/MetricsAlertJobTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Stream; import static com.comet.opik.api.AlertTriggerConfig.NAME_CONFIG_KEY; import static com.comet.opik.api.AlertTriggerConfig.OPERATOR_CONFIG_KEY; @@ -229,6 +231,33 @@ void payloadScalarsMatchSourceOrderEvenWhenSecondFetchCompletesFirst(AlertEventT assertThat(payload.get("conditions").get(1).get("threshold").asText()).isEqualTo("0.6000"); } + @ParameterizedTest + @MethodSource("windowConfigKeys") + void firesTraceErrorsWhenWindowProvidedUnderCanonicalOrDocumentedAlias(String windowKey) { + Alert alert = alertWithErrorThreshold(windowKey, "2", "300"); + + when(projectMetricsDAO.getTotalTraceErrors(anyList(), any(Instant.class), any(Instant.class))) + .thenReturn(Mono.just(new BigDecimal("3"))); + when(alertService.findAllByWorkspaceAndEventTypes(null, + MetricsAlertJob.SUPPORTED_EVENT_TYPES)).thenReturn(List.of(alert)); + + job.doJob(null); + + ArgumentCaptor> payloadCaptor = listCaptor(); + verify(alertWebhookSender, timeout(ASYNC_TIMEOUT_MS)).createAndSendWebhook( + any(), eq(WORKSPACE_ID), anyString(), eq(AlertEventType.TRACE_ERRORS), anyList(), + payloadCaptor.capture(), anyList()); + + JsonNode payload = JsonUtils.readValue(payloadCaptor.getValue().getFirst(), JsonNode.class); + assertThat(payload.get("window_seconds").asLong()).isEqualTo(300L); + assertThat(payload.get("metric_value").asText()).isEqualTo("3"); + assertThat(payload.get("threshold").asText()).isEqualTo("2"); + } + + static Stream windowConfigKeys() { + return Stream.of(WINDOW_CONFIG_KEY, "window_in_seconds"); + } + @Test void doesNotEvaluateWhenInterrupted() throws org.quartz.UnableToInterruptJobException { job.interrupt(); @@ -274,6 +303,30 @@ private static Alert alertWithGroupedFeedbackConfigs(AlertEventType eventType, i .build(); } + private static Alert alertWithErrorThreshold(String windowKey, String threshold, String window) { + AlertTrigger trigger = AlertTrigger.builder() + .id(UUID.randomUUID()) + .eventType(AlertEventType.TRACE_ERRORS) + .triggerConfigs(List.of(AlertTriggerConfig.builder() + .id(UUID.randomUUID()) + .type(AlertTriggerConfigType.THRESHOLD_ERRORS) + .configValue(Map.of( + THRESHOLD_CONFIG_KEY, threshold, + windowKey, window)) + .build())) + .build(); + + return Alert.builder() + .id(UUID.randomUUID()) + .name("test-alert") + .enabled(true) + .webhook(Webhook.builder().url("http://example/hook").build()) + .triggers(List.of(trigger)) + .projectId(PROJECT_ID) + .workspaceId(WORKSPACE_ID) + .build(); + } + private static AlertTriggerConfig feedbackConfig(String operator, String threshold, Integer groupIndex) { return AlertTriggerConfig.builder() .id(UUID.randomUUID())