From d5190dac232f8e9b4cbe7901f07f6c97d98ecf29 Mon Sep 17 00:00:00 2001
From: Daniel Gerhardt <code@dgerhardt.net>
Date: Sun, 28 Jul 2019 18:53:18 +0200
Subject: [PATCH] Add /management endpoint provided via Spring Actuator

The new endpoint provide metrics including information about application
environment, configuration, beans, caches via Spring Actuator.
Additional custom endpoints for version info and statistics are
available.

To allow monitoring of metrics, a Prometheus endpoint is provided via
Micrometer.
---
 pom.xml                                       | 12 ++++
 .../java/de/thm/arsnova/config/AppConfig.java | 37 +++++++++++--
 .../arsnova/controller/WelcomeController.java | 22 ++------
 .../management/StatisticsEndpoint.java        | 41 ++++++++++++++
 .../management/VersionInfoContributor.java    | 55 +++++++++++++++++++
 ... PathBasedContentNegotiationStrategy.java} | 15 +++--
 src/main/resources/config/actuator.yml        | 11 ++++
 7 files changed, 168 insertions(+), 25 deletions(-)
 create mode 100644 src/main/java/de/thm/arsnova/management/StatisticsEndpoint.java
 create mode 100644 src/main/java/de/thm/arsnova/management/VersionInfoContributor.java
 rename src/main/java/de/thm/arsnova/web/{PathApiVersionContentNegotiationStrategy.java => PathBasedContentNegotiationStrategy.java} (82%)
 create mode 100644 src/main/resources/config/actuator.yml

diff --git a/pom.xml b/pom.xml
index 1155820d8..e14879fad 100644
--- a/pom.xml
+++ b/pom.xml
@@ -213,6 +213,18 @@
 			<groupId>org.springframework.security</groupId>
 			<artifactId>spring-security-ldap</artifactId>
 		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-actuator-autoconfigure</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>io.micrometer</groupId>
+			<artifactId>micrometer-registry-jmx</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>io.micrometer</groupId>
+			<artifactId>micrometer-registry-prometheus</artifactId>
+		</dependency>
 		<dependency>
 			<groupId>org.hibernate.validator</groupId>
 			<artifactId>hibernate-validator</artifactId>
diff --git a/src/main/java/de/thm/arsnova/config/AppConfig.java b/src/main/java/de/thm/arsnova/config/AppConfig.java
index 4b14d85ad..880574ba1 100644
--- a/src/main/java/de/thm/arsnova/config/AppConfig.java
+++ b/src/main/java/de/thm/arsnova/config/AppConfig.java
@@ -26,6 +26,9 @@ import java.util.ArrayList;
 import java.util.List;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.config.PropertiesFactoryBean;
+import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
+import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.cache.CacheManager;
 import org.springframework.cache.annotation.EnableCaching;
@@ -69,7 +72,7 @@ import de.thm.arsnova.util.ImageUtils;
 import de.thm.arsnova.web.CacheControlInterceptorHandler;
 import de.thm.arsnova.web.CorsFilter;
 import de.thm.arsnova.web.DeprecatedApiInterceptorHandler;
-import de.thm.arsnova.web.PathApiVersionContentNegotiationStrategy;
+import de.thm.arsnova.web.PathBasedContentNegotiationStrategy;
 import de.thm.arsnova.web.ResponseInterceptorHandler;
 import de.thm.arsnova.websocket.ArsnovaSocketioServer;
 import de.thm.arsnova.websocket.ArsnovaSocketioServerImpl;
@@ -87,18 +90,23 @@ import de.thm.arsnova.websocket.ArsnovaSocketioServerImpl;
 		"de.thm.arsnova.cache",
 		"de.thm.arsnova.controller",
 		"de.thm.arsnova.event",
+		"de.thm.arsnova.management",
 		"de.thm.arsnova.security",
 		"de.thm.arsnova.service",
 		"de.thm.arsnova.web",
 		"de.thm.arsnova.websocket.handler"})
 @Configuration
 @EnableAsync(mode = AdviceMode.ASPECTJ)
+@EnableAutoConfiguration
 @EnableCaching(mode = AdviceMode.ASPECTJ)
 @EnableScheduling
 @EnableSpringConfigured
 @EnableWebMvc
 @PropertySource(
-		value = {"classpath:config/defaults.yml", "file:${arsnova.config-dir:.}/application.yml"},
+		value = {
+			"classpath:config/defaults.yml",
+			"classpath:config/actuator.yml",
+			"file:${arsnova.config-dir:.}/application.yml"},
 		ignoreResourceNotFound = true,
 		encoding = "UTF-8",
 		factory = YamlPropertySourceFactory.class
@@ -109,6 +117,7 @@ public class AppConfig implements WebMvcConfigurer {
 	public static final String API_V3_MEDIA_TYPE_VALUE = "application/vnd.de.thm.arsnova.v3+json";
 	public static final MediaType API_V2_MEDIA_TYPE = MediaType.valueOf(API_V2_MEDIA_TYPE_VALUE);
 	public static final MediaType API_V3_MEDIA_TYPE = MediaType.valueOf(API_V3_MEDIA_TYPE_VALUE);
+	public static final MediaType ACTUATOR_MEDIA_TYPE = MediaType.valueOf(ActuatorMediaType.V2_JSON);
 
 	@Autowired
 	private Environment env;
@@ -119,18 +128,22 @@ public class AppConfig implements WebMvcConfigurer {
 	@Autowired
 	private SecurityProperties securityProperties;
 
+	@Autowired
+	private WebEndpointProperties webEndpointProperties;
+
 	@Override
 	public void configureMessageConverters(final List<HttpMessageConverter<?>> converters) {
 		converters.add(defaultJsonMessageConverter());
 		converters.add(apiV2JsonMessageConverter());
+		converters.add(managementJsonMessageConverter());
 		converters.add(stringMessageConverter());
 		//converters.add(new MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build()));
 	}
 
 	@Override
 	public void configureContentNegotiation(final ContentNegotiationConfigurer configurer) {
-		final PathApiVersionContentNegotiationStrategy strategy =
-				new PathApiVersionContentNegotiationStrategy(API_V3_MEDIA_TYPE);
+		final PathBasedContentNegotiationStrategy strategy =
+				new PathBasedContentNegotiationStrategy(API_V3_MEDIA_TYPE, webEndpointProperties.getBasePath());
 		configurer.mediaType("json", MediaType.APPLICATION_JSON_UTF8);
 		configurer.mediaType("xml", MediaType.APPLICATION_XML);
 		configurer.favorParameter(false);
@@ -228,6 +241,22 @@ public class AppConfig implements WebMvcConfigurer {
 		return converter;
 	}
 
+	@Bean
+	public MappingJackson2HttpMessageConverter managementJsonMessageConverter() {
+		final Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
+		builder
+				.indentOutput(systemProperties.getApi().isIndentResponseBody())
+				.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
+		final ObjectMapper mapper = builder.build();
+		final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(mapper);
+		final List<MediaType> mediaTypes = new ArrayList<>();
+		mediaTypes.add(ACTUATOR_MEDIA_TYPE);
+		mediaTypes.add(MediaType.APPLICATION_JSON_UTF8);
+		converter.setSupportedMediaTypes(mediaTypes);
+
+		return converter;
+	}
+
 	@Bean
 	public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
 		final PropertySourcesPlaceholderConfigurer configurer = new PropertySourcesPlaceholderConfigurer();
diff --git a/src/main/java/de/thm/arsnova/controller/WelcomeController.java b/src/main/java/de/thm/arsnova/controller/WelcomeController.java
index 8ce005e50..59c322881 100644
--- a/src/main/java/de/thm/arsnova/controller/WelcomeController.java
+++ b/src/main/java/de/thm/arsnova/controller/WelcomeController.java
@@ -22,11 +22,9 @@ import java.net.InetAddress;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.UnknownHostException;
-import java.util.HashMap;
 import java.util.Map;
-import java.util.Properties;
-import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
@@ -42,6 +40,7 @@ import org.springframework.web.client.RestTemplate;
 import org.springframework.web.servlet.View;
 import org.springframework.web.servlet.view.RedirectView;
 
+import de.thm.arsnova.management.VersionInfoContributor;
 import de.thm.arsnova.web.exceptions.BadRequestException;
 import de.thm.arsnova.web.exceptions.NoContentException;
 
@@ -54,8 +53,8 @@ public class WelcomeController extends AbstractController {
 	@Value("${mobile.path}")
 	private String mobileContextPath;
 
-	@Resource(name = "versionInfoProperties")
-	private Properties versionInfoProperties;
+	@Autowired
+	private VersionInfoContributor versionInfoContributor;
 
 	@RequestMapping(value = "/", method = RequestMethod.GET)
 	public View home() {
@@ -65,18 +64,7 @@ public class WelcomeController extends AbstractController {
 	@RequestMapping(value = "/", method = RequestMethod.GET, produces = "application/json")
 	@ResponseBody
 	public Map<String, Object> jsonHome() {
-		final Map<String, Object> response = new HashMap<>();
-		final Map<String, Object> version = new HashMap<>();
-
-		version.put("string", versionInfoProperties.getProperty("version.string"));
-		version.put("buildTime", versionInfoProperties.getProperty("version.build-time"));
-		version.put("gitCommitId", versionInfoProperties.getProperty("version.git.commit-id"));
-		version.put("gitDirty", Boolean.parseBoolean(versionInfoProperties.getProperty("version.git.dirty")));
-
-		response.put("productName", "arsnova-backend");
-		response.put("version", version);
-
-		return response;
+		return versionInfoContributor.getInfoDetails();
 	}
 
 	@RequestMapping(value = "/checkframeoptionsheader", method = RequestMethod.POST)
diff --git a/src/main/java/de/thm/arsnova/management/StatisticsEndpoint.java b/src/main/java/de/thm/arsnova/management/StatisticsEndpoint.java
new file mode 100644
index 000000000..084972f47
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/management/StatisticsEndpoint.java
@@ -0,0 +1,41 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.management;
+
+import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
+import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
+import org.springframework.stereotype.Component;
+
+import de.thm.arsnova.model.Statistics;
+import de.thm.arsnova.service.StatisticsService;
+
+@Component
+@Endpoint(id = "stats")
+public class StatisticsEndpoint {
+	private StatisticsService statisticsService;
+
+	public StatisticsEndpoint(final StatisticsService statisticsService) {
+		this.statisticsService = statisticsService;
+	}
+
+	@ReadOperation
+	public Statistics readStatistics() {
+		return statisticsService.getStatistics();
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/management/VersionInfoContributor.java b/src/main/java/de/thm/arsnova/management/VersionInfoContributor.java
new file mode 100644
index 000000000..8a7defe35
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/management/VersionInfoContributor.java
@@ -0,0 +1,55 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.management;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import javax.annotation.Resource;
+import org.springframework.boot.actuate.info.Info;
+import org.springframework.boot.actuate.info.InfoContributor;
+import org.springframework.stereotype.Component;
+
+@Component
+public class VersionInfoContributor implements InfoContributor {
+	private Map<String, Object> infoDetails;
+
+	@Override
+	public void contribute(final Info.Builder builder) {
+		builder.withDetails(infoDetails);
+	}
+
+	@Resource(name = "versionInfoProperties")
+	public void setVersionInfoProperties(final Properties versionInfoProperties) {
+		infoDetails = new HashMap<>();
+		final Map<String, Object> version = new HashMap<>();
+
+		version.put("string", versionInfoProperties.getProperty("version.string"));
+		version.put("buildTime", versionInfoProperties.getProperty("version.build-time"));
+		version.put("gitCommitId", versionInfoProperties.getProperty("version.git.commit-id"));
+		version.put("gitDirty", Boolean.parseBoolean(versionInfoProperties.getProperty("version.git.dirty")));
+
+		infoDetails.put("productName", "arsnova-backend");
+		infoDetails.put("version", version);
+	}
+
+	public Map<String, Object> getInfoDetails() {
+		return infoDetails;
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/web/PathApiVersionContentNegotiationStrategy.java b/src/main/java/de/thm/arsnova/web/PathBasedContentNegotiationStrategy.java
similarity index 82%
rename from src/main/java/de/thm/arsnova/web/PathApiVersionContentNegotiationStrategy.java
rename to src/main/java/de/thm/arsnova/web/PathBasedContentNegotiationStrategy.java
index b5875482a..a7e7dd586 100644
--- a/src/main/java/de/thm/arsnova/web/PathApiVersionContentNegotiationStrategy.java
+++ b/src/main/java/de/thm/arsnova/web/PathBasedContentNegotiationStrategy.java
@@ -39,14 +39,17 @@ import de.thm.arsnova.controller.AbstractEntityController;
  *
  * @author Daniel Gerhardt
  */
-public class PathApiVersionContentNegotiationStrategy implements ContentNegotiationStrategy {
-	private static final Logger logger = LoggerFactory.getLogger(PathApiVersionContentNegotiationStrategy.class);
+public class PathBasedContentNegotiationStrategy implements ContentNegotiationStrategy {
+	private static final Logger logger = LoggerFactory.getLogger(PathBasedContentNegotiationStrategy.class);
+
+	private final String managementPath;
 
 	private MediaType fallback;
 	private MediaType empty = MediaType.valueOf(AbstractEntityController.MEDIATYPE_EMPTY);
 
-	public PathApiVersionContentNegotiationStrategy(final MediaType fallback) {
+	public PathBasedContentNegotiationStrategy(final MediaType fallback, final String managementPath) {
 		this.fallback = fallback;
+		this.managementPath = managementPath + "/";
 	}
 
 	@Override
@@ -54,7 +57,11 @@ public class PathApiVersionContentNegotiationStrategy implements ContentNegotiat
 			throws HttpMediaTypeNotAcceptableException {
 		final HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
 		final List<MediaType> mediaTypes = new ArrayList<>();
-		if (servletRequest.getServletPath().startsWith("/v2/")) {
+		if (servletRequest.getServletPath().startsWith(managementPath)) {
+			logger.trace("Negotiating content based on path for management API");
+			mediaTypes.add(AppConfig.ACTUATOR_MEDIA_TYPE);
+			mediaTypes.add(MediaType.TEXT_PLAIN);
+		} else if (servletRequest.getServletPath().startsWith("/v2/")) {
 			logger.trace("Negotiating content based on path for API v2");
 			mediaTypes.add(AppConfig.API_V2_MEDIA_TYPE);
 			mediaTypes.add(MediaType.TEXT_PLAIN);
diff --git a/src/main/resources/config/actuator.yml b/src/main/resources/config/actuator.yml
new file mode 100644
index 000000000..568e51f7f
--- /dev/null
+++ b/src/main/resources/config/actuator.yml
@@ -0,0 +1,11 @@
+arsnova:
+  management:
+    endpoints:
+      web:
+        base-path: /management
+        exposure:
+          include: "*"
+    metrics:
+      web:
+        server:
+          auto-time-requests: true
-- 
GitLab