diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle index c85920eebf8f..f628e45a86a0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -53,6 +53,7 @@ dependencies { optional("io.micrometer:micrometer-registry-influx") optional("io.micrometer:micrometer-registry-jmx") optional("io.micrometer:micrometer-registry-kairos") + optional("io.micrometer:micrometer-registry-health") optional("io.micrometer:micrometer-registry-new-relic") optional("io.micrometer:micrometer-registry-prometheus") optional("io.micrometer:micrometer-registry-stackdriver") diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfiguration.java new file mode 100644 index 000000000000..2d9dcca2f078 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfiguration.java @@ -0,0 +1,142 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.binder.BaseUnits; +import io.micrometer.core.instrument.binder.MeterBinder; +import io.micrometer.core.instrument.config.NamingConvention; +import io.micrometer.core.ipc.http.HttpUrlConnectionSender; +import io.micrometer.health.HealthConfig; +import io.micrometer.health.HealthMeterRegistry; +import io.micrometer.health.ServiceLevelObjective; +import io.micrometer.health.objectives.JvmServiceLevelObjectives; +import io.micrometer.health.objectives.OperatingSystemServiceLevelObjectives; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.actuate.health.*; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for building health indicators based + * on service level objectives. + * + * @author Jon Schneider + * @since 2.4.0 + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureBefore({ CompositeMeterRegistryAutoConfiguration.class, SimpleMetricsExportAutoConfiguration.class }) +@AutoConfigureAfter(MetricsAutoConfiguration.class) +@ConditionalOnBean(Clock.class) +@ConditionalOnClass(HealthMeterRegistry.class) +@ConditionalOnProperty(prefix = "management.metrics.export.health", name = "enabled", havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(HealthProperties.class) +public class HealthMetricsExportAutoConfiguration { + + private final NamingConvention camelCasedHealthIndicatorNames = NamingConvention.camelCase; + + private final HealthProperties properties; + + public HealthMetricsExportAutoConfiguration(HealthProperties properties) { + this.properties = properties; + } + + @Bean + @ConditionalOnMissingBean + public HealthConfig healthConfig() { + return new HealthPropertiesConfigAdapter(this.properties); + } + + @Bean + @ConditionalOnMissingBean + public HealthMeterRegistry healthMeterRegistry(HealthConfig healthConfig, Clock clock, + ObjectProvider serviceLevelObjectives, + GenericApplicationContext applicationContext) { + HealthMeterRegistry registry = HealthMeterRegistry.builder(healthConfig).clock(clock) + .serviceLevelObjectives(serviceLevelObjectives.orderedStream().toArray(ServiceLevelObjective[]::new)) + .serviceLevelObjectives(JvmServiceLevelObjectives.MEMORY) + .serviceLevelObjectives(OperatingSystemServiceLevelObjectives.DISK) + .serviceLevelObjectives(properties.getApiErrorBudgets().entrySet().stream().map(apiErrorBudget -> { + String apiEndpoints = '/' + apiErrorBudget.getKey().replace('.', '/'); + + return ServiceLevelObjective.build("api.error.ratio." + apiErrorBudget.getKey()) + .failedMessage("API error ratio exceeded.").baseUnit(BaseUnits.PERCENT) + .tag("uri.matches", apiEndpoints + "/**").tag("error.outcome", "SERVER_ERROR") + .errorRatio( + s -> s.name("http.server.requests").tag("uri", uri -> uri.startsWith(apiEndpoints)), + all -> all.tag("outcome", "SERVER_ERROR")) + .isLessThan(apiErrorBudget.getValue()); + }).toArray(ServiceLevelObjective[]::new)).build(); + + for (ServiceLevelObjective slo : registry.getServiceLevelObjectives()) { + applicationContext.registerBean(camelCasedHealthIndicatorNames.name(slo.getName(), Meter.Type.GAUGE), + HealthContributor.class, () -> toHealthContributor(registry, slo)); + } + + return registry; + } + + private HealthContributor toHealthContributor(HealthMeterRegistry registry, ServiceLevelObjective slo) { + if (slo instanceof ServiceLevelObjective.SingleIndicator) { + return new AbstractHealthIndicator(slo.getFailedMessage()) { + @Override + protected void doHealthCheck(Health.Builder builder) { + ServiceLevelObjective.SingleIndicator singleIndicator = (ServiceLevelObjective.SingleIndicator) slo; + builder.status(slo.healthy(registry) ? Status.UP : Status.OUT_OF_SERVICE) + .withDetail("value", singleIndicator.getValueAsString(registry)) + .withDetail("mustBe", singleIndicator.getTestDescription()); + + for (Tag tag : slo.getTags()) { + builder.withDetail(camelCasedHealthIndicatorNames.tagKey(tag.getKey()), tag.getValue()); + } + + if (slo.getBaseUnit() != null) { + builder.withDetail("unit", slo.getBaseUnit()); + } + } + }; + } + else { + ServiceLevelObjective.MultipleIndicator multipleIndicator = (ServiceLevelObjective.MultipleIndicator) slo; + Map objectiveIndicators = Arrays.stream(multipleIndicator.getObjectives()) + .collect(Collectors.toMap( + indicator -> camelCasedHealthIndicatorNames.name(indicator.getName(), Meter.Type.GAUGE), + indicator -> toHealthContributor(registry, indicator))); + return CompositeHealthContributor.fromMap(objectiveIndicators); + } + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthProperties.java new file mode 100644 index 000000000000..a0e8ad84ef88 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthProperties.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link ConfigurationProperties @ConfigurationProperties} for configuring health + * indicators based on service level objectives. + * + * @author Jon Schneider + * @since 2.4.0 + */ +@ConfigurationProperties(prefix = "management.metrics.export.health") +public class HealthProperties { + + /** + * Step size (i.e. polling frequency for moving window indicators) to use. + */ + private Duration step = Duration.ofSeconds(10); + + /** + * Error budgets by API endpoint prefix. The value is a percentage in the range [0,1]. + */ + private final Map apiErrorBudgets = new LinkedHashMap<>(); + + public Duration getStep() { + return step; + } + + public void setStep(Duration step) { + this.step = step; + } + + public Map getApiErrorBudgets() { + return apiErrorBudgets; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapter.java new file mode 100644 index 000000000000..9954ba97920f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapter.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import io.micrometer.health.HealthConfig; +import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PropertiesConfigAdapter; + +import java.time.Duration; + +/** + * Adapter to convert {@link HealthProperties} to a {@link HealthConfig}. + * + * @author Jon Schneider + * @since 2.4.0 + */ +class HealthPropertiesConfigAdapter extends PropertiesConfigAdapter implements HealthConfig { + + HealthPropertiesConfigAdapter(HealthProperties properties) { + super(properties); + } + + @Override + public String prefix() { + return "management.metrics.export.health"; + } + + @Override + public String get(String k) { + return null; + } + + @Override + public Duration step() { + return get(HealthProperties::getStep, HealthConfig.super::step); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/package-info.java new file mode 100644 index 000000000000..2d9f24ec7aa4 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for building health indicators with service level objectives. + */ +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfigurationTests.java new file mode 100644 index 000000000000..28341b1cc254 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthMetricsExportAutoConfigurationTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.health.HealthConfig; +import io.micrometer.health.HealthMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HealthMetricsExportAutoConfiguration}. + * + * @author Jon Schneider + */ +class HealthMetricsExportAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HealthMetricsExportAutoConfiguration.class)); + + @Test + void backsOffWithoutAClock() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(HealthMeterRegistry.class)); + } + + @Test + void autoConfiguresConfigAndMeterRegistry() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class).run((context) -> assertThat(context) + .hasSingleBean(HealthMeterRegistry.class).hasSingleBean(HealthConfig.class)); + } + + @Test + void autoConfiguresHealthIndicators() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class) + .withPropertyValues("management.metrics.export.health.api-error-budgets.api.customer=0.01") + .withPropertyValues("management.metrics.export.health.api-error-budgets.admin=0.02") + .run((context) -> assertThat(context).hasBean("apiErrorRatioApiCustomer").hasBean("apiErrorRatioAdmin") + .hasBean("jvmGcLoad").hasBean("jvmPoolMemory").hasBean("jvmTotalMemory")); + } + + @Test + void autoConfigurationCanBeDisabled() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class) + .withPropertyValues("management.metrics.export.health.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(HealthMeterRegistry.class) + .doesNotHaveBean(HealthConfig.class)); + } + + @Test + void allowsCustomConfigToBeUsed() { + this.contextRunner.withUserConfiguration(CustomConfigConfiguration.class).run((context) -> assertThat(context) + .hasSingleBean(HealthMeterRegistry.class).hasSingleBean(HealthConfig.class).hasBean("customConfig")); + } + + @Test + void allowsCustomRegistryToBeUsed() { + this.contextRunner.withUserConfiguration(CustomRegistryConfiguration.class).run((context) -> assertThat(context) + .hasSingleBean(HealthMeterRegistry.class).hasBean("customRegistry").hasSingleBean(HealthConfig.class)); + } + + @Test + void stopsMeterRegistryWhenContextIsClosed() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class).run((context) -> { + HealthMeterRegistry registry = context.getBean(HealthMeterRegistry.class); + assertThat(registry.isClosed()).isFalse(); + context.close(); + assertThat(registry.isClosed()).isTrue(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class BaseConfiguration { + + @Bean + Clock clock() { + return Clock.SYSTEM; + } + + } + + @Configuration(proxyBeanMethods = false) + @Import(BaseConfiguration.class) + static class CustomConfigConfiguration { + + @Bean + HealthConfig customConfig() { + return (key) -> { + if ("health.step".equals(key)) { + return "PT20S"; + } + return null; + }; + } + + } + + @Configuration(proxyBeanMethods = false) + @Import(BaseConfiguration.class) + static class CustomRegistryConfiguration { + + @Bean + HealthMeterRegistry customRegistry(HealthConfig config, Clock clock) { + return HealthMeterRegistry.builder(config).clock(clock).build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapterTests.java new file mode 100644 index 000000000000..992b16072c22 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesConfigAdapterTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HealthPropertiesConfigAdapter}. + * + * @author Jon Schneider + */ +class HealthPropertiesConfigAdapterTests { + + @Test + void stepCanBeSet() { + HealthProperties properties = new HealthProperties(); + properties.setStep(Duration.ofSeconds(20)); + assertThat(new HealthPropertiesConfigAdapter(properties).step()).isEqualTo(Duration.ofSeconds(20)); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesTests.java new file mode 100644 index 000000000000..5d96e43f57cd --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/health/HealthPropertiesTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics.export.health; + +import io.micrometer.datadog.DatadogConfig; +import io.micrometer.health.HealthConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.metrics.export.datadog.DatadogProperties; +import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesTests; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HealthProperties}. + * + * @author Jon Schneider + */ +class HealthPropertiesTests { + + @Test + void defaultValuesAreConsistent() { + HealthProperties properties = new HealthProperties(); + HealthConfig config = (key) -> null; + assertThat(properties.getStep()).isEqualTo(config.step()); + } + +}