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