From 1c8c10ce8bfad86dac6629706aeea09e1c380578 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
health, environment, configuration, beans, caches and performance via
Spring Actuator. Additional custom endpoints for version info and
statistics are available.

Content type `application/json` may not be set for the `Accept` header
of requests to the management API. Otherwise, only a JSON stub is
returned. `application/vnd.spring-boot.actuator.v2+json` or `*/*` should
be used instead.

To allow monitoring of metrics, a Prometheus endpoint is provided via
Micrometer.

Web API documentation:
https://docs.spring.io/spring-boot/docs/2.1.x/actuator-api/html/
---
 pom.xml                                       | 12 ++++
 .../java/de/thm/arsnova/config/AppConfig.java | 57 +++++++++++++++----
 .../de/thm/arsnova/config/AppInitializer.java |  2 +
 .../arsnova/controller/WelcomeController.java | 22 ++-----
 .../management/StatisticsEndpoint.java        | 41 +++++++++++++
 .../management/VersionInfoContributor.java    | 55 ++++++++++++++++++
 ... PathBasedContentNegotiationStrategy.java} | 15 +++--
 src/main/resources/config/actuator.yml        | 11 ++++
 8 files changed, 184 insertions(+), 31 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 6d8ae36ac..0e58a253b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -219,6 +219,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..ddce39e4d 100644
--- a/src/main/java/de/thm/arsnova/config/AppConfig.java
+++ b/src/main/java/de/thm/arsnova/config/AppConfig.java
@@ -21,21 +21,23 @@ package de.thm.arsnova.config;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.SerializationFeature;
+import io.micrometer.core.instrument.MeterRegistry;
 import java.nio.charset.Charset;
 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.autoconfigure.metrics.MetricsProperties;
+import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
+import org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter;
+import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.cache.CacheManager;
 import org.springframework.cache.annotation.EnableCaching;
 import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
-import org.springframework.context.annotation.AdviceMode;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.ComponentScan;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Profile;
-import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.*;
 import org.springframework.context.annotation.aspectj.EnableSpringConfigured;
 import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
 import org.springframework.core.env.Environment;
@@ -69,7 +71,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 +89,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 +116,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 +127,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);
@@ -161,6 +173,15 @@ public class AppConfig implements WebMvcConfigurer {
 		registry.addResourceHandler("swagger.json").addResourceLocations("classpath:/");
 	}
 
+	/* Provides a Spring Framework (non-Boot) compatible Filter. */
+	@Bean
+	public WebMvcMetricsFilter webMvcMetricsFilterOverride(
+			final MeterRegistry registry, final WebMvcTagsProvider tagsProvider) {
+		final MetricsProperties.Web.Server serverProperties = new MetricsProperties.Web.Server();
+		return new WebMvcMetricsFilter(registry, tagsProvider,
+				serverProperties.getRequestsMetricName(), serverProperties.isAutoTimeRequests());
+	}
+
 	@Bean
 	public CacheControlInterceptorHandler cacheControlInterceptorHandler() {
 		return new CacheControlInterceptorHandler();
@@ -228,6 +249,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/config/AppInitializer.java b/src/main/java/de/thm/arsnova/config/AppInitializer.java
index 74e069a74..d19711218 100644
--- a/src/main/java/de/thm/arsnova/config/AppInitializer.java
+++ b/src/main/java/de/thm/arsnova/config/AppInitializer.java
@@ -50,11 +50,13 @@ public class AppInitializer extends AbstractAnnotationConfigDispatcherServletIni
 	@Override
 	protected Filter[] getServletFilters() {
 		final CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter("UTF-8");
+		final DelegatingFilterProxy webMvcMetricsFilter = new DelegatingFilterProxy("webMvcMetricsFilterOverride");
 		final DelegatingFilterProxy corsFilter = new DelegatingFilterProxy("corsFilter");
 		final DelegatingFilterProxy maintenanceModeFilter = new DelegatingFilterProxy("maintenanceModeFilter");
 		final DelegatingFilterProxy v2ContentTypeOverrideFilter = new DelegatingFilterProxy("v2ContentTypeOverrideFilter");
 
 		return new Filter[] {
+				webMvcMetricsFilter,
 				characterEncodingFilter,
 				corsFilter,
 				maintenanceModeFilter,
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