diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/Span.java b/apps/opik-backend/src/main/java/com/comet/opik/api/Span.java index 6e664cafa8..ba5e3c0c6e 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/api/Span.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/Span.java @@ -77,6 +77,6 @@ public static class Public { @JsonView({Span.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY, description = "Duration in milliseconds as a decimal number to support sub-millisecond precision") public Double duration() { - return DurationUtils.getDurationInSeconds(startTime, endTime); + return DurationUtils.getDurationInMillisWithSubMilliPrecision(startTime, endTime); } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java b/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java index 808abdecfc..b1aeed3952 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java @@ -72,6 +72,6 @@ public static class Public { @JsonView({Span.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY, description = "Duration in milliseconds as a decimal number to support sub-millisecond precision") public Double duration() { - return DurationUtils.getDurationInSeconds(startTime, endTime); + return DurationUtils.getDurationInMillisWithSubMilliPrecision(startTime, endTime); } } diff --git a/apps/opik-backend/src/main/java/com/comet/opik/utils/DurationUtils.java b/apps/opik-backend/src/main/java/com/comet/opik/utils/DurationUtils.java index 2fd55f9fd5..74ddd85010 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/utils/DurationUtils.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/utils/DurationUtils.java @@ -11,7 +11,7 @@ public class DurationUtils { public static final Double TIME_UNIT = 1_000.0; - public static Double getDurationInSeconds(@NonNull Instant startTime, Instant endTime) { + public static Double getDurationInMillisWithSubMilliPrecision(@NonNull Instant startTime, Instant endTime) { if (endTime == null) { return null; } diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/SpansResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/SpansResourceTest.java index 8989c980fa..38cc16e697 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/SpansResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/SpansResourceTest.java @@ -2770,26 +2770,25 @@ void getSpansByProject__whenFilterFeedbackScoresLessThanEqual__thenReturnSpansFi Stream getSpansByProject__whenFilterByDuration__thenReturnSpansFiltered() { return Stream.of( - arguments(Operator.EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.GREATER_THAN, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN, Duration.ofMillis(8L).toNanos() / 1000, 7.0), - arguments(Operator.GREATER_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.GREATER_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN_EQUAL, Duration.ofMillis(1L).plusNanos(1000).toNanos() / 1000, 1.0), - arguments(Operator.LESS_THAN, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN, Duration.ofMillis(1L).plusNanos(1).toNanos() / 1000, 2.0), - arguments(Operator.LESS_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.LESS_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 2.0)); } @ParameterizedTest @MethodSource - void getSpansByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator operator, Instant start, - long end, double duration) { + void getSpansByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator operator, long end, double duration) { String workspaceName = UUID.randomUUID().toString(); String workspaceId = UUID.randomUUID().toString(); String apiKey = UUID.randomUUID().toString(); @@ -2813,6 +2812,7 @@ void getSpansByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator o }) .collect(Collectors.toCollection(ArrayList::new)); + var start = Instant.now().truncatedTo(ChronoUnit.MILLIS); spans.set(0, spans.getFirst().toBuilder() .startTime(start) .endTime(start.plus(end, ChronoUnit.MICROS)) @@ -3133,7 +3133,7 @@ static Stream getSpansByProject__whenFilterInvalidOperatorForFieldType__ @MethodSource void getSpansByProject__whenFilterInvalidOperatorForFieldType__thenReturn400(Filter filter) { var expectedError = new io.dropwizard.jersey.errors.ErrorMessage( - 400, + HttpStatus.SC_BAD_REQUEST, "Invalid operator '%s' for field '%s' of type '%s'".formatted( filter.operator().getQueryParamOperator(), filter.field().getQueryParamField(), @@ -3148,7 +3148,7 @@ void getSpansByProject__whenFilterInvalidOperatorForFieldType__thenReturn400(Fil .header(WORKSPACE_HEADER, TEST_WORKSPACE) .get(); - assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(400); + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST); var actualError = actualResponse.readEntity(io.dropwizard.jersey.errors.ErrorMessage.class); assertThat(actualError).isEqualTo(expectedError); @@ -3252,7 +3252,7 @@ void getSpansByProject__whenFilterInvalidValueOrKeyForFieldType__thenReturn400(F mockTargetWorkspace(apiKey, workspaceName, workspaceId); var expectedError = new io.dropwizard.jersey.errors.ErrorMessage( - 400, + HttpStatus.SC_BAD_REQUEST, "Invalid value '%s' or key '%s' for field '%s' of type '%s'".formatted( filter.value(), filter.key(), @@ -3269,7 +3269,7 @@ void getSpansByProject__whenFilterInvalidValueOrKeyForFieldType__thenReturn400(F .header(WORKSPACE_HEADER, workspaceName) .get(); - assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(400); + assertThat(actualResponse.getStatusInfo().getStatusCode()).isEqualTo(HttpStatus.SC_BAD_REQUEST); var actualError = actualResponse.readEntity(io.dropwizard.jersey.errors.ErrorMessage.class); assertThat(actualError).isEqualTo(expectedError); @@ -7227,6 +7227,80 @@ void getSpanStats__whenFilterFeedbackScoresLessThanEqual__thenReturnSpansFiltere getStatsAndAssert(projectName, null, filters, null, null, apiKey, workspaceName, projectStatItems); } + Stream getSpanStats__whenFilterByDuration__thenReturnSpansFiltered() { + return Stream.of( + arguments(Operator.EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.GREATER_THAN, + Duration.ofMillis(8L).toNanos() / 1000, 7.0), + arguments(Operator.GREATER_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.GREATER_THAN_EQUAL, + Duration.ofMillis(1L).plusNanos(1000).toNanos() / 1000, 1.0), + arguments(Operator.LESS_THAN, + Duration.ofMillis(1L).plusNanos(1).toNanos() / 1000, 2.0), + arguments(Operator.LESS_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.LESS_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 2.0)); + } + + @ParameterizedTest + @MethodSource + void getSpanStats__whenFilterByDuration__thenReturnSpansFiltered(Operator operator, long end, double duration) { + String workspaceName = UUID.randomUUID().toString(); + String workspaceId = UUID.randomUUID().toString(); + String apiKey = UUID.randomUUID().toString(); + + mockTargetWorkspace(apiKey, workspaceName, workspaceId); + + var projectName = generator.generate().toString(); + var spans = PodamFactoryUtils.manufacturePojoList(podamFactory, Span.class) + .stream() + .map(span -> { + Instant now = Instant.now(); + return span.toBuilder() + .projectId(null) + .projectName(projectName) + .feedbackScores(null) + .startTime(now) + .endTime(Set.of(Operator.LESS_THAN, Operator.LESS_THAN_EQUAL).contains(operator) + ? Instant.now().plusSeconds(2) + : now.plusNanos(1000)) + .build(); + }) + .collect(Collectors.toCollection(ArrayList::new)); + + var start = Instant.now().truncatedTo(ChronoUnit.MILLIS); + spans.set(0, spans.getFirst().toBuilder() + .startTime(start) + .endTime(start.plus(end, ChronoUnit.MICROS)) + .build()); + + spans.forEach(expectedSpan -> createAndAssert(expectedSpan, apiKey, workspaceName)); + + var expectedSpans = List.of(spans.getFirst()); + + var unexpectedSpans = PodamFactoryUtils.manufacturePojoList(podamFactory, Span.class).stream() + .map(span -> span.toBuilder() + .projectId(null) + .build()) + .toList(); + + unexpectedSpans.forEach(expectedSpan -> createAndAssert(expectedSpan, apiKey, workspaceName)); + + var filters = List.of( + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(operator) + .value(String.valueOf(duration)) + .build()); + + List> stats = getProjectSpanStatItems(expectedSpans); + + getStatsAndAssert(projectName, null, filters, null, null, apiKey, workspaceName, stats); + } + static Stream getSpanStats__whenFilterInvalidOperatorForFieldType__thenReturn400() { return Stream.of( SpanFilter.builder() @@ -7493,14 +7567,35 @@ static Stream getSpanStats__whenFilterInvalidOperatorForFieldType__thenR .field(SpanField.TAGS) .operator(Operator.LESS_THAN_EQUAL) .value(RandomStringUtils.randomAlphanumeric(10)) - .build()); + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.NOT_CONTAINS) + .value("1") + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.CONTAINS) + .value("1") + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.ENDS_WITH) + .value("1") + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.STARTS_WITH) + .value("1") + .build() + ); } @ParameterizedTest @MethodSource void getSpanStats__whenFilterInvalidOperatorForFieldType__thenReturn400(Filter filter) { var expectedError = new io.dropwizard.jersey.errors.ErrorMessage( - 400, + HttpStatus.SC_BAD_REQUEST, "Invalid operator '%s' for field '%s' of type '%s'".formatted( filter.operator().getQueryParamOperator(), filter.field().getQueryParamField(), @@ -7597,7 +7692,18 @@ static Stream getSpanStats__whenFilterInvalidValueOrKeyForFieldType__the .operator(Operator.EQUAL) .value("") .key("hallucination") - .build()); + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.EQUAL) + .value("") + .build(), + SpanFilter.builder() + .field(SpanField.DURATION) + .operator(Operator.EQUAL) + .value(RandomStringUtils.randomAlphanumeric(5)) + .build() + ); } @ParameterizedTest @@ -7610,7 +7716,7 @@ void getSpanStats__whenFilterInvalidValueOrKeyForFieldType__thenReturn400(Filter mockTargetWorkspace(apiKey, workspaceName, workspaceId); var expectedError = new io.dropwizard.jersey.errors.ErrorMessage( - 400, + HttpStatus.SC_BAD_REQUEST, "Invalid value '%s' or key '%s' for field '%s' of type '%s'".formatted( filter.value(), filter.key(), diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java index e240e6d63c..a3b1896f21 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java @@ -19,8 +19,6 @@ import com.comet.opik.api.filter.Field; import com.comet.opik.api.filter.Filter; import com.comet.opik.api.filter.Operator; -import com.comet.opik.api.filter.SpanField; -import com.comet.opik.api.filter.SpanFilter; import com.comet.opik.api.filter.TraceField; import com.comet.opik.api.filter.TraceFilter; import com.comet.opik.api.resources.utils.AuthTestUtils; @@ -2780,28 +2778,27 @@ void getTracesByProject__whenFilterFeedbackScoresLessThanEqual__thenReturnTraces getAndAssertPage(workspaceName, projectName, filters, traces, expectedTraces, unexpectedTraces, apiKey); } - Stream getTracesByProject__whenFilterByDuration__thenReturnSpansFiltered() { + Stream getTracesByProject__whenFilterByDuration__thenReturnTracesFiltered() { return Stream.of( - arguments(Operator.EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.GREATER_THAN, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN, Duration.ofMillis(8L).toNanos() / 1000, 7.0), - arguments(Operator.GREATER_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.GREATER_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.GREATER_THAN_EQUAL, Duration.ofMillis(1L).plusNanos(1000).toNanos() / 1000, 1.0), - arguments(Operator.LESS_THAN, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN, Duration.ofMillis(1L).plusNanos(1).toNanos() / 1000, 2.0), - arguments(Operator.LESS_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 1.0), - arguments(Operator.LESS_THAN_EQUAL, Instant.now().truncatedTo(ChronoUnit.MILLIS), + arguments(Operator.LESS_THAN_EQUAL, Duration.ofMillis(1L).toNanos() / 1000, 2.0)); } @ParameterizedTest @MethodSource - void getTracesByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator operator, Instant start, - long end, double duration) { + void getTracesByProject__whenFilterByDuration__thenReturnTracesFiltered(Operator operator, long end, double duration) { String workspaceName = UUID.randomUUID().toString(); String workspaceId = UUID.randomUUID().toString(); String apiKey = UUID.randomUUID().toString(); @@ -2826,6 +2823,7 @@ void getTracesByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator }) .collect(Collectors.toCollection(ArrayList::new)); + var start = Instant.now().truncatedTo(ChronoUnit.MILLIS); traces.set(0, traces.getFirst().toBuilder() .startTime(start) .endTime(start.plus(end, ChronoUnit.MICROS)) @@ -2844,8 +2842,8 @@ void getTracesByProject__whenFilterByDuration__thenReturnSpansFiltered(Operator unexpectedTraces.forEach(expectedTrace -> create(expectedTrace, apiKey, workspaceName)); var filters = List.of( - SpanFilter.builder() - .field(SpanField.DURATION) + TraceFilter.builder() + .field(TraceField.DURATION) .operator(operator) .value(String.valueOf(duration)) .build()); @@ -3079,7 +3077,8 @@ static Stream getTracesByProject__whenFilterInvalidOperatorForFieldType_ .field(TraceField.DURATION) .operator(Operator.NOT_CONTAINS) .value("1") - .build()); + .build() + ); } @ParameterizedTest @@ -6718,6 +6717,82 @@ void getTraceStats__whenFilterMetadataLessThanNull__thenReturnTracesFiltered() { getStatsAndAssert(projectName, null, filters, apiKey, workspaceName, expectedStats); } + Stream getTraceStats__whenFilterByDuration__thenReturnTracesFiltered() { + return Stream.of( + arguments(Operator.EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.GREATER_THAN, + Duration.ofMillis(8L).toNanos() / 1000, 7.0), + arguments(Operator.GREATER_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.GREATER_THAN_EQUAL, + Duration.ofMillis(1L).plusNanos(1000).toNanos() / 1000, 1.0), + arguments(Operator.LESS_THAN, + Duration.ofMillis(1L).plusNanos(1).toNanos() / 1000, 2.0), + arguments(Operator.LESS_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 1.0), + arguments(Operator.LESS_THAN_EQUAL, + Duration.ofMillis(1L).toNanos() / 1000, 2.0)); + } + + @ParameterizedTest + @MethodSource + void getTraceStats__whenFilterByDuration__thenReturnTracesFiltered(Operator operator, long end, double duration) { + String workspaceName = UUID.randomUUID().toString(); + String workspaceId = UUID.randomUUID().toString(); + String apiKey = UUID.randomUUID().toString(); + + mockTargetWorkspace(apiKey, workspaceName, workspaceId); + + var projectName = generator.generate().toString(); + var traces = PodamFactoryUtils.manufacturePojoList(factory, Trace.class) + .stream() + .map(trace -> { + Instant now = Instant.now(); + return trace.toBuilder() + .projectId(null) + .usage(null) + .projectName(projectName) + .feedbackScores(null) + .totalEstimatedCost(BigDecimal.ZERO) + .startTime(now) + .endTime(Set.of(Operator.LESS_THAN, Operator.LESS_THAN_EQUAL).contains(operator) + ? Instant.now().plusSeconds(2) + : now.plusNanos(1000)) + .build(); + }) + .collect(Collectors.toCollection(ArrayList::new)); + + var start = Instant.now().truncatedTo(ChronoUnit.MILLIS); + traces.set(0, traces.getFirst().toBuilder() + .startTime(start) + .endTime(start.plus(end, ChronoUnit.MICROS)) + .build()); + + traces.forEach(expectedTrace -> create(expectedTrace, apiKey, workspaceName)); + + var expectedTraces = List.of(traces.getFirst()); + + var unexpectedTraces = PodamFactoryUtils.manufacturePojoList(factory, Trace.class).stream() + .map(span -> span.toBuilder() + .projectId(null) + .build()) + .toList(); + + unexpectedTraces.forEach(expectedTrace -> create(expectedTrace, apiKey, workspaceName)); + + var filters = List.of( + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(operator) + .value(String.valueOf(duration)) + .build()); + + var expectedStats = getProjectTraceStatItems(expectedTraces); + + getStatsAndAssert(projectName, null, filters, apiKey, workspaceName, expectedStats); + } + private void getStatsAndAssert(String projectName, UUID projectId, List filters, String apiKey, String workspaceName, List> expectedStats) { WebTarget webTarget = client.target(URL_TEMPLATE.formatted(baseURI)) @@ -7513,7 +7588,28 @@ static Stream getTraceStats__whenFilterInvalidOperatorForFieldType__then .field(TraceField.TAGS) .operator(Operator.LESS_THAN_EQUAL) .value(RandomStringUtils.randomAlphanumeric(10)) - .build()); + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.ENDS_WITH) + .value("1") + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.STARTS_WITH) + .value("1") + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.CONTAINS) + .value("1") + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.NOT_CONTAINS) + .value("1") + .build() + ); } @ParameterizedTest @@ -7521,7 +7617,7 @@ static Stream getTraceStats__whenFilterInvalidOperatorForFieldType__then void getTraceStats__whenFilterInvalidOperatorForFieldType__thenReturn400(Filter filter) { var expectedError = new io.dropwizard.jersey.errors.ErrorMessage( - 400, + HttpStatus.SC_BAD_REQUEST, "Invalid operator '%s' for field '%s' of type '%s'".formatted( filter.operator().getQueryParamOperator(), filter.field().getQueryParamField(), @@ -7604,7 +7700,18 @@ static Stream getTraceStats__whenFilterInvalidValueOrKeyForFieldType__th .operator(Operator.EQUAL) .value("") .key("hallucination") - .build()); + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.EQUAL) + .value("") + .build(), + TraceFilter.builder() + .field(TraceField.DURATION) + .operator(Operator.EQUAL) + .value(RandomStringUtils.randomAlphanumeric(5)) + .build() + ); } @ParameterizedTest