All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.perfana.client.PerfanaClient Maven / Gradle / Ivy

/*
 *    Copyright 2020-2023  Peter Paul Bakker @ perfana.io, Daniel Moll @ perfana.io
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package io.perfana.client;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
import io.perfana.client.api.PerfanaCaller;
import io.perfana.client.api.PerfanaClientLogger;
import io.perfana.client.api.PerfanaConnectionSettings;
import io.perfana.client.api.PerfanaTestContext;
import io.perfana.client.domain.*;
import io.perfana.client.exception.PerfanaAssertResultsException;
import io.perfana.client.exception.PerfanaAssertionsAreFalse;
import io.perfana.client.exception.PerfanaClientException;
import io.perfana.client.exception.PerfanaClientRuntimeException;
import io.perfana.eventscheduler.exception.handler.AbortSchedulerException;
import io.perfana.eventscheduler.exception.handler.KillSwitchException;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import static java.net.HttpURLConnection.*;

public final class PerfanaClient implements PerfanaCaller {

    private static final MediaType JSON
            = MediaType.parse("application/json; charset=utf-8");
    public static final PerfanaErrorMessage PERFANA_ERROR_MESSAGE_NOT_FOUND = new PerfanaErrorMessage(Collections.singletonList(""));
    public static final PerfanaSingleMessage PERFANA_SINGLE_MESSAGE_NOT_FOUND = new PerfanaSingleMessage("");

    private final OkHttpClient client = new OkHttpClient();

    private final PerfanaClientLogger logger;

    private final PerfanaTestContext context;
    private final PerfanaConnectionSettings settings;
    
    private final boolean assertResultsEnabled;

    private static final ObjectReader perfanaBenchmarkReader;

    private static final ObjectReader errorMessageReader;
    private static final ObjectReader singleMessageReader;
    private static final ObjectReader perfanaTestReader;
    private static final ObjectWriter perfanaMessageWriter;
    private static final ObjectWriter perfanaEventWriter;

    private static final ObjectWriter testRunConfigKeyValueWriter;

    private static final ObjectWriter testRunConfigJsonWriter;

    private static final ObjectWriter testRunConfigKeysWriter;

    private static final ObjectWriter initWriter;
    private static final ObjectReader initReplyReader;

    static {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        perfanaBenchmarkReader = objectMapper.reader().forType(Benchmark.class);
        errorMessageReader = objectMapper.reader().forType(PerfanaErrorMessage.class);
        singleMessageReader = objectMapper.reader().forType(PerfanaSingleMessage.class);
        perfanaTestReader = objectMapper.reader().forType(PerfanaTest.class);
        perfanaMessageWriter = objectMapper.writer().forType(PerfanaMessage.class);
        perfanaEventWriter = objectMapper.writer().forType(PerfanaEvent.class);
        testRunConfigKeyValueWriter = objectMapper.writer().forType(TestRunConfigKeyValue.class);
        testRunConfigJsonWriter = objectMapper.writer().forType(TestRunConfigJson.class);
        testRunConfigKeysWriter = objectMapper.writer().forType(TestRunConfigKeys.class);
        initWriter = objectMapper.writer().forType(Init.class);
        initReplyReader = objectMapper.reader().forType(InitReply.class);
    }

    PerfanaClient(PerfanaTestContext context, PerfanaConnectionSettings settings,
                  boolean assertResultsEnabled, PerfanaClientLogger logger) {
        this.context = context;
        this.settings = settings;
        this.assertResultsEnabled = assertResultsEnabled;
        this.logger = logger;
    }

    public void callPerfanaTestEndpoint(PerfanaTestContext context, boolean completed) throws KillSwitchException {
        callPerfanaTestEndpoint(context, completed, Collections.emptyMap());
    }

    @Override
    public void callPerfanaTestEndpoint(PerfanaTestContext context, boolean completed, Map extraVariables) throws KillSwitchException {
        final String json = perfanaMessageToJson(context, completed, extraVariables);
        final Request request = createRequest("/api/test", json);

        try (Response response = client.newCall(request).execute()) {

            logger.debug("test endpoint result: " + response);

            final int code = response.code();
            final String body = extractBodyAsString(response.body());

            if (code == HTTP_UNAUTHORIZED) { // 401
                String dueTo = extractDueTo(response.header("WWW-Authenticate"));
                throw new AbortSchedulerException(String.format("Abort: not authorized (%d) for [%s]%s", code, request, dueTo));
            } else if (code == HTTP_UNAVAILABLE || code == HTTP_BAD_GATEWAY) {
                logger.warn(String.format("Perfana replied with service unavailable (%d) for [%s]. Will retry.", code, request));
            } else if (code == HTTP_BAD_REQUEST || code == HTTP_INTERNAL_ERROR) { // 400 || 500
                PerfanaErrorMessage message = extractPerfanaErrorMessage(body);
                if (body != null) {
                    throw new AbortSchedulerException("Abort due to: " + message.getMessage());
                } else {
                    logger.error(String.format("No response body in test endpoint result: %s", response));
                    throw new AbortSchedulerException(String.format("Abort due to Perfana error reply (%d) for [%s]", code, request));
                }
            } else {
                if (body != null) {
                    // only do the abort check for the keep alive calls, completed is final call
                    if (!completed) {
                        PerfanaTest test = perfanaTestReader.readValue(body);
                        if (test.isAbort()) {
                            String message = test.getAbortMessage();
                            logger.info(String.format("abort requested by Perfana! Reason: '%s'", message));
                            throw new KillSwitchException(message);
                        }
                    }
                } else {
                    logger.error(String.format("No response body in test endpoint result: %s", response));
                }
            }
        } catch (IOException e) {
            logger.error(String.format("Failed to call Perfana test endpoint: %s", e.getMessage()));
        }
    }


    @NotNull
    private Request createRequest(String endPoint) {
        return createRequest(endPoint, null);
    }

    private Request createRequest(@NotNull String endpoint, String json) {
        logger.debug("call to endpoint: " + endpoint + (json != null ? " with json: " + json : ""));

        String url = PerfanaUtils.addSlashIfNeeded(settings.getPerfanaUrl(), endpoint);

        Request.Builder requestBuilder = new Request.Builder()
            .url(url);

        if (json == null) {
            requestBuilder.get();
        }
        else {
            RequestBody body = RequestBody.create(json, JSON);
            requestBuilder.post(body);
        }

        if (settings.getApiKey() != null) {
            requestBuilder.addHeader("Authorization", "Bearer " + settings.getApiKey());
        }

        return requestBuilder.build();
    }

    @Override
    public void callPerfanaEvent(PerfanaTestContext context, String eventTitle, String eventDescription) {
        logger.info("add Perfana event: " + eventDescription);
        String json = perfanaEventToJson(context, eventTitle, eventDescription);
        try {
            String result = post("/api/events", json);
            logger.debug("result: " + result);
        } catch (IOException e) {
            logger.error("failed to call Perfana event endpoint: " + e.getMessage());
        }
    }

    /**
     * @return null when response is not successful
     */
    private String post(String endpoint, String json) throws IOException {
        Request request = createRequest(endpoint, json);
        try (Response response = client.newCall(request).execute()) {
            String responseBody = response.body() == null ? "" : response.body().string();
            final int responseCode = response.code();
            if (responseCode == HTTP_UNAUTHORIZED) {
                logger.warn("ignoring: not authorised (401) to post to [" + endpoint + "]");
                return null;
            }
            if (!response.isSuccessful()) {
                logger.warn("POST was not successful. Response: " + response + " Body: '" + responseBody + "' Request: " + json);
                return null;
            }
            return responseBody;
        }
    }

    public static String perfanaMessageToJson(PerfanaTestContext context, boolean completed, Map extraVariables) {

        PerfanaMessage.PerfanaMessageBuilder perfanaMessageBuilder = PerfanaMessage.builder()
            .testRunId(context.getTestRunId())
            .workload(context.getWorkload())
            .testEnvironment(context.getTestEnvironment())
            .systemUnderTest(context.getSystemUnderTest())
            .version(context.getVersion())
            .cibuildResultsUrl(context.getCIBuildResultsUrl())
            .rampUp(String.valueOf(context.getRampupTime().getSeconds()))
            .duration(String.valueOf(context.getPlannedDuration().getSeconds()))
            .completed(completed)
            .annotations(context.getAnnotations())
            .tags(context.getTags());

        context.getVariables().forEach((k,v) -> perfanaMessageBuilder
            .variable(Variable.builder().placeholder(k).value(v).build()));

        extraVariables.forEach((k, v) -> perfanaMessageBuilder
            .variable(Variable.builder().placeholder(k).value(v).build()));

        PerfanaMessage perfanaMessage = perfanaMessageBuilder.build();

        try {
            return perfanaMessageWriter.writeValueAsString(perfanaMessage);
        } catch (JsonProcessingException e) {
            throw new PerfanaClientRuntimeException("Failed to write PerfanaMessage to json: " + perfanaMessage, e);
        }
    }

    private String perfanaEventToJson(PerfanaTestContext context, String eventTitle, String eventDescription) {

        PerfanaEvent event = PerfanaEvent.builder()
            .systemUnderTest(context.getSystemUnderTest())
            .testEnvironment(context.getTestEnvironment())
            .title(eventTitle)
            .description(eventDescription)
            .tag(context.getWorkload())
            .build();

        try {
            return perfanaEventWriter.writeValueAsString(event);
        } catch (JsonProcessingException e) {
            throw new PerfanaClientRuntimeException("Unable to transform PerfanaEvent to json", e);
        }
    }

    /**
     * Call asserts for this test run. Will retry if needed, e.g. 502 or 503.
     *
     *            example:
     *            
https://perfana-url/api/benchmark-results/DASHBOARD/NIGHTLY/TEST-RUN-831
* * response example: *
     *            {
     *              "requirements":{"result":true,"deeplink":"https://perfana:4000/requirements/123"},
     *              "benchmarkPreviousTestRun":{"result":true,"deeplink":"https://perfana:4000/benchmarkPrevious/123"},
     *              "benchmarkBaselineTestRun":{"result":true,"deeplink":"https://perfana:4000/benchmarkBaseline/123"}
     *            }
     *            
* * response example for 500 or other errors: *
{ "message": "Something went wrong" }
* * @return string such as "All configured checks are OK: * https://perfana:4000/requirements/123 * https://perfana:4000/benchmarkBaseline/123 * https://perfana:4000/benchmarkPrevious/123" * @throws PerfanaClientException when call fails unexpectedly (e.g. bug) * @throws PerfanaAssertResultsException when call fails in more-or-less expect way (e.g. status code 400) */ private String callCheckAsserts() throws PerfanaClientException, PerfanaAssertResultsException { String endPoint; try { endPoint = String.join("/", "/api", "benchmark-results", encodeForURL(context.getSystemUnderTest()), encodeForURL(context.getTestRunId())); } catch (UnsupportedEncodingException e) { throw new PerfanaClientException("cannot encode Perfana url.", e); } Request request = createRequest(endPoint); final int maxRetryCount = settings.getRetryMaxCount(); final long sleepDurationMillis = settings.getRetryDuration().toMillis(); int retryCount = 0; String assertions = null; boolean keepRetrying = true; boolean assertionsAvailable = false; boolean checksSpecified = false; while (keepRetrying && (retryCount++ < maxRetryCount)) { try (Response response = client.newCall(request).execute()) { // for response codes that do not throw PerfanaAssertResultsException: retries are done final int code = response.code(); final String body = extractBodyAsString(response.body()); if (body != null && body.contains("")) { throw new PerfanaAssertResultsException(String.format("Got html instead of json response for [%s]: [%s]", endPoint, body)); } logger.debug(String.format("Received response for [%s] with code [%d] and body [%s]", request, code, body)); if (code == HTTP_OK) { assertions = body; assertionsAvailable = true; checksSpecified = true; keepRetrying = false; } else if (code == HTTP_ACCEPTED) { // 202 PerfanaSingleMessage message = extractPerfanaSingleMessage(body); // evaluation in progress logger.info(String.format("Trying to get test run check results at %s, attempt (%d/%d). %s", endPoint, retryCount, maxRetryCount, message.getMessage())); } else { if (code == HTTP_NO_CONTENT) { // 204 // no checks specified assertionsAvailable = true; // valid response, interpret as "empty assertion list" keepRetrying = false; logger.info(String.format("No check can be done for [%s], due to: %s", context.getTestRunId(), "no checks specified for this test run in Perfana")); } else if (code == HTTP_UNAVAILABLE || code == HTTP_BAD_GATEWAY) { // 503 and 502 // no results available (yet), can be retried logger.warn(String.format("Perfana is currently unavailable (%s) for [%s]. Will retry (%d/%d)...", code, context.getTestRunId(), retryCount, maxRetryCount)); } else if (code == HTTP_BAD_REQUEST) { // 400 String dueTo = extractDueTo(body); throw new PerfanaAssertResultsException(String.format("Bad request from client (%d) to results for [%s].%s", code, context.getTestRunId(), dueTo)); } else if (code == HTTP_INTERNAL_ERROR) { // 500 PerfanaErrorMessage perfanaErrorMessage = extractPerfanaErrorMessage(body); throw new PerfanaAssertResultsException(String.format("Test run [%s] has been marked as invalid, due to: %s", context.getTestRunId(), perfanaErrorMessage.getMessage())); } else if (code == HTTP_NOT_FOUND) { // 404 PerfanaErrorMessage perfanaErrorMessage = extractPerfanaErrorMessage(body); throw new PerfanaAssertResultsException(String.format("Test run [%s] not found, due to: %s", context.getTestRunId(), perfanaErrorMessage.getMessage())); } else if (code == HTTP_UNAUTHORIZED) { // 401 String dueTo = extractDueTo(response.header("WWW-Authenticate")); throw new PerfanaAssertResultsException(String.format("Not authorized (%d) for [%s]. Check the Perfana API key.%s", code, endPoint, dueTo)); } else { PerfanaErrorMessage perfanaErrorMessage = extractPerfanaErrorMessage(body); throw new PerfanaAssertResultsException(String.format("No action defined for dealing with http code (%d) for [%s]. Message: %s", code, endPoint, perfanaErrorMessage)); } } } catch (IOException e) { logger.warn(String.format("IO Exception while trying to get test run check results at [%s], will retry (%d/%d)...[%s][%s]", endPoint, retryCount, maxRetryCount, e.getClass().getName(), e.getMessage())); } if (!assertionsAvailable) { sleep(sleepDurationMillis); } } if (!assertionsAvailable) { String message = "Failed to get test run check results at [" + endPoint + "], maximum attempts reached!"; logger.warn(message); throw new PerfanaAssertResultsException(message); } return checksSpecified ? assertions : null; } @Nullable private String extractBodyAsString(ResponseBody responseBody) throws IOException { return responseBody == null ? null : responseBody.string(); } @NotNull private String extractDueTo(String bodyAsString) { String dueTo; if (bodyAsString != null && !bodyAsString.isEmpty()) { dueTo = " Due to : " + bodyAsString; } else { dueTo = ""; } return dueTo; } private PerfanaErrorMessage extractPerfanaErrorMessage(String messageBody) throws IOException { if (messageBody == null || messageBody.isEmpty()) { return PERFANA_ERROR_MESSAGE_NOT_FOUND; } PerfanaErrorMessage perfanaErrorMessage; try { perfanaErrorMessage = errorMessageReader.readValue(messageBody); } catch (JsonProcessingException e) { logger.warn(String.format("Failed to process Perfana error message: [%s] due to: %s", messageBody, e)); return PERFANA_ERROR_MESSAGE_NOT_FOUND; } return perfanaErrorMessage; } private PerfanaSingleMessage extractPerfanaSingleMessage(String messageBody) throws IOException { if (messageBody == null || messageBody.isEmpty()) { return PerfanaSingleMessage.builder().message("[No message]").build(); } PerfanaSingleMessage perfanaSingleMessage; try { perfanaSingleMessage = singleMessageReader.readValue(messageBody); } catch (JsonProcessingException e) { logger.warn(String.format("Failed to process Perfana error message: [%s] due to: %s", messageBody, e)); return PERFANA_SINGLE_MESSAGE_NOT_FOUND; } return perfanaSingleMessage; } private void sleep(long sleepDurationMillis) { try { Thread.sleep(sleepDurationMillis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private String encodeForURL(String testRunId) throws UnsupportedEncodingException { return URLEncoder.encode(testRunId, StandardCharsets.UTF_8).replace("\\", "%20"); } public String assertResults() throws PerfanaClientException, PerfanaAssertResultsException, PerfanaAssertionsAreFalse { if (!assertResultsEnabled) { String message = "Perfana assert results is not enabled: results will not be checked."; logger.info(message); return message; } final String assertions = callCheckAsserts(); if (assertions == null) { // No checks have specified return "No checks have been specified for this test run. Set assertResults property to false or create checks for key metrics."; } Benchmark benchmark; try { benchmark = perfanaBenchmarkReader.readValue(assertions); } catch (IOException e) { throw new PerfanaClientRuntimeException("Unable to parse benchmark message: " + assertions, e); } Optional baseline = Optional.ofNullable(benchmark.getBenchmarkBaselineTestRun()); Optional previous = Optional.ofNullable(benchmark.getBenchmarkPreviousTestRun()); Optional requirements = Optional.ofNullable(benchmark.getRequirements()); requirements.ifPresent(r -> logger.info("Requirements: " + r.isResult())); baseline.ifPresent(r -> logger.info("Compared to baseline test run: " + r.isResult())); previous.ifPresent(r -> logger.info("Compared to previous test run: " + r.isResult())); StringBuilder text = new StringBuilder(); if (assertions.contains("false")) { text.append("One or more Perfana assertions are failing: \n"); requirements.filter(r -> !r.isResult()).ifPresent(r -> text.append("Requirements check failed: ").append(r.getDeeplink()).append("\n")); baseline.filter(r -> !r.isResult()).ifPresent(r -> text.append("Comparison check to baseline test run failed: ").append(r.getDeeplink()).append("\n")); previous.filter(r -> !r.isResult()).ifPresent(r -> text.append("Comparison check to previous test run failed: ").append(r.getDeeplink()).append("\n")); logger.info("Test run has failed checks: " + text); throw new PerfanaAssertionsAreFalse(text.toString()); } else { text.append("All configured checks are OK: \n"); requirements.ifPresent(r -> text.append(r.getDeeplink()).append("\n")); baseline.ifPresent(r -> text.append(r.getDeeplink()).append("\n")); previous.ifPresent(r -> text.append(r.getDeeplink())); } return text.toString(); } @Override public String toString() { return "PerfanaClient [testRunId:" + context.getTestRunId() + " workload: " + context.getWorkload() + " testEnvironment: " + context.getTestEnvironment() + " Perfana url: " + settings.getPerfanaUrl() + "]"; } public void addTestRunConfigKeyValue(TestRunConfigKeyValue testRunConfigKeyValue) { logger.info("add Perfana test-run-config with key-value: " + testRunConfigKeyValue); try { String json = testRunConfigKeyValueWriter.writeValueAsString(testRunConfigKeyValue); String result = post("/api/config/key", json); // result expected to be ""? logger.debug("result: " + result); } catch (JsonProcessingException e) { logger.error("failed to serialize " + testRunConfigKeyValue + " to json", e); } catch (IOException e) { logger.error("failed to call Perfana event endpoint: " + e.getMessage()); } } public void addTestRunConfigJson(TestRunConfigJson testRunConfigJson) { logger.info("add Perfana test-run-config with json with " + testRunConfigJson.getJson().length() + " characters."); logger.debug("add Perfana test-run-config with json: " + testRunConfigJson); try { String json = testRunConfigJsonWriter.writeValueAsString(testRunConfigJson); String result = post("/api/config/json", json); // result expected to be ""? logger.debug("result: " + result); } catch (JsonProcessingException e) { logger.error("failed to serialize " + testRunConfigJson + " to json", e); } catch (IOException e) { logger.error("failed to call Perfana event endpoint: " + e.getMessage()); } } public void addTestRunConfigKeys(TestRunConfigKeys testRunConfigKeys) { logger.info("add Perfana test-run-config with " + testRunConfigKeys.getConfigItems().size() + " keys"); logger.debug("add Perfana test-run-config with keys: " + testRunConfigKeys); try { String keys = testRunConfigKeysWriter.writeValueAsString(testRunConfigKeys); String result = post("/api/config/keys", keys); // result expected to be ""? logger.debug("result: " + result); } catch (JsonProcessingException e) { logger.error("failed to serialize " + testRunConfigKeys + " to json", e); } catch (IOException e) { logger.error("failed to call Perfana event endpoint: " + e.getMessage()); } } /** * @return the testRunId or null if the call failed. */ public String callInitTest(PerfanaTestContext context) { Init init = Init.builder() .systemUnderTest(context.getSystemUnderTest()) .testEnvironment(context.getTestEnvironment()) .workload(context.getWorkload()).build(); logger.info("call Perfana init-test with: " + init); try { String json = initWriter.writeValueAsString(init); String initReplyJson = post("/api/init", json); logger.info("got init reply: " + initReplyJson); if (initReplyJson == null) { logger.error("Perfana init call failed."); return null; } try { InitReply initReply = initReplyReader.readValue(initReplyJson); return initReply.getTestRunId(); } catch (JsonProcessingException e) { logger.error("failed to serialize " + initReplyJson + " to json: " + e.getMessage()); } } catch (IOException e) { logger.error("failed to call Perfana init-test endpoint: " + e.getMessage()); } return null; } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy