diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsController.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsController.java new file mode 100644 index 00000000..342ffc6b --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsController.java @@ -0,0 +1,95 @@ +package com.consoleconnect.kraken.operator.controller.api; + +import static com.consoleconnect.kraken.operator.core.model.HttpResponse.ok; + +import com.consoleconnect.kraken.operator.controller.dto.statistics.ApiRequestActivityStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ErrorApiRequestStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.MostPopularEndpointStatistics; +import com.consoleconnect.kraken.operator.controller.service.statistics.ApiActivityStatisticsService; +import com.consoleconnect.kraken.operator.core.model.HttpResponse; +import com.consoleconnect.kraken.operator.core.request.ApiStatisticsSearchRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.ZonedDateTime; +import lombok.AllArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController() +@RequestMapping( + value = "/products/{productId}/envs/{envId}/statistics", + produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "API Activities Statistics", description = "API Activities Statistics") +public class EnvAPIActivityStatisticsController { + + private final ApiActivityStatisticsService apiActivityStatisticsService; + + @Operation(summary = "Load api activity request statistics") + @GetMapping("/api-activity-requests") + public HttpResponse getRequestStatistics( + @PathVariable("productId") String productId, + @PathVariable("envId") String envId, + @RequestParam(value = "requestStartTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestStartTime, + @RequestParam(value = "requestEndTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestEndTime, + @RequestParam(value = "buyerId", required = false) String buyerId) { + + return ok( + apiActivityStatisticsService.loadRequestStatistics( + ApiStatisticsSearchRequest.builder() + .env(envId) + .buyerId(buyerId) + .queryStart(requestStartTime) + .queryEnd(requestEndTime) + .build())); + } + + @Operation(summary = "Load error request statistics") + @GetMapping("/error-requests") + public HttpResponse getErrorStatistics( + @PathVariable("productId") String productId, + @PathVariable("envId") String envId, + @RequestParam(value = "requestStartTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestStartTime, + @RequestParam(value = "requestEndTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestEndTime, + @RequestParam(value = "buyerId", required = false) String buyerId) { + + return ok( + apiActivityStatisticsService.loadErrorsStatistics( + ApiStatisticsSearchRequest.builder() + .env(envId) + .buyerId(buyerId) + .queryStart(requestStartTime) + .queryEnd(requestEndTime) + .build())); + } + + @Operation(summary = "Load most popular endpoint statistics") + @GetMapping("/most-popular-endpoint") + public HttpResponse getMostPopularEndpointStatistics( + @PathVariable("productId") String productId, + @PathVariable("envId") String envId, + @RequestParam(value = "requestStartTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestStartTime, + @RequestParam(value = "requestEndTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + ZonedDateTime requestEndTime, + @RequestParam(value = "buyerId", required = false) String buyerId) { + + return ok( + apiActivityStatisticsService.loadMostPopularEndpointStatistics( + ApiStatisticsSearchRequest.builder() + .env(envId) + .buyerId(buyerId) + .queryStart(requestStartTime) + .queryEnd(requestEndTime) + .build())); + } +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ApiRequestActivityStatistics.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ApiRequestActivityStatistics.java new file mode 100644 index 00000000..88e67f0b --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ApiRequestActivityStatistics.java @@ -0,0 +1,13 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ApiRequestActivityStatistics { + private List requestStatistics; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/EndpointUsage.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/EndpointUsage.java new file mode 100644 index 00000000..8013c37f --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/EndpointUsage.java @@ -0,0 +1,13 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class EndpointUsage { + private String method; + private String endpoint; + private Long usage; + private double popularity; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorApiRequestStatistics.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorApiRequestStatistics.java new file mode 100644 index 00000000..41c93f17 --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorApiRequestStatistics.java @@ -0,0 +1,13 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ErrorApiRequestStatistics { + private List errorBreakdowns; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorBreakdown.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorBreakdown.java new file mode 100644 index 00000000..07807562 --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/ErrorBreakdown.java @@ -0,0 +1,15 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import java.time.LocalDate; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ErrorBreakdown { + private LocalDate date; + private Map errors; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/MostPopularEndpointStatistics.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/MostPopularEndpointStatistics.java new file mode 100644 index 00000000..6a4f985b --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/MostPopularEndpointStatistics.java @@ -0,0 +1,13 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class MostPopularEndpointStatistics { + private List endpointUsages; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/RequestStatistics.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/RequestStatistics.java new file mode 100644 index 00000000..f7d16289 --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/dto/statistics/RequestStatistics.java @@ -0,0 +1,13 @@ +package com.consoleconnect.kraken.operator.controller.dto.statistics; + +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RequestStatistics { + private LocalDate date; + private Long success; + private Long error; +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsService.java b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsService.java new file mode 100644 index 00000000..9acefadd --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/main/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsService.java @@ -0,0 +1,197 @@ +package com.consoleconnect.kraken.operator.controller.service.statistics; + +import com.consoleconnect.kraken.operator.controller.dto.statistics.ApiRequestActivityStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.EndpointUsage; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ErrorApiRequestStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ErrorBreakdown; +import com.consoleconnect.kraken.operator.controller.dto.statistics.MostPopularEndpointStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.RequestStatistics; +import com.consoleconnect.kraken.operator.core.entity.AbstractHttpEntity; +import com.consoleconnect.kraken.operator.core.entity.ApiActivityLogEntity; +import com.consoleconnect.kraken.operator.core.repo.ApiActivityLogRepository; +import com.consoleconnect.kraken.operator.core.request.ApiStatisticsSearchRequest; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class ApiActivityStatisticsService { + + public static final String CREATED_AT = "createdAt"; + public static final String ENV = "env"; + public static final String CALL_SEQ = "callSeq"; + public static final String HTTP_STATUS_CODE = "httpStatusCode"; + public static final String CALL_SEQ_ZERO = "0"; + public static final String BUYER = "buyer"; + public static final int NUMBER_OF_MOST_POPULAR_ENDPOINT_LIMIT = 7; + + private final ApiActivityLogRepository repository; + + public ApiRequestActivityStatistics loadRequestStatistics( + ApiStatisticsSearchRequest searchRequest) { + var zoneId = searchRequest.getQueryStart().getZone(); + var logs = getApiActivityLogEntities(searchRequest); + var logsGroupedByDay = groupByDayAndSuccessError(zoneId, logs); + return createApiRequestActivityStatistics(logsGroupedByDay); + } + + private List getApiActivityLogEntities( + ApiStatisticsSearchRequest searchRequest) { + Specification spec = + (root, query, criteriaBuilder) -> { + var predicateList = predicates(searchRequest, root, criteriaBuilder); + if (searchRequest.getBuyerId() != null) { + predicateList.add(criteriaBuilder.equal(root.get(BUYER), searchRequest.getBuyerId())); + } + return query.where(predicateList.toArray(new Predicate[0])).getRestriction(); + }; + return repository.findAll(spec); + } + + private Map> groupByDayAndSuccessError( + ZoneId zoneId, List logs) { + return logs.stream() + .collect( + Collectors.groupingBy( + entity -> + entity.getCreatedAt().withZoneSameInstant(zoneId).truncatedTo(ChronoUnit.DAYS), + Collectors.groupingBy( + entity -> status(entity.getHttpStatusCode()), Collectors.counting()))); + } + + private ApiRequestActivityStatistics createApiRequestActivityStatistics( + Map> logsGroupedByDayAndHttpStatus) { + var stats = + logsGroupedByDayAndHttpStatus.entrySet().stream() + .map( + dateEntry -> + new RequestStatistics( + dateEntry.getKey().toLocalDate(), + dateEntry.getValue().get(RequestStatus.SUCCESS), + dateEntry.getValue().get(RequestStatus.ERROR))) + .sorted(Comparator.comparing(RequestStatistics::getDate)) + .toList(); + return new ApiRequestActivityStatistics(stats); + } + + private RequestStatus status(Integer statusCode) { + return (HttpStatus.valueOf(statusCode).is2xxSuccessful()) + ? RequestStatus.SUCCESS + : RequestStatus.ERROR; + } + + public ErrorApiRequestStatistics loadErrorsStatistics(ApiStatisticsSearchRequest searchRequest) { + var zoneId = searchRequest.getQueryStart().getZone(); + var errorLogs = getApiActivityLogErrorEntities(searchRequest); + var logsGroupedByDayAndErrors = groupByDayAndStatus(zoneId, errorLogs); + return createErrorApiRequestStatistics(logsGroupedByDayAndErrors); + } + + private List getApiActivityLogErrorEntities( + ApiStatisticsSearchRequest searchRequest) { + Specification spec = + (root, query, criteriaBuilder) -> { + var predicateList = predicates(searchRequest, root, criteriaBuilder); + if (searchRequest.getBuyerId() != null) { + predicateList.add(criteriaBuilder.equal(root.get(BUYER), searchRequest.getBuyerId())); + } + predicateList.add(criteriaBuilder.greaterThanOrEqualTo(root.get(HTTP_STATUS_CODE), 400)); + predicateList.add(criteriaBuilder.lessThan(root.get(HTTP_STATUS_CODE), 600)); + return query.where(predicateList.toArray(new Predicate[0])).getRestriction(); + }; + return repository.findAll(spec); + } + + private Map> groupByDayAndStatus( + ZoneId zoneId, List logs) { + return logs.stream() + .collect( + Collectors.groupingBy( + entity -> + entity.getCreatedAt().withZoneSameInstant(zoneId).truncatedTo(ChronoUnit.DAYS), + Collectors.groupingBy( + AbstractHttpEntity::getHttpStatusCode, Collectors.counting()))); + } + + private ErrorApiRequestStatistics createErrorApiRequestStatistics( + Map> stats) { + var data = + stats.entrySet().stream() + .map( + dateEntry -> + new ErrorBreakdown(dateEntry.getKey().toLocalDate(), dateEntry.getValue())) + .sorted(Comparator.comparing(ErrorBreakdown::getDate)) + .toList(); + return new ErrorApiRequestStatistics(data); + } + + public MostPopularEndpointStatistics loadMostPopularEndpointStatistics( + ApiStatisticsSearchRequest searchRequest) { + List endpointPerUsage = + repository.findTopEndpoints( + searchRequest.getEnv(), + searchRequest.getQueryStart(), + searchRequest.getQueryEnd(), + CALL_SEQ_ZERO, + searchRequest.getBuyerId(), + NUMBER_OF_MOST_POPULAR_ENDPOINT_LIMIT); + long numberOfAllRequests = + repository.count( + (root, query, criteriaBuilder) -> { + var predicateList = predicates(searchRequest, root, criteriaBuilder); + return query.where(predicateList.toArray(new Predicate[0])).getRestriction(); + }); + + var data = + endpointPerUsage.stream() + .map(endpoint -> createEndpointUsage(endpoint, numberOfAllRequests)) + .toList(); + return new MostPopularEndpointStatistics(data); + } + + private EndpointUsage createEndpointUsage(Object[] endpoint, long numberOfAllRequests) { + var path = (String) endpoint[0]; + var method = (String) endpoint[1]; + var number = (Long) endpoint[2]; + var percentage = + new BigDecimal(number) + .divide(new BigDecimal(numberOfAllRequests), 5, RoundingMode.DOWN) + .multiply(new BigDecimal(100)) + .setScale(2, RoundingMode.DOWN); + return new EndpointUsage(method, path, number, percentage.doubleValue()); + } + + private List predicates( + ApiStatisticsSearchRequest searchRequest, + Root root, + CriteriaBuilder criteriaBuilder) { + var predicateList = new ArrayList(); + predicateList.add(criteriaBuilder.equal(root.get(ENV), searchRequest.getEnv())); + predicateList.add(criteriaBuilder.equal(root.get(CALL_SEQ), CALL_SEQ_ZERO)); + predicateList.add( + criteriaBuilder.greaterThanOrEqualTo(root.get(CREATED_AT), searchRequest.getQueryStart())); + predicateList.add( + criteriaBuilder.lessThanOrEqualTo(root.get(CREATED_AT), searchRequest.getQueryEnd())); + return predicateList; + } + + enum RequestStatus { + SUCCESS, + ERROR + } +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsControllerTest.java b/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsControllerTest.java new file mode 100644 index 00000000..21f10662 --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/api/EnvAPIActivityStatisticsControllerTest.java @@ -0,0 +1,160 @@ +package com.consoleconnect.kraken.operator.controller.api; + +import static java.util.List.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.consoleconnect.kraken.operator.config.TestApplication; +import com.consoleconnect.kraken.operator.controller.WebTestClientHelper; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ApiRequestActivityStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.EndpointUsage; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ErrorApiRequestStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.ErrorBreakdown; +import com.consoleconnect.kraken.operator.controller.dto.statistics.MostPopularEndpointStatistics; +import com.consoleconnect.kraken.operator.controller.dto.statistics.RequestStatistics; +import com.consoleconnect.kraken.operator.controller.service.statistics.ApiActivityStatisticsService; +import com.consoleconnect.kraken.operator.core.model.HttpResponse; +import com.consoleconnect.kraken.operator.core.request.ApiStatisticsSearchRequest; +import com.consoleconnect.kraken.operator.test.AbstractIntegrationTest; +import com.consoleconnect.kraken.operator.test.MockIntegrationTest; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.UUID; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; + +@ActiveProfiles("test-auth-server-enabled") +@MockIntegrationTest +@ContextConfiguration(classes = TestApplication.class) +class EnvAPIActivityStatisticsControllerTest extends AbstractIntegrationTest { + + public static final ZonedDateTime START_DATE = ZonedDateTime.parse("2023-10-24T00:00:00-03:00"); + public static final ZonedDateTime END_DATE = ZonedDateTime.parse("2023-10-25T00:00:00-03:00"); + public static final String BUYER_ID_1 = "buyerId1"; + @MockBean private ApiActivityStatisticsService service; + @Autowired private ObjectMapper objectMapper; + + private final WebTestClientHelper testClientHelper; + + @Autowired + EnvAPIActivityStatisticsControllerTest(WebTestClient webTestClient) { + testClientHelper = new WebTestClientHelper(webTestClient); + } + + @Test + void givenApiActivityLogs_whenGettingApiRequestStatistics_thenReturnsOk() { + // given + var envId = UUID.randomUUID(); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(START_DATE) + .queryEnd(END_DATE) + .buyerId(BUYER_ID_1) + .build(); + + var apiRequestActivityStatistics = + new ApiRequestActivityStatistics( + of(new RequestStatistics(START_DATE.toLocalDate(), 100L, 200L))); + when(service.loadRequestStatistics(searchRequest)).thenReturn(apiRequestActivityStatistics); + // when + var path = + String.format("/products/%s/envs/%s/statistics/api-activity-requests", "productId", envId); + testClientHelper.getAndVerify( + (uriBuilder -> + uriBuilder + .path(path) + .queryParam("requestStartTime", START_DATE) + .queryParam("requestEndTime", END_DATE) + .queryParam("buyerId", BUYER_ID_1) + .build()), + bodyStr -> { + // then + var result = + content(bodyStr, new TypeReference>() {}); + assertThat(result.getData()).isEqualTo(apiRequestActivityStatistics); + }); + } + + @Test + void givenApiActivityLogs_whenLoadErrorsStatistics_thenReturnsOk() { + // given + var envId = UUID.randomUUID(); + + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(START_DATE) + .queryEnd(END_DATE) + .buyerId(BUYER_ID_1) + .build(); + + var errors = + new ErrorApiRequestStatistics( + of(new ErrorBreakdown(START_DATE.toLocalDate(), new HashMap<>()))); + when(service.loadErrorsStatistics(searchRequest)).thenReturn(errors); + // when + var path = String.format("/products/%s/envs/%s/statistics/error-requests", "productId", envId); + testClientHelper.getAndVerify( + (uriBuilder -> + uriBuilder + .path(path) + .queryParam("requestStartTime", START_DATE) + .queryParam("requestEndTime", END_DATE) + .queryParam("buyerId", BUYER_ID_1) + .build()), + bodyStr -> { + // then + var result = + content(bodyStr, new TypeReference>() {}); + assertThat(result.getData()).isEqualTo(errors); + }); + } + + @Test + void givenApiActivityLogs_whenLoadMostPopularEndpointStatistics_thenReturnsOk() { + // given + var envId = UUID.randomUUID(); + + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(START_DATE) + .queryEnd(END_DATE) + .buyerId(BUYER_ID_1) + .build(); + + var popular = + new MostPopularEndpointStatistics(of(new EndpointUsage("GEt", "/path/1", 100L, 1.1f))); + when(service.loadMostPopularEndpointStatistics(searchRequest)).thenReturn(popular); + // when + var path = + String.format("/products/%s/envs/%s/statistics/most-popular-endpoint", "productId", envId); + testClientHelper.getAndVerify( + (uriBuilder -> + uriBuilder + .path(path) + .queryParam("requestStartTime", START_DATE) + .queryParam("requestEndTime", END_DATE) + .queryParam("buyerId", BUYER_ID_1) + .build()), + bodyStr -> { + // then + var result = + content(bodyStr, new TypeReference>() {}); + assertThat(result.getData()).isEqualTo(popular); + }); + } + + @SneakyThrows + private T content(String response, TypeReference typeReference) { + return objectMapper.readValue(response, typeReference); + } +} diff --git a/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsServiceTest.java b/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsServiceTest.java new file mode 100644 index 00000000..1f978f8a --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-controller/src/test/java/com/consoleconnect/kraken/operator/controller/service/statistics/ApiActivityStatisticsServiceTest.java @@ -0,0 +1,550 @@ +package com.consoleconnect.kraken.operator.controller.service.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.consoleconnect.kraken.operator.config.TestApplication; +import com.consoleconnect.kraken.operator.controller.handler.ClientAPIAuditLogEventHandler; +import com.consoleconnect.kraken.operator.core.client.ClientEvent; +import com.consoleconnect.kraken.operator.core.client.ClientEventTypeEnum; +import com.consoleconnect.kraken.operator.core.dto.ApiActivityLog; +import com.consoleconnect.kraken.operator.core.request.ApiStatisticsSearchRequest; +import com.consoleconnect.kraken.operator.core.toolkit.JsonToolkit; +import com.consoleconnect.kraken.operator.test.AbstractIntegrationTest; +import com.consoleconnect.kraken.operator.test.MockIntegrationTest; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +@ActiveProfiles("test-auth-server-enabled") +@MockIntegrationTest +@ContextConfiguration(classes = TestApplication.class) +class ApiActivityStatisticsServiceTest extends AbstractIntegrationTest { + + public static final String BUYER_ID_1 = "buyerId1"; + public static final String BUYER_ID_2 = "buyerId2"; + public static final String NOW_WITH_TIMEZONE = "2023-10-24T05:00:00+02:00"; + + @Autowired private ApiActivityStatisticsService sut; + @Autowired private ClientAPIAuditLogEventHandler clientAPIAuditLogEventHandler; + + @Test + void givenNoLogsForEnv_whenLoadRequestStatistics_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadRequestStatistics(searchRequest); + // then + assertThat(result.getRequestStatistics()).isEmpty(); + } + + @Test + void givenLogsForEnv_whenLoadRequestStatisticsOutOfTheTimeRage_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(4)) + .queryEnd(now.minusDays(2)) + .build(); + // when + var result = sut.loadRequestStatistics(searchRequest); + // then + assertThat(result.getRequestStatistics()).isEmpty(); + } + + private ZonedDateTime toUTC(ZonedDateTime now) { + return now.withZoneSameInstant(ZoneId.of("UTC")); + } + + @Test + void givenApiActivityLogs_whenLoadRequestStatistics_thenReturnsApiRequestsStatisticsSortedAsc() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadRequestStatistics(searchRequest); + // then + assertThat(result.getRequestStatistics()).hasSize(2); + var statsFor1Day = result.getRequestStatistics().get(0); + var statsFor2Day = result.getRequestStatistics().get(1); + assertThat(statsFor1Day.getDate()).isBefore(statsFor2Day.getDate()); + assertThat(statsFor1Day.getSuccess()).isEqualTo(8); + assertThat(statsFor1Day.getError()).isEqualTo(8); + assertThat(statsFor2Day.getSuccess()).isEqualTo(12); + assertThat(statsFor2Day.getError()).isEqualTo(12); + } + + @Test + void givenApiActivityLogs_whenLoadRequestStatisticsByBuyerId_thenReturnsApiRequestsStatistics() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .buyerId(BUYER_ID_1) + .build(); + // when + var result = sut.loadRequestStatistics(searchRequest); + // then + assertThat(result.getRequestStatistics()).hasSize(2); + var statsFor1Day = result.getRequestStatistics().get(0); + assertThat(statsFor1Day.getSuccess()).isEqualTo(8); + assertThat(statsFor1Day.getError()).isEqualTo(4); + var statsFor2Day = result.getRequestStatistics().get(1); + assertThat(statsFor2Day.getSuccess()).isEqualTo(12); + assertThat(statsFor2Day.getError()).isEqualTo(6); + } + + @Test + void givenNoErrorLogsForEnv_whenLoadErrorsStatistics_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadErrorsStatistics(searchRequest); + // then + assertThat(result.getErrorBreakdowns()).isEmpty(); + } + + @Test + void givenErrorLogsForEnv_whenLoadErrorsStatisticsOutOfTheTimeRage_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(4)) + .queryEnd(now.minusDays(2)) + .build(); + // when + var result = sut.loadErrorsStatistics(searchRequest); + // then + assertThat(result.getErrorBreakdowns()).isEmpty(); + } + + @Test + void givenErrorApiActivityLogs_whenLoadErrorsStatistics_thenReturnsErrorStatisticsSortedAsc() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadErrorsStatistics(searchRequest); + // then + assertThat(result.getErrorBreakdowns()).hasSize(2); + var errorBreakdown0 = result.getErrorBreakdowns().get(0); + var errorBreakdown1 = result.getErrorBreakdowns().get(1); + assertThat(errorBreakdown0.getDate()).isBefore(errorBreakdown1.getDate()); + assertThat(errorBreakdown0.getErrors()).containsEntry(401, 4L).containsEntry(500, 4L); + assertThat(errorBreakdown1.getErrors()).containsEntry(401, 6L).containsEntry(500, 6L); + } + + @Test + void givenErrorApiActivityLogs_whenLoadErrorsStatisticsByBuyerId_thenReturnsErrorStatistics() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + addApiLogActivity(envId.toString(), createPayloads(toUTC(now))); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .buyerId(BUYER_ID_1) + .build(); + // when + var result = sut.loadErrorsStatistics(searchRequest); + // then + assertThat(result.getErrorBreakdowns()).hasSize(2); + assertThat(result.getErrorBreakdowns().get(0).getErrors()).containsEntry(401, 4L); + assertThat(result.getErrorBreakdowns().get(1).getErrors()).containsEntry(401, 6L); + } + + @Test + void givenNoLogsForEnv_whenLoadMostPopularEndpointStatistics_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadMostPopularEndpointStatistics(searchRequest); + // then + assertThat(result.getEndpointUsages()).isEmpty(); + } + + @Test + void givenNoLogsForEnv_whenLoadMostPopularEndpointStatisticsOutOfTheTimeRage_thenEmptyResult() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var payload = payloadForEndpointPopularity(toUTC(now)); + addApiLogActivity(envId.toString(), payload); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(4)) + .queryEnd(now.minusDays(2)) + .build(); + // when + var result = sut.loadMostPopularEndpointStatistics(searchRequest); + // then + assertThat(result.getEndpointUsages()).isEmpty(); + } + + @Test + void givenApiActivityLogs_whenLoadMostPopularEndpointStatistics_thenReturnsEndpointsStatistics() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var payload = payloadForEndpointPopularity(toUTC(now)); + addApiLogActivity(envId.toString(), payload); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .build(); + // when + var result = sut.loadMostPopularEndpointStatistics(searchRequest); + // then + assertThat(result.getEndpointUsages()).hasSize(7); + assertThat(result.getEndpointUsages().get(0).getMethod()).isEqualTo("GET"); + assertThat(result.getEndpointUsages().get(0).getEndpoint()) + .isEqualTo("/mefApi/sonata/product/0"); + assertThat(result.getEndpointUsages().get(0).getUsage()).isEqualTo(30); + assertThat(result.getEndpointUsages().get(0).getPopularity()).isEqualTo(30.0); + assertThat(result.getEndpointUsages().get(6).getMethod()).isEqualTo("DELETE"); + assertThat(result.getEndpointUsages().get(6).getEndpoint()) + .isEqualTo("/mefApi/sonata/product/6"); + assertThat(result.getEndpointUsages().get(6).getUsage()).isEqualTo(5); + assertThat(result.getEndpointUsages().get(6).getPopularity()).isEqualTo(5.0); + } + + @Test + void + givenApiActivityLogs_whenLoadMostPopularEndpointStatisticsByBuyerId_thenReturnsEndpointsStatistics() { + // given + var envId = UUID.randomUUID(); + var now = ZonedDateTime.parse(NOW_WITH_TIMEZONE); + var payload = payloadForEndpointPopularity(toUTC(now)); + addApiLogActivity(envId.toString(), payload); + var searchRequest = + ApiStatisticsSearchRequest.builder() + .env(envId.toString()) + .queryStart(now.minusDays(2)) + .queryEnd(now) + .buyerId(BUYER_ID_2) + .build(); + // when + var result = sut.loadMostPopularEndpointStatistics(searchRequest); + // then + assertThat(result.getEndpointUsages()).hasSize(1); + assertThat(result.getEndpointUsages().get(0).getMethod()).isEqualTo("GET"); + assertThat(result.getEndpointUsages().get(0).getEndpoint()) + .isEqualTo("/mefApi/sonata/product/9"); + assertThat(result.getEndpointUsages().get(0).getUsage()).isEqualTo(2); + assertThat(result.getEndpointUsages().get(0).getPopularity()).isEqualTo(2.0); + } + + private List payloadForEndpointPopularity(ZonedDateTime now) { + var payload = new ArrayList(); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/0") + .path1("/hub/product/0") + .httpStatus(200) + .now(now) + .number(30) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("POST") + .path0("/mefApi/sonata/product/1") + .path1("/hub/product/1") + .httpStatus(200) + .now(now) + .number(20) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("DELETE") + .path0("/mefApi/sonata/product/2") + .path1("/hub/product/2") + .httpStatus(201) + .now(now) + .number(12) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("PUT") + .path0("/mefApi/sonata/product/3") + .path1("/hub/product/3") + .httpStatus(204) + .now(now) + .number(10) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/4") + .path1("/hub/product/4") + .httpStatus(400) + .now(now) + .number(8) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("POST") + .path0("/mefApi/sonata/product/5") + .path1("/hub/product/5") + .httpStatus(401) + .now(now) + .number(6) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("DELETE") + .path0("/mefApi/sonata/product/6") + .path1("/hub/product/6") + .httpStatus(402) + .now(now) + .number(5) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("PUT") + .path0("/mefApi/sonata/product/7") + .path1("/hub/product/7") + .httpStatus(404) + .now(now) + .number(4) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/8") + .path1("/hub/product/8") + .httpStatus(500) + .now(now) + .number(3) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/9") + .path1("/hub/product/9") + .httpStatus(500) + .now(now) + .number(2) + .buyerId(BUYER_ID_2) + .build() + .createPayload()); + return payload; + } + + private void addApiLogActivity(String envId, List payloads) { + payloads.forEach( + p -> { + clientAPIAuditLogEventHandler.onEvent( + envId, UUID.randomUUID().toString(), createEvent(p)); + }); + } + + private List createPayloads(ZonedDateTime now) { + var payload = new ArrayList(); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/123") + .path1("/hub/product/123") + .httpStatus(200) + .now(now) + .number(10) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/234") + .path1("/hub/product/234") + .httpStatus(204) + .now(now) + .number(10) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/order/678") + .path1("/hub/order/678") + .httpStatus(401) + .now(now) + .number(10) + .buyerId(BUYER_ID_1) + .build() + .createPayload()); + payload.addAll( + PayloadBuilder.builder() + .method("GET") + .path0("/mefApi/sonata/product/789") + .path1("/hub/product/789") + .httpStatus(500) + .now(now) + .number(10) + .buyerId("buyer2") + .build() + .createPayload()); + return payload; + } + + private static ClientEvent createEvent(String json) { + var clientEvent = new ClientEvent(); + clientEvent.setEventType(ClientEventTypeEnum.CLIENT_API_AUDIT_LOG); + clientEvent.setClientId("127.0.1.1"); + clientEvent.setEventPayload(json); + return clientEvent; + } + + @lombok.Builder + public static class PayloadBuilder { + private String method; + private String path0; + private String path1; + private int httpStatus; + private ZonedDateTime now; + private int number; + private String buyerId; + + List createPayload() { + return IntStream.range(0, number) + .mapToObj( + operand -> { + ZonedDateTime date = now.minusHours(operand); + return createPayload( + method, path0, path1, httpStatus, date, date.plusSeconds(2), buyerId); + }) + .toList(); + } + + private String createPayload( + String method, + String path0, + String path1, + Integer httpStatus, + ZonedDateTime createdAt0, + ZonedDateTime createdAt1, + String buyerId) { + var requestId = UUID.randomUUID(); + var apiActivityLog0 = + getApiActivityLog0(method, path0, httpStatus, createdAt0, requestId, buyerId); + var apiActivityLog1 = + getApiActivityLog1(method, path1, httpStatus, createdAt1, requestId, buyerId); + return JsonToolkit.toJson(List.of(apiActivityLog0, apiActivityLog1)); + } + + private ApiActivityLog getApiActivityLog0( + String method, + String path0, + Integer httpStatus, + ZonedDateTime createdAt0, + UUID requestId, + String buyerId) { + var apiActivityLog0 = new ApiActivityLog(); + apiActivityLog0.setRequestId(requestId.toString()); + apiActivityLog0.setCallSeq(0); + apiActivityLog0.setUri("http://localhost:8888/mef.sonata"); + apiActivityLog0.setMethod(method); + apiActivityLog0.setPath(path0); + apiActivityLog0.setHttpStatusCode(httpStatus); + apiActivityLog0.setCreatedAt(createdAt0); + apiActivityLog0.setBuyer(buyerId); + return apiActivityLog0; + } + + private ApiActivityLog getApiActivityLog1( + String method, + String path1, + Integer httpStatus, + ZonedDateTime createdAt1, + UUID requestId, + String buyerId) { + var apiActivityLog1 = new ApiActivityLog(); + apiActivityLog1.setRequestId(requestId.toString()); + apiActivityLog1.setCallSeq(1); + apiActivityLog1.setUri("http://localhost:8888/mef.sonata"); + apiActivityLog1.setMethod(method); + apiActivityLog1.setPath(path1); + apiActivityLog1.setHttpStatusCode(httpStatus); + apiActivityLog1.setCreatedAt(createdAt1); + apiActivityLog1.setBuyer(buyerId); + return apiActivityLog1; + } + } +} diff --git a/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/repo/ApiActivityLogRepository.java b/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/repo/ApiActivityLogRepository.java index ad245d20..f5a79125 100644 --- a/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/repo/ApiActivityLogRepository.java +++ b/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/repo/ApiActivityLogRepository.java @@ -10,7 +10,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; public interface ApiActivityLogRepository extends PagingAndSortingRepository, @@ -25,4 +27,21 @@ public interface ApiActivityLogRepository Page findAllBySyncStatusAndCreatedAtBefore( SyncStatusEnum syncStatus, ZonedDateTime createdAt, Pageable pageable); + + @Query( + "SELECT e.path, e.method , COUNT(e) FROM #{#entityName} e " + + "WHERE e.env = :env " + + "AND e.callSeq = :callSeq " + + "AND e.createdAt BETWEEN :startDate AND :endDate " + + "AND ( (:buyer) is null or e.buyer = :buyer )" + + "GROUP BY e.path, e.method " + + "ORDER BY COUNT(e) DESC " + + "LIMIT :limit ") + List findTopEndpoints( + @Param("env") String env, + @Param("startDate") ZonedDateTime startDate, + @Param("endDate") ZonedDateTime endDate, + @Param("callSeq") String callSeq, + @Param("buyer") String buyer, + @Param("limit") int limit); } diff --git a/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/request/ApiStatisticsSearchRequest.java b/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/request/ApiStatisticsSearchRequest.java new file mode 100644 index 00000000..dc0125a9 --- /dev/null +++ b/kraken-java-sdk/kraken-java-sdk-core/src/main/java/com/consoleconnect/kraken/operator/core/request/ApiStatisticsSearchRequest.java @@ -0,0 +1,14 @@ +package com.consoleconnect.kraken.operator.core.request; + +import java.time.ZonedDateTime; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class ApiStatisticsSearchRequest { + String env; + ZonedDateTime queryStart; + ZonedDateTime queryEnd; + String buyerId; +}