diff --git a/.env.tpl b/.env.tpl
new file mode 100644
index 0000000..de791ea
--- /dev/null
+++ b/.env.tpl
@@ -0,0 +1,5 @@
+ANTRAGSGRUEN_INSTALLATIONS_0_ID=std
+ANTRAGSGRUEN_INSTALLATIONS_0_PUBLIC_KEY=MII...
+ANTRAGSGRUEN_WS_ORIGINS=http://*.antragsgruen.test
+ACTUATOR_USER=admin
+ACTUATOR_PASSWORD=admin
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index e128394..a876cb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,10 @@ target/
!**/src/test/**/target/
src/main/resources/static/stomp.umd.min.js
+src/main/resources/public.key
+docker/prometheus/prometheus.yml
node_modules/
+.env
### IntelliJ IDEA ###
.idea
diff --git a/README.md b/README.md
index ae9c479..3b43136 100644
--- a/README.md
+++ b/README.md
@@ -115,13 +115,20 @@ Compiling and running:
### Running with Docker (JRE)
-A dummy docker-compose.yml is provided that builds and runs the application.
+A dummy docker-compose.yml is provided that builds and runs the application. To set it up:
+- copy [.env.tpl](.env.tpl) to `.env` and modify environment variables to your needs. In particular, enter the JWT private key.
+- copy [prometheus.demo.yml](docker/prometheus/prometheus.demo.yml) to `prometheus.yml`. Set the credentials in there to the same as `ACTUATOR_USER` / `ACTUATOR_PASSWORD` in `.env`.
```shell
docker compose -f docker-compose.jdk.yml build
docker compose -f docker-compose.jdk.yml up
```
+This will expose services:
+- http://localhost:8080/ : The main webservice application (not meant to be accessed directly)
+- http://localhost:3000/ : Grafana, to access Prometheus logs. (Grafana will not be configured at all. So to access the metrics, it will be necessary to set up a Prometheus datasource pointing at `http://prometheus:9090`.)
+
+
## Testing
### Running spotbugs && checkstyle
diff --git a/docker-compose.jdk.yml b/docker-compose.jdk.yml
index 7c52ceb..77c4cea 100644
--- a/docker-compose.jdk.yml
+++ b/docker-compose.jdk.yml
@@ -5,15 +5,14 @@ services:
dockerfile: docker/jdk/Dockerfile
#target: build - enable this to use JDK image
environment:
- - ANTRAGSGRUEN_WS_ORIGINS=http://*.antragsgruen.test
- RABBITMQ_HOST=rabbitmq
- - ACTUATOR_USER=admin
- - ACTUATOR_PASSWORD=admin
+ env_file: .env
depends_on:
rabbitmq:
condition: service_healthy
ports:
- 127.0.0.1:8080:8080
+
rabbitmq:
image: 'rabbitmq:3-management-alpine'
environment:
@@ -26,4 +25,31 @@ services:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 10s
- retries: 3
\ No newline at end of file
+ retries: 3
+
+ prometheus:
+ image: prom/prometheus:v2.26.1
+ #ports:
+ # - 9090:9090
+ volumes:
+ - prometheus-data:/prometheus
+ - ./docker/prometheus:/etc/prometheus
+ environment:
+ - ACTUATOR_USER=admin
+ - ACTUATOR_PASSWORD=admin
+ command: --config.file=/etc/prometheus/prometheus.yml
+ depends_on:
+ - live
+
+ grafana:
+ image: grafana/grafana:7.5.6
+ ports:
+ - 3000:3000
+ volumes:
+ - grafana-data:/var/lib/grafana
+ depends_on:
+ - prometheus
+
+volumes:
+ prometheus-data:
+ grafana-data:
\ No newline at end of file
diff --git a/docker/prometheus/prometheus.demo.yml b/docker/prometheus/prometheus.demo.yml
new file mode 100644
index 0000000..82ced30
--- /dev/null
+++ b/docker/prometheus/prometheus.demo.yml
@@ -0,0 +1,18 @@
+global:
+ scrape_interval: 10s
+
+scrape_configs:
+ - job_name: 'prometheus'
+ static_configs:
+ - targets:
+ - 'localhost:9090'
+
+ - job_name: 'antragsgruen_live'
+ metrics_path: /actuator/prometheus
+ # https://github.com/prometheus/prometheus/issues/10554 - no support for environment variables
+ basic_auth:
+ username: admin
+ password: admin
+ static_configs:
+ - targets:
+ - 'live:8080'
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index a97e755..6ce37c0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.3.4
+ 3.3.5
de.antragsgruen
@@ -29,7 +29,7 @@
org.projectlombok
lombok
- 1.18.34
+ 1.18.36
provided
@@ -40,6 +40,11 @@
org.springframework.boot
spring-boot-starter-actuator
+
+ io.micrometer
+ micrometer-registry-prometheus
+ runtime
+
@@ -50,7 +55,7 @@
net.javacrumbs.json-unit
json-unit-assertj
- 3.4.1
+ 3.5.0
test
diff --git a/spotbugs-ignore.xml b/spotbugs-ignore.xml
index b4f8c08..9598e4e 100644
--- a/spotbugs-ignore.xml
+++ b/spotbugs-ignore.xml
@@ -10,6 +10,7 @@
+
diff --git a/src/main/java/de/antragsgruen/live/LiveApplication.java b/src/main/java/de/antragsgruen/live/LiveApplication.java
index b69c1af..221a739 100644
--- a/src/main/java/de/antragsgruen/live/LiveApplication.java
+++ b/src/main/java/de/antragsgruen/live/LiveApplication.java
@@ -2,8 +2,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication()
+@EnableScheduling
public class LiveApplication {
public static void main(String[] args) {
diff --git a/src/main/java/de/antragsgruen/live/metrics/ActiveWebsocketConnectionMetric.java b/src/main/java/de/antragsgruen/live/metrics/ActiveWebsocketConnectionMetric.java
new file mode 100644
index 0000000..da6fe2e
--- /dev/null
+++ b/src/main/java/de/antragsgruen/live/metrics/ActiveWebsocketConnectionMetric.java
@@ -0,0 +1,48 @@
+package de.antragsgruen.live.metrics;
+
+import de.antragsgruen.live.multisite.ConsultationScope;
+import de.antragsgruen.live.websocket.TopicPermissionChecker;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.messaging.simp.user.SimpUserRegistry;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ActiveWebsocketConnectionMetric {
+ private final SimpUserRegistry userRegistry;
+ private final MeterRegistry registry;
+
+ private static final String METRIC_NAME = "antragsgruen_live.ws.active_connections";
+
+ @Scheduled(fixedRateString = "${antragsgruen.metrics.interval.ms}")
+ public void processGauge() {
+ userRegistry.findSubscriptions(subscription -> true)
+ .stream()
+ .map(subscription -> TopicPermissionChecker.consultationScopeFromPathParts(subscription.getDestination().split("/")))
+ .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
+ .forEach(this::trackMetric);
+ }
+
+ private void trackMetric(ConsultationScope scope, Long subscribers) {
+ log.debug("Logging active websocket metrics: " + scope + " - " + subscribers);
+
+ registry.gauge(
+ METRIC_NAME,
+ List.of(
+ Tag.of("installation", scope.installation()),
+ Tag.of("site", scope.site()),
+ Tag.of("consultation", scope.consultation())
+ ),
+ subscribers
+ );
+ }
+}
diff --git a/src/main/java/de/antragsgruen/live/metrics/ReceivedRabbitMQMessagesMetric.java b/src/main/java/de/antragsgruen/live/metrics/ReceivedRabbitMQMessagesMetric.java
new file mode 100644
index 0000000..4d39713
--- /dev/null
+++ b/src/main/java/de/antragsgruen/live/metrics/ReceivedRabbitMQMessagesMetric.java
@@ -0,0 +1,41 @@
+package de.antragsgruen.live.metrics;
+
+import de.antragsgruen.live.multisite.ConsultationScope;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ReceivedRabbitMQMessagesMetric {
+ private final MeterRegistry registry;
+
+ private static final String METRIC_NAME = "antragsgruen_live.rabbitmq.msg_count";
+
+ private static final String TYPE_SPEECH = "speech";
+ private static final String TYPE_USER = "user";
+
+ public void onSpeechEvent(ConsultationScope scope) {
+ this.receivedMessage(scope, TYPE_SPEECH);
+ }
+
+ public void onUserEvent(ConsultationScope scope) {
+ this.receivedMessage(scope, TYPE_USER);
+ }
+
+ private void receivedMessage(ConsultationScope scope, String type) {
+ log.debug("Logging RabbitMQ message count: " + scope + " - " + type);
+
+ registry.counter(METRIC_NAME, List.of(
+ Tag.of("installation", scope.installation()),
+ Tag.of("site", scope.site()),
+ Tag.of("consultation", scope.consultation()),
+ Tag.of("type", type)
+ )).increment();
+ }
+}
diff --git a/src/main/java/de/antragsgruen/live/metrics/package-info.java b/src/main/java/de/antragsgruen/live/metrics/package-info.java
new file mode 100644
index 0000000..726c459
--- /dev/null
+++ b/src/main/java/de/antragsgruen/live/metrics/package-info.java
@@ -0,0 +1,6 @@
+@NonNullApi
+@NonNullFields
+package de.antragsgruen.live.metrics;
+
+import org.springframework.lang.NonNullApi;
+import org.springframework.lang.NonNullFields;
diff --git a/src/main/java/de/antragsgruen/live/rabbitmq/SpeechMessageReceiver.java b/src/main/java/de/antragsgruen/live/rabbitmq/SpeechMessageReceiver.java
index 33d3c94..044f62b 100644
--- a/src/main/java/de/antragsgruen/live/rabbitmq/SpeechMessageReceiver.java
+++ b/src/main/java/de/antragsgruen/live/rabbitmq/SpeechMessageReceiver.java
@@ -1,6 +1,7 @@
package de.antragsgruen.live.rabbitmq;
import de.antragsgruen.live.SpeechAdminHandler;
+import de.antragsgruen.live.metrics.ReceivedRabbitMQMessagesMetric;
import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQSpeechQueue;
import de.antragsgruen.live.SpeechUserHandler;
@@ -23,6 +24,7 @@ public final class SpeechMessageReceiver {
@NonNull private SpeechUserHandler speechUserHandler;
@NonNull private SpeechAdminHandler speechAdminHandler;
+ @NonNull private ReceivedRabbitMQMessagesMetric receivedRabbitMQMessagesMetric;
@RabbitListener(queues = {"${antragsgruen.rabbitmq.queue.speech}"})
public void receiveMessage(MQSpeechQueue event, @Header(AmqpHeaders.RECEIVED_ROUTING_KEY) String routingKey) {
@@ -37,6 +39,7 @@ public void receiveMessage(MQSpeechQueue event, @Header(AmqpHeaders.RECEIVED_ROU
routingKeyParts[RK_PART_CONSULTATION]
);
+ receivedRabbitMQMessagesMetric.onSpeechEvent(scope);
speechUserHandler.onSpeechEvent(scope, event);
speechAdminHandler.onSpeechEvent(scope, event);
}
diff --git a/src/main/java/de/antragsgruen/live/rabbitmq/UserMessageReceiver.java b/src/main/java/de/antragsgruen/live/rabbitmq/UserMessageReceiver.java
index eeeac87..5758aab 100644
--- a/src/main/java/de/antragsgruen/live/rabbitmq/UserMessageReceiver.java
+++ b/src/main/java/de/antragsgruen/live/rabbitmq/UserMessageReceiver.java
@@ -1,5 +1,6 @@
package de.antragsgruen.live.rabbitmq;
+import de.antragsgruen.live.metrics.ReceivedRabbitMQMessagesMetric;
import de.antragsgruen.live.multisite.ConsultationScope;
import de.antragsgruen.live.rabbitmq.dto.MQUserEvent;
import de.antragsgruen.live.websocket.Sender;
@@ -23,6 +24,7 @@ public final class UserMessageReceiver {
private static final int RK_PART_USER = 4;
@NonNull private Sender sender;
+ @NonNull private ReceivedRabbitMQMessagesMetric receivedRabbitMQMessagesMetric;
@RabbitListener(queues = {"${antragsgruen.rabbitmq.queue.user}"})
public void receiveMessage(MQUserEvent event, @Header(AmqpHeaders.RECEIVED_ROUTING_KEY) String routingKey) {
@@ -37,8 +39,9 @@ public void receiveMessage(MQUserEvent event, @Header(AmqpHeaders.RECEIVED_ROUTI
routingKeyParts[RK_PART_CONSULTATION]
);
- log.warn("Received user message: " + routingKey + " => " + event.username());
+ log.debug("Received user message: " + routingKey + " => " + event.username());
+ receivedRabbitMQMessagesMetric.onUserEvent(scope);
sender.sendToUser(scope, routingKeyParts[RK_PART_USER], Sender.ROLE_USER, Sender.USER_CHANNEL_DEFAULT, event.username());
}
}
diff --git a/src/main/java/de/antragsgruen/live/websocket/TopicPermissionChecker.java b/src/main/java/de/antragsgruen/live/websocket/TopicPermissionChecker.java
index 9a5ee15..9452d28 100644
--- a/src/main/java/de/antragsgruen/live/websocket/TopicPermissionChecker.java
+++ b/src/main/java/de/antragsgruen/live/websocket/TopicPermissionChecker.java
@@ -14,7 +14,7 @@
public class TopicPermissionChecker {
private static final String ROLE_SPEECH_ADMIN = "ROLE_SPEECH_ADMIN";
- public static final int USER_PARTS_LENGTH = 7;
+ public static final int USER_PARTS_LENGTH = 7; // Also used for /admin/ topics
public static final int USER_PART_ROLE = 1;
public static final int USER_PART_INSTALLATION = 2;
public static final int USER_PART_SITE = 3;
@@ -44,18 +44,13 @@ public boolean canSubscribeToDestination(JwtAuthenticationToken jwtToken, @Nulla
return false;
}
- ConsultationScope scope = null;
- boolean additionalPermissionsPassed = true;
+ ConsultationScope scope = TopicPermissionChecker.consultationScopeFromPathParts(pathParts);
- if ("topic".equals(pathParts[TOPIC_PART_TOPIC]) && pathParts.length == TOPIC_PARTS_LENGTH) {
- scope = new ConsultationScope(pathParts[TOPIC_PART_INSTALLATION], pathParts[TOPIC_PART_SITE], pathParts[TOPIC_PART_CONSULTATION]);
- }
+ boolean additionalPermissionsPassed = true;
if (Sender.ROLE_USER.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
- scope = new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
additionalPermissionsPassed = pathParts[USER_PART_USER].equals(jwtToken.getName());
}
if (Sender.ROLE_ADMIN.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
- scope = new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
additionalPermissionsPassed = jwtHasRoleForTopic(jwtToken, pathParts[USER_PART_MODULE])
&& pathParts[USER_PART_USER].equals(jwtToken.getName());
}
@@ -63,6 +58,19 @@ public boolean canSubscribeToDestination(JwtAuthenticationToken jwtToken, @Nulla
return (scope != null && jwtIsForCorrectConsultation(jwtToken, scope) && additionalPermissionsPassed);
}
+ public static ConsultationScope consultationScopeFromPathParts(String[] pathParts) {
+ if ("topic".equals(pathParts[TOPIC_PART_TOPIC]) && pathParts.length == TOPIC_PARTS_LENGTH) {
+ return new ConsultationScope(pathParts[TOPIC_PART_INSTALLATION], pathParts[TOPIC_PART_SITE], pathParts[TOPIC_PART_CONSULTATION]);
+ }
+ if (Sender.ROLE_USER.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
+ return new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
+ }
+ if (Sender.ROLE_ADMIN.equals(pathParts[USER_PART_ROLE]) && pathParts.length == USER_PARTS_LENGTH) {
+ return new ConsultationScope(pathParts[USER_PART_INSTALLATION], pathParts[USER_PART_SITE], pathParts[USER_PART_CONSULTATION]);
+ }
+ return null;
+ }
+
private boolean jwtIsForCorrectConsultation(JwtAuthenticationToken jwtToken, ConsultationScope scope) {
Object payload = jwtToken.getTokenAttributes().get("payload");
if (!(payload instanceof Map, ?> payloadMap)) {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 9c15b53..1d692a9 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -23,9 +23,14 @@ antragsgruen:
routing:
user: user.*.*.*.*
speech: speech.*.*.*
+ metrics:
+ interval.ms: 5000
# Actuator
management:
+ metrics:
+ tags:
+ application: antragsgruen_live
endpoints:
web:
exposure:
@@ -43,4 +48,14 @@ spring.security.user:
name: ${ACTUATOR_USER:admin}
password: ${ACTUATOR_PASSWORD:admin}
roles:
- - ADMIN
\ No newline at end of file
+ - ADMIN
+
+server:
+ tomcat:
+ accesslog:
+ enabled: true
+ buffered: false
+ directory: /dev
+ prefix: stdout
+ suffix: ""
+ file-date-format: ""
\ No newline at end of file