Monitoring and loading Jmix applications

Application monitoring tools can be useful not only for DevOps, but also for developers to study the application performance in search, for example, bottlenecks in its operation, so in this article we will not only set up monitoring for the Jmix application, but also prepare for its synthetic load testing. A feature of the Jmix platform, due to the fact that it uses the Vaadin framework, is the fact that the UI is integrated with the backend, but this also means that metrics can be used transparently, i.e. measure the work of the interface layer with them.

Create a project and enable metrics

To develop, you will need the IntelliJ IDEA development environment with the Jmix plugin installed.

Let's take jmix-onboarding as an example. This is a more complete Hello World, i.e. demonstration project.

To get it on your computer in the development environment, select File -> New -> Project from Version Control and in the dialog that appears, indicate the repository address: https://github.com/jmix-framework/jmix-onboarding-2.git

However, you can create a completely new project, it will be immediately ready to launch and work.

To connect Actuator, add its dependencies to build.gradle:

    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus'

By default, metrics are disabled. To include them in application.properties add the following lines:

management.endpoints.web.exposure.include=prometheus,health,info,metrics
management.endpoint.health.show-details=always

Setting up authorization

Spring Security is enabled in Jmix applications, which means that the metrics need to be accessed in the project code, let's add a configuration bean.

@Configuration
public class ActuatorSecurityConfiguration {
    @Bean
    @Order(JmixSecurityFilterChainOrder.FLOWUI - 10)
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/actuator/**")
                .authorizeHttpRequests((authorize) -> authorize.requestMatchers("/actuator/**").permitAll());
        return http.build();
    }
}

Let's create a docker subdirectory and a docker-compose.yml file in it for the project with PostgreSQL:

services:
  jmix-onboarding-postgres:
    container_name: jmix-onboarding-postgres
    image: postgres:latest
    ports:
      - "5432:5432"
    volumes:
      - postgres:/var/lib/postgresql/data
      - storage:/storage
    environment:
      - POSTGRES_USER=onboarding
      - POSTGRES_PASSWORD=onboarding
      - POSTGRES_DB=onboarding
  Jmix-onboarding:
    container_name: jmix-onboarding
    image: jmix-onboarding:0.0.1-SNAPSHOT
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=jmix-onboarding-postgres
      - DB_USER=onboarding
      - DB_PASSWORD=onboarding
      - DB_PORT=5432
      - DB_NAME=onboarding
    depends_on:
      - jmix-onboarding-postgres
volumes:
  postgres: {}
  storage: {}

Let's create a profile for the product in the application-prod.properties file with datasource for PostgreSQL from Docker:

main.datasource.url = jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
main.datasource.username = ${DB_USER}
main.datasource.password =${DB_PASSWORD}
jmix.localfs.storage-dir = /storage

Datasource for development will be pulled out from application.properties to application-dev.properties

main.datasource.url = jdbc:hsqldb:file:.jmix/hsqldb/onboarding
main.datasource.username = sa
main.datasource.password =

To support PostgreSQL, you also need to add the DBMS driver to the dependencies section of the build.gradle file.

    runtimeOnly 'org.postgresql:postgresql'

In order for profiles to be enabled automatically, we will add the transfer of profile parameters to build.gradle:

bootRun {
    args = ["--spring.profiles.active=dev"]
}

tasks.named("bootBuildImage") {
    environment["BPE_APPEND_JAVA_TOOL_OPTIONS"] = " -Dspring.profiles.active=prod"
}

Build a Docker image:

./gradlew bootBuildImage -Pvaadin.productionMode=true

Launch containers:

 docker compose -f ./docker/docker-compose.yml up -d

We check that the application provides data for monitoring:

curl http://localhost:8080/actuator/prometheus

Let's start monitoring

Create a docker/config/grafana/provisioning/datasources/all.yaml configuration with Prometheus datasource:

apiVersion: 1
 
datasources:
  - name: Prometheus
    label: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

Create a Docker file for Graphana docker/config/grafana/Dockerfile

FROM grafana/grafana
ADD ./provisioning /etc/grafana/provisioning

Create docker/config/prometheus.yml

scrape_configs:
  - job_name: 'onboarding_monitoring'
    scrape_interval: 5s
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['jmix-onboarding:8080']

Create docker/monitoring.yml

services:
  grafana:
    build: './config/grafana'
    user: root
    ports:
      - 3000:3000
    volumes:
      - ./grafana:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
  prometheus:
    image: prom/prometheus
    user: root
    ports:
      - 9090:9090
    volumes:
      - ./config/prometheus.yml:/etc/prometheus/prometheus.yml
      - ./prometheus:/prometheus

Let's start monitoring:

docker compose -f ./docker/monitoring.yml start

To check, you can go to the Prometheus web interface by opening it in your browser http://localhost:9090 and make sure that the status of the metrics collector's work there is positive.

Setting up panels and charts

There is a ready-made JVM dashboard for Grafana (https://grafana.com/grafana/dashboards/4701-jvm-micrometer/.)which looks nice right away. But we'll go a little further and make dashboards with only data specific to the operation of the Jmix framework subsystems.

Jmix already has built-in metrics collection for the main structural units – views and data loaders, and when you add your screens and write logic in them for working with data, you automatically get the ability to track their performance separately.

For views, the metric name is jmix.ui.views

The following lifeCycle, view tags are supported. The following life cycle phases are monitored:

  • Create

  • Load

  • Init

  • Before show

  • Ready

  • Inject

  • Before close

  • After close

The result for Prometheus will look something like this:

jmix_ui_views_seconds_max{lifeCycle="load",view="User.list",} 0.0412709

For data loaders, the metric name is jmix.ui.data

The following lifecycle phases are monitored: lifeCycle, view, dataLoader tags are supported:

Metrics recording only works for data loaders that have an Id defined.

Example of dataloader metrics for Prometheus:

jmix_ui_data_seconds_max{dataLoader="usersDl",lifeCycle="load",view="User.list",} 0.005668899

Let's go to http://localhost:3000 add a dashboard and metrics from Prometheus data source

jmix_ui_data_seconds_max{dataLoader="usersDl"} -> dashboards

You can also do this through Explore by selecting the appropriate datasource and entering the metric name and its parameters, you will see the selection elements. By clicking Run query, a preview of the graph should be built. But this will only happen if there is already some data, i.e. you need to log in to the application, and at least go to user management.

Add your own metrics

Using the Micrometer API, you can add your own metrics: timers, counters. Let's add this to the listener for entity events.

And add a save counter for the User entity using the following code:

package com.company.onboarding.listener;

import com.company.onboarding.entity.User;
import io.jmix.core.event.EntitySavingEvent;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class UserEventListener {

    @Autowired
    MeterRegistry meterRegistry;

    @EventListener
    public void onUserLoading(final EntitySavingEvent<User> event) {
        meterRegistry.counter("onboarding_on_user_save").increment();
    }
}

After that, if we rebuild the image, restart it in the container and save users in the management interface, we will see the appearance of a new metric and filling with data displayed on the graphs.

Enable REST mapping for the project

There is an add-on for Jmix that allows you to automatically display entities, interfaces to them and other service methods in web services, with automatic documentation in OpenAPI. This approach allows you to use the framework as a basis for developing services with minimal costs for generating a data management interface (backoffice) and fairly advanced customization capabilities for both the interface part and the business logic associated with them.

To enable the addon, add module dependencies to build.properties:

implementation 'io.jmix.authserver:jmix-authserver-starter' 
implementation 'io.jmix.rest:jmix-rest-starter'

Let's add a minimal security configuration for the service in the application.properties file:

# The client id is my-client 
spring.security.oauth2.authorizationserver.client.myclient.registration.client-id=my-client
# The client secret (password) is my-secret 
spring.security.oauth2.authorizationserver.client.myclient.registration.client-secret={noop}my-secret 
# Enable Client Credential grant for the my-client 
spring.security.oauth2.authorizationserver.client.myclient.registration.authorization-grant-types=client_credentials 
# Client credentials must be passed in the Authorization header using the HTTP Basic authentication scheme 
spring.security.oauth2.authorizationserver.client.myclient.registration.client-authentication_methods=client_secret_basic 
# Use opaque tokens instead of JWT 
spring.security.oauth2.authorizationserver.client.myclient.token.access-token-format=reference 
# access token time-to-live 
spring.security.oauth2.authorizationserver.client.myclient.token.access-token-time-to-live=24h
jmix.authserver.client.myclient.client-id = my-client 
jmix.authserver.client.myclient.resource-roles = user-management, rest-minimal

You also need to create an access role class in the com.company.onboarding.security package:

@ResourceRole(name = "User management", code = UserManagementRole.CODE, scope = "API")
public interface UserManagementRole {

    String CODE = "user-management";

    @EntityAttributePolicy(entityClass = User.class, attributes = "*", action = EntityAttributePolicyAction.MODIFY)
    @EntityPolicy(entityClass = User.class, actions = EntityPolicyAction.ALL)
    void user();
}

We restart the project and check that the endpoints have worked by first executing a request to obtain an access token:

curl -X POST http://localhost:8080/oauth2/token --basic --user my-client:my-secret -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=client_credentials"

And we’ll get a list of users by substituting the received token into a new request:

curl -X GET http://localhost:8080/rest/entities/User -H "Authorization: Bearer <access_token>"

Installing JMeter

JMeter is a load testing tool that allowed no/low-code testing before it became mainstream.

To run JMeter you will need to install JDK 11 and set it to be used at startup.
To do this, I needed to make a small launch shell script in /usr/local/bin with the following content:

#!/bin/bash
export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/
/opt/apache-jmeter-5.6.3/bin/jmeter

Running tests

Let's add variables

Let's create a Thread Group with a name, for example, REST Users

Let's uncheck Infinite and define 20 repetitions for 10 parallel users

Authentication

Since our service is closed for authorization, JMeter thread groups will need to go through this procedure at least once per session. To implement this, the If Controller element from the Logic Controller group. Let's call it Authenticate.

Let's add an HTTP sampler to it for a POST request to the address /oauth2/token

The only parameters you need to pass are grant_type=client_credentials

We will format the connection parameters from the variables that were previously specified at the Thread Group level, the substitution of which is formatted as ${__V(variable_name)}

We will also need to pass the login and password values ​​in the Authorization request header. To do this, add the HTTP Header Manager element to Authenticate and set the Authorization Basic header value to bXktY2xpZW50Om15LXNlY3JldA==.

This value is obtained from the login and password strings, concatenated with the “:” symbol and encoded in Base64. I used an online converter service to get a string for my values. And the built-in IntelliJ IDEA HTTP client to debug the request parameters.

To execute the condition only on the first request, we set the corresponding condition in the If Controller parameters:

${__groovy(ctx.getThreadNum() == 0 &&  vars.getIteration() == 1,)}

We will also add a JSON Extractor from Post Processors to it, which receives a token using the expression

$.access_token

To the access_token variable

And the BeanShell Assertion value saver with the expression:

${__setProperty(access_token, ${access_token})};

Query for a list of users

We will add the HTTP sampler Users List to the group to get users from the resource rest/entities/User

You also need to add HTTP Header Manager

And BeanShell PreProcessor to set the header with the token in the following code:

import org.apache.jmeter.protocol.http.control.Header;

var authHeader = sampler.getHeaderManager().getFirstHeaderNamed("Authorization");
if (authHeader == null) {
    sampler.getHeaderManager().add(new Header("Authorization", "Bearer" + props.get("access_token")));
} else {
    authHeader.setValue("Bearer" + props.get("access_token"));
}

Add HTTP Cookie Manager to the parent group from the Add -> Config Element submenu.

We will also add a Summary Report from the Add -> Listener submenu.

Running tests

Now you can start testing.

After launch, it's time to look at the monitoring.

By stopping the execution of queries using the “Stop” button, data on the executed queries will appear in the Summary Report section.

User Interface Testing

Developing UI tests

JMeter can also perform tests using WebDriver, which uses real browsers and performs testing by scripting or recording user actions in them.

At the time of writing, ChromeDriver required a fairly outdated version 114, which seemed rather non-trivial to combine on the developer's computer with the current one, so an alternative option using GeckoDriver and the Firefox ESR distribution worked.

The tests are based on the same logic: authentication and execution. But the key units are the WebDriver Sampler components, in which the code that scripts user actions is written.

For example, a login script for Jmix 2.x might have code like this:

var props = WDS.vars

WDS.sampleResult.sampleStart()
WDS.browser.get(props.get('jmix_proto') + '://' + props.get('jmix_host') + ':' + props.get('jmix_port'))

var pkg = JavaImporter(org.openqa.selenium, org.openqa.selenium.support.ui)
var wait = new pkg.WebDriverWait(WDS.browser, java.time.Duration.ofSeconds(100))

// wait for login screen
wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('input[name=username]')))

var login = WDS.browser.findElement(pkg.By.cssSelector('input[name=username]'))
var password = WDS.browser.findElement(pkg.By.cssSelector('input[name=password]'))
var submit = WDS.browser.findElement(pkg.By.cssSelector('vaadin-button[slot=submit]'))

login.clear()
login.sendKeys(['admin'])
password.clear()
password.sendKeys(['admin'])
submit.click()

// wait main app screen

wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('vaadin-app-layout[id="MainView"]')))

var appMenu = WDS.browser.findElement(pkg.By.cssSelector('a[href=users]'))
appMenu.click()

wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('[id="usersDataGrid"]')))

WDS.sampleResult.sampleEnd()

The script for the main actions in the control panel will look like this:

var props = WDS.vars
var pkg = JavaImporter(org.openqa.selenium, org.openqa.selenium.support.ui)
var wait = new pkg.WebDriverWait(WDS.browser, java.time.Duration.ofSeconds(120))
var tu = JavaImporter(java.util.concurrent.TimeUnit)

WDS.sampleResult.sampleStart()
WDS.browser.manage().window().setPosition(new pkg.Point(0, 0))
WDS.browser.manage().window().setSize(new pkg.Dimension(1280, 1024))
WDS.browser.get(props.get('jmix_proto') + '://' + props.get('jmix_host') + ':' + props.get('jmix_port'))
wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('vaadin-app-layout[id="MainView"]')))
var usersMenu = WDS.browser.findElement(pkg.By.cssSelector('a[href="https://habr.com/ru/companies/haulmont/articles/825402/users"]'))

usersMenu.click()

wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('[id="usersDataGrid"]')))

var tableRow  = WDS.browser.findElement(pkg.By.cssSelector('[id="usersDataGrid"]')).getShadowRoot().findElements(pkg.By.cssSelector('table tbody tr td')).get(0)
tableRow.click()

wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('[id="editBtn"]:not(.v-disabled)')))

var editButton = WDS.browser.findElement(pkg.By.cssSelector('[id="editBtn"]'))
editButton.click()

wait.until(pkg.ExpectedConditions.presenceOfElementLocated(pkg.By.cssSelector('[id="closeBtn"]')))

var closeButton = WDS.browser.findElement(pkg.By.cssSelector('[id="closeBtn"]'))
closeButton.click()

WDS.sampleResult.sampleEnd()
WDS.sampleResult.setSuccessful(true)

It’s also important not to forget that the jmix.host variable should point to the external address of the host machine or to the Docker host 172.17.0.1. In this case, the application must be run in a container. This is done simply, in the directory with the project we execute:

./gradlew -Pvaadin.productionMode=true bootBuildImage

And then we run it interactively:

docker run -it docker.io/library/jmix-onboarding:0.0.1-SNAPSHOT

where jmix-onboarding:0.0.1-SNAPSHOT is the image name that the build command will output to you.

If you are having trouble working with DockerHub, instructions for running a simple Docker image registry can be found in our previous article “Setting up a pipeline for building Java projects in GitLab” (https://habr.com/ru/companies/haulmont/articles/810151/).

For real cases, it will be necessary to develop testing scripts so that they cover all the main functionality of the application.

Due to the fact that the Jmix Flow UI interface subsystem produces standard HTML code, you will not have problems with selecting elements with selectors. However, it should be taken into account that the “insides” of many components are hidden in the shadow tree (Shadow DOM), which the search for an element enters only by calling the getShadowRoot() method, as in this example:

var tableRow  = WDS.browser.findElement(pkg.By.cssSelector('[id="usersDataGrid"]')).getShadowRoot().findElements(pkg.By.cssSelector('table tbody tr td')).get(0)

Also, when developing tests, it should be taken into account that between the imitation of the user's action and the result, there may be a time delay caused by the processing of his requests and the display of the result, therefore, in order for the tests not to fail in vain, it is necessary to place a call to wait for results everywhere in such moments using wail.until (WebDriverWait) and similar methods.

In some cases, you can also try to generate testing macros by recording with browser extensions such as Selenium IDE.

Automation

The complexity of setting up an environment for testing, as well as the need to run tests automatically, leads us to the idea of ​​running them in Docker containers. At a minimum, this will help us avoid downgrading the main browser to versions compatible with the test equipment. To run tests, we need a Docker file, which we will place in a separate project directory, for example ffjmeter.

FROM ubuntu:22.04

ARG JMETER_VERSION="5.6.3"
ENV JMETER_HOME /opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN ${JMETER_HOME}/bin
ENV JMETER_PLUGINS_MANAGER_VERSION 1.3
ENV CMDRUNNER_VERSION 2.2
ARG GECKODRIVER_VERSION=0.34.0

ENV JMETER_DOWNLOAD_URL https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz

RUN apt-get -qq update
RUN apt-get -qq -y install software-properties-common
RUN add-apt-repository ppa:mozillateam/ppa
RUN apt install wget gnupg unzip curl openjdk-11-jdk-headless firefox-esr -y
RUN mkdir -p /tmp/dependencies && \
  curl -L --silent ${JMETER_DOWNLOAD_URL} > /tmp/dependencies/apache-jmeter-${JMETER_VERSION}.tgz && \
  mkdir -p /opt && \
  tar -xzf /tmp/dependencies/apache-jmeter-${JMETER_VERSION}.tgz -C /opt && \
  rm -rf /tmp/dependencies

ENV PATH $PATH:$JMETER_BIN

WORKDIR ${JMETER_HOME}/bin

RUN curl --location --silent --show-error --output ${JMETER_HOME}/lib/ext/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar \
    http://search.maven.org/remotecontent?filepath=kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar \
 && curl --location --silent --show-error --output ${JMETER_HOME}/lib/cmdrunner-${CMDRUNNER_VERSION}.jar \
    http://search.maven.org/remotecontent?filepath=kg/apc/cmdrunner/${CMDRUNNER_VERSION}/cmdrunner-${CMDRUNNER_VERSION}.jar \
 && curl --location --silent --show-error --output ${JMETER_HOME}/lib/jmeter-plugins-webdriver-4.9.1.0.jar \
    https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-webdriver/4.9.1.0/jmeter-plugins-webdriver-4.9.1.0.jar \
 && curl --location --silent --show-error --output ${JMETER_HOME}/lib/selenium-remote-driver-4.9.1.jar \
    https://repo1.maven.org/maven2/org/seleniumhq/selenium/selenium-remote-driver/4.9.1/selenium-remote-driver-4.9.1.jar \
 && curl --location --silent --show-error --output ${JMETER_HOME}/lib/selenium-api-4.21.0.jar \
    https://repo1.maven.org/maven2/org/seleniumhq/selenium/selenium-api/4.21.0/selenium-api-4.21.0.jar

RUN ln -sf /usr/bin/firefox-esr /usr/bin/firefox

RUN java -cp ${JMETER_HOME}/lib/ext/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar org.jmeterplugins.repository.PluginManagerCMDInstaller

RUN curl -fL -o /tmp/geckodriver.tar.gz \
         https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz \
 && tar -xzf /tmp/geckodriver.tar.gz -C /tmp/ \
 && chmod +x /tmp/geckodriver \
 && mv /tmp/geckodriver ${JMETER_HOME}/bin

RUN ./PluginsManagerCMD.sh install jpgc-webdriver webdriver-sampler jmeter-plugins-webdriver selenium-remote

COPY launch.sh /usr/local/bin
RUN chmod +x /usr/local/bin/launch.sh



ENTRYPOINT ["/usr/local/bin/launch.sh"]

The script uses the launcher launch.sh, we create it next to the following content:

#!/bin/bash

export JVM_ARGS="-Xms1024m -Xmx8G -XX:MaxMetaspaceSize=1024m"
echo "JVM_ARGS=${JVM_ARGS}"
echo "/opt/apache-jmeter-5.6.3/bin/jmeter args=$@"

jmeter $@

By changing the JVM_ARGS parameters, you can adjust the test execution limits.

You can build a container into an image with the ffjmeter label using the command:

docker build -t docker/ffjmeter .

And to run tests, it’s convenient to create another script, run_tests.sh:

#!/bin/bash

NOW=$(date '+%Y-%m-%d_%H_%M');
JMX_FILENAME="jmix-benchmark.jmx";
VOLUME_PATH="/home/${USER}/projects/testjmix-jmeter";
JMETER_PATH="/root/jmeter";

docker run --name "jmeter_${NOW}" --volume "${VOLUME_PATH}":"${JMETER_PATH}" docker/ffjmeter -n -t "${JMETER_PATH}/${JMX_FILENAME}" -l "${JMETER_PATH}/result_${NOW}.jtl" -j "${JMETER_PATH}/jmeter_${NOW}.log"

docker rm jmeter_${NOW}

VOLUME_PATH points to the directory of the host computer in which we develop JMeter tests, and JMX_FILENAME determines which test case to run.

After running the tests, the container will be deleted, but the logs and report will remain in the project directory.

Using the JTL report file, you can build an HTML page with visually presented results.

To do this, in the first field of the form you need to select the CSV/JTL report file left over from the test container, in the second you can specify an empty (but existing) file, and in the third – an empty directory.

Thus, we now have a container that is suitable for both developing tests and executing them in a CI/CD continuous delivery environment. Development can be carried out without leaving the JMeter and browser test development environments. And with the help of configured monitoring, we can observe live the changes in indicators that occur during the tests. By combining the use of a test development environment with a graphical interface and their launchers in containers, we can both divide the load between the development computer and servers, and solve the problem of installing different versions of browsers and other necessary software on the developer’s computer.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *