diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/loggers.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/loggers.adoc index ee7671faa22..e9a20b09e78 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/loggers.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/loggers.adoc @@ -57,6 +57,21 @@ include::{snippets}loggers/single/response-fields.adoc[] +[[loggers-group]] +== Retrieving a Single Group + +To retrieve a single group, make a `GET` request to `/actuator/loggers/{group.name}`, +as shown in the following curl-based example: + +include::{snippets}loggers/group/curl-request.adoc[] + +The preceding example retrieves information about the logger group named `test`. The +resulting response is similar to the following: + +include::{snippets}loggers/group/http-response.adoc[] + + + [[loggers-setting-level]] == Setting a Log Level @@ -81,6 +96,30 @@ include::{snippets}loggers/set/request-fields.adoc[] +[[loggers-setting-level]] +== Setting a Log Level for a Group + +To set the level of a logger, make a `POST` request to +`/actuator/loggers/{group.name}` with a JSON body that specifies the configured level +for the logger group, as shown in the following curl-based example: + +include::{snippets}loggers/setGroup/curl-request.adoc[] + +The preceding example sets the `configuredLevel` of the `test` logger group to `DEBUG`. + + + +[[loggers-setting-level-request-structure]] +=== Request Structure + +The request specifies the desired level of the logger group. The following table describes the +structure of the request: + +[cols="3,1,3"] +include::{snippets}loggers/set/request-fields.adoc[] + + + [[loggers-clearing-level]] == Clearing a Log Level diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/logging/LoggersEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/logging/LoggersEndpointAutoConfiguration.java index 0b370d54959..244baa620dc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/logging/LoggersEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/logging/LoggersEndpointAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.logging; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.logging.LoggersEndpoint; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -24,6 +25,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionOutcome; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingSystem; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ConditionContext; @@ -45,8 +47,9 @@ public class LoggersEndpointAutoConfiguration { @ConditionalOnBean(LoggingSystem.class) @Conditional(OnEnabledLoggingSystemCondition.class) @ConditionalOnMissingBean - public LoggersEndpoint loggersEndpoint(LoggingSystem loggingSystem) { - return new LoggersEndpoint(loggingSystem); + public LoggersEndpoint loggersEndpoint(LoggingSystem loggingSystem, + ObjectProvider springBootLoggerGroups) { + return new LoggersEndpoint(loggingSystem, springBootLoggerGroups.getIfAvailable(LoggerGroups::new)); } static class OnEnabledLoggingSystemCondition extends SpringBootCondition { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/LoggersEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/LoggersEndpointDocumentationTests.java index 8b2e18b0c8f..37263c135ca 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/LoggersEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/LoggersEndpointDocumentationTests.java @@ -17,14 +17,18 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation; import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.logging.LoggersEndpoint; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; @@ -54,9 +58,21 @@ class LoggersEndpointDocumentationTests extends MockMvcEndpointDocumentationTest fieldWithPath("configuredLevel").description("Configured level of the logger, if any.").optional(), fieldWithPath("effectiveLevel").description("Effective level of the logger.")); + private static final List groupLevelFields; + + static { + groupLevelFields = Arrays.asList( + fieldWithPath("configuredLevel").description("Configured level of the logger group") + .type(LogLevel.class).optional(), + fieldWithPath("members").description("Loggers that are part of this group").optional()); + } + @MockBean private LoggingSystem loggingSystem; + @Autowired + private LoggerGroups loggerGroups; + @Test void allLoggers() throws Exception { given(this.loggingSystem.getSupportedLogLevels()).willReturn(EnumSet.allOf(LogLevel.class)); @@ -66,8 +82,10 @@ class LoggersEndpointDocumentationTests extends MockMvcEndpointDocumentationTest this.mockMvc.perform(get("/actuator/loggers")).andExpect(status().isOk()) .andDo(MockMvcRestDocumentation.document("loggers/all", responseFields(fieldWithPath("levels").description("Levels support by the logging system."), - fieldWithPath("loggers").description("Loggers keyed by name.")) - .andWithPrefix("loggers.*.", levelFields))); + fieldWithPath("loggers").description("Loggers keyed by name."), + fieldWithPath("groups").description("Logger groups keyed by name")) + .andWithPrefix("loggers.*.", levelFields) + .andWithPrefix("groups.*.", groupLevelFields))); } @Test @@ -78,6 +96,12 @@ class LoggersEndpointDocumentationTests extends MockMvcEndpointDocumentationTest .andDo(MockMvcRestDocumentation.document("loggers/single", responseFields(levelFields))); } + @Test + void loggerGroups() throws Exception { + this.mockMvc.perform(get("/actuator/loggers/test")).andExpect(status().isOk()) + .andDo(MockMvcRestDocumentation.document("loggers/group", responseFields(groupLevelFields))); + } + @Test void setLogLevel() throws Exception { this.mockMvc @@ -89,6 +113,26 @@ class LoggersEndpointDocumentationTests extends MockMvcEndpointDocumentationTest verify(this.loggingSystem).setLogLevel("com.example", LogLevel.DEBUG); } + @Test + void setLogLevelOfLoggerGroup() throws Exception { + this.mockMvc + .perform(post("/actuator/loggers/test") + .content("{\"configuredLevel\":\"debug\"}").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()).andDo( + MockMvcRestDocumentation.document("loggers/setGroup", + requestFields(fieldWithPath("configuredLevel").description( + "Level for the logger group. May be omitted to clear the level of the loggers.") + .optional()))); + verify(this.loggingSystem).setLogLevel("test.member1", LogLevel.DEBUG); + verify(this.loggingSystem).setLogLevel("test.member2", LogLevel.DEBUG); + resetLogger(); + } + + private void resetLogger() { + this.loggerGroups.get("test").configureLogLevel(null, (a, b) -> { + }); + } + @Test void clearLogLevel() throws Exception { this.mockMvc @@ -102,8 +146,13 @@ class LoggersEndpointDocumentationTests extends MockMvcEndpointDocumentationTest static class TestConfiguration { @Bean - LoggersEndpoint endpoint(LoggingSystem loggingSystem) { - return new LoggersEndpoint(loggingSystem); + LoggersEndpoint endpoint(LoggingSystem loggingSystem, LoggerGroups groups) { + groups.putAll(getLoggerGroups()); + return new LoggersEndpoint(loggingSystem, groups); + } + + private Map> getLoggerGroups() { + return Collections.singletonMap("test", Arrays.asList("test.member1", "test.member2")); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/logging/LoggersEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/logging/LoggersEndpoint.java index ac575565768..565fa076c5d 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/logging/LoggersEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/logging/LoggersEndpoint.java @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.logging; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.NavigableSet; import java.util.Set; @@ -30,6 +31,8 @@ import org.springframework.boot.actuate.endpoint.annotation.Selector; import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; +import org.springframework.boot.logging.LoggerGroup; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingSystem; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -39,6 +42,7 @@ import org.springframework.util.Assert; * * @author Ben Hale * @author Phillip Webb + * @author HaiTao Zhang * @since 2.0.0 */ @Endpoint(id = "loggers") @@ -46,13 +50,18 @@ public class LoggersEndpoint { private final LoggingSystem loggingSystem; + private final LoggerGroups loggerGroups; + /** * Create a new {@link LoggersEndpoint} instance. * @param loggingSystem the logging system to expose + * @param loggerGroups the logger group to expose */ - public LoggersEndpoint(LoggingSystem loggingSystem) { + public LoggersEndpoint(LoggingSystem loggingSystem, LoggerGroups loggerGroups) { Assert.notNull(loggingSystem, "LoggingSystem must not be null"); + Assert.notNull(loggerGroups, "LoggerGroups must not be null"); this.loggingSystem = loggingSystem; + this.loggerGroups = loggerGroups; } @ReadOperation @@ -64,19 +73,36 @@ public class LoggersEndpoint { Map result = new LinkedHashMap<>(); result.put("levels", getLevels()); result.put("loggers", getLoggers(configurations)); + result.put("groups", getGroups()); return result; } + private Map getGroups() { + Map groups = new LinkedHashMap<>(); + this.loggerGroups.forEach((group) -> groups.put(group.getName(), + new GroupLoggerLevels(group.getConfiguredLevel(), group.getMembers()))); + return groups; + } + @ReadOperation public LoggerLevels loggerLevels(@Selector String name) { Assert.notNull(name, "Name must not be null"); + LoggerGroup group = this.loggerGroups.get(name); + if (group != null) { + return new GroupLoggerLevels(group.getConfiguredLevel(), group.getMembers()); + } LoggerConfiguration configuration = this.loggingSystem.getLoggerConfiguration(name); - return (configuration != null) ? new LoggerLevels(configuration) : null; + return (configuration != null) ? new SingleLoggerLevels(configuration) : null; } @WriteOperation public void configureLogLevel(@Selector String name, @Nullable LogLevel configuredLevel) { Assert.notNull(name, "Name must not be empty"); + LoggerGroup group = this.loggerGroups.get(name); + if (group != null && group.hasMembers()) { + group.configureLogLevel(configuredLevel, this.loggingSystem::setLogLevel); + return; + } this.loggingSystem.setLogLevel(name, configuredLevel); } @@ -88,7 +114,7 @@ public class LoggersEndpoint { private Map getLoggers(Collection configurations) { Map loggers = new LinkedHashMap<>(configurations.size()); for (LoggerConfiguration configuration : configurations) { - loggers.put(configuration.getName(), new LoggerLevels(configuration)); + loggers.put(configuration.getName(), new SingleLoggerLevels(configuration)); } return loggers; } @@ -100,14 +126,11 @@ public class LoggersEndpoint { private String configuredLevel; - private String effectiveLevel; - - public LoggerLevels(LoggerConfiguration configuration) { - this.configuredLevel = getName(configuration.getConfiguredLevel()); - this.effectiveLevel = getName(configuration.getEffectiveLevel()); + public LoggerLevels(LogLevel configuredLevel) { + this.configuredLevel = getName(configuredLevel); } - private String getName(LogLevel level) { + protected final String getName(LogLevel level) { return (level != null) ? level.name() : null; } @@ -115,6 +138,32 @@ public class LoggersEndpoint { return this.configuredLevel; } + } + + public static class GroupLoggerLevels extends LoggerLevels { + + private List members; + + public GroupLoggerLevels(LogLevel configuredLevel, List members) { + super(configuredLevel); + this.members = members; + } + + public List getMembers() { + return this.members; + } + + } + + public static class SingleLoggerLevels extends LoggerLevels { + + private String effectiveLevel; + + public SingleLoggerLevels(LoggerConfiguration configuration) { + super(configuration.getConfiguredLevel()); + this.effectiveLevel = getName(configuration.getEffectiveLevel()); + } + public String getEffectiveLevel() { return this.effectiveLevel; } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointTests.java index a83034508e9..7ced35e133d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointTests.java @@ -18,14 +18,19 @@ package org.springframework.boot.actuate.logging; import java.util.Collections; import java.util.EnumSet; +import java.util.List; import java.util.Map; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.logging.LoggersEndpoint.GroupLoggerLevels; import org.springframework.boot.actuate.logging.LoggersEndpoint.LoggerLevels; +import org.springframework.boot.actuate.logging.LoggersEndpoint.SingleLoggerLevels; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingSystem; import static org.assertj.core.api.Assertions.assertThat; @@ -38,46 +43,102 @@ import static org.mockito.Mockito.verify; * * @author Ben Hale * @author Andy Wilkinson + * @author HaiTao Zhang + * @author Madhura Bhave */ class LoggersEndpointTests { private final LoggingSystem loggingSystem = mock(LoggingSystem.class); + private LoggerGroups loggerGroups; + + @BeforeEach + void setup() { + Map> groups = Collections.singletonMap("test", Collections.singletonList("test.member")); + this.loggerGroups = new LoggerGroups(groups); + this.loggerGroups.get("test").configureLogLevel(LogLevel.DEBUG, (a, b) -> { + }); + } + @Test @SuppressWarnings("unchecked") - void loggersShouldReturnLoggerConfigurations() { + void loggersShouldReturnLoggerConfigurationsWithNoLoggerGroups() { given(this.loggingSystem.getLoggerConfigurations()) .willReturn(Collections.singletonList(new LoggerConfiguration("ROOT", null, LogLevel.DEBUG))); given(this.loggingSystem.getSupportedLogLevels()).willReturn(EnumSet.allOf(LogLevel.class)); - Map result = new LoggersEndpoint(this.loggingSystem).loggers(); + Map result = new LoggersEndpoint(this.loggingSystem, new LoggerGroups()).loggers(); Map loggers = (Map) result.get("loggers"); Set levels = (Set) result.get("levels"); - LoggerLevels rootLevels = loggers.get("ROOT"); + SingleLoggerLevels rootLevels = (SingleLoggerLevels) loggers.get("ROOT"); assertThat(rootLevels.getConfiguredLevel()).isNull(); assertThat(rootLevels.getEffectiveLevel()).isEqualTo("DEBUG"); assertThat(levels).containsExactly(LogLevel.OFF, LogLevel.FATAL, LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE); + Map groups = (Map) result.get("groups"); + assertThat(groups).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void loggersShouldReturnLoggerConfigurationsWithLoggerGroups() { + given(this.loggingSystem.getLoggerConfigurations()) + .willReturn(Collections.singletonList(new LoggerConfiguration("ROOT", null, LogLevel.DEBUG))); + given(this.loggingSystem.getSupportedLogLevels()).willReturn(EnumSet.allOf(LogLevel.class)); + Map result = new LoggersEndpoint(this.loggingSystem, this.loggerGroups).loggers(); + Map loggerGroups = (Map) result.get("groups"); + GroupLoggerLevels groupLevel = loggerGroups.get("test"); + Map loggers = (Map) result.get("loggers"); + Set levels = (Set) result.get("levels"); + SingleLoggerLevels rootLevels = (SingleLoggerLevels) loggers.get("ROOT"); + assertThat(rootLevels.getConfiguredLevel()).isNull(); + assertThat(rootLevels.getEffectiveLevel()).isEqualTo("DEBUG"); + assertThat(levels).containsExactly(LogLevel.OFF, LogLevel.FATAL, LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, + LogLevel.DEBUG, LogLevel.TRACE); + assertThat(loggerGroups).isNotNull(); + assertThat(groupLevel.getConfiguredLevel()).isEqualTo("DEBUG"); + assertThat(groupLevel.getMembers()).containsExactly("test.member"); } @Test void loggerLevelsWhenNameSpecifiedShouldReturnLevels() { given(this.loggingSystem.getLoggerConfiguration("ROOT")) .willReturn(new LoggerConfiguration("ROOT", null, LogLevel.DEBUG)); - LoggerLevels levels = new LoggersEndpoint(this.loggingSystem).loggerLevels("ROOT"); + SingleLoggerLevels levels = (SingleLoggerLevels) new LoggersEndpoint(this.loggingSystem, this.loggerGroups) + .loggerLevels("ROOT"); assertThat(levels.getConfiguredLevel()).isNull(); assertThat(levels.getEffectiveLevel()).isEqualTo("DEBUG"); } + @Test + void groupNameSpecifiedShouldReturnConfiguredLevelAndMembers() { + GroupLoggerLevels levels = (GroupLoggerLevels) new LoggersEndpoint(this.loggingSystem, this.loggerGroups) + .loggerLevels("test"); + assertThat(levels.getConfiguredLevel()).isEqualTo("DEBUG"); + assertThat(levels.getMembers()).isEqualTo(Collections.singletonList("test.member")); + } + @Test void configureLogLevelShouldSetLevelOnLoggingSystem() { - new LoggersEndpoint(this.loggingSystem).configureLogLevel("ROOT", LogLevel.DEBUG); + new LoggersEndpoint(this.loggingSystem, this.loggerGroups).configureLogLevel("ROOT", LogLevel.DEBUG); verify(this.loggingSystem).setLogLevel("ROOT", LogLevel.DEBUG); } @Test void configureLogLevelWithNullSetsLevelOnLoggingSystemToNull() { - new LoggersEndpoint(this.loggingSystem).configureLogLevel("ROOT", null); + new LoggersEndpoint(this.loggingSystem, this.loggerGroups).configureLogLevel("ROOT", null); verify(this.loggingSystem).setLogLevel("ROOT", null); } + @Test + void configureLogLevelInLoggerGroupShouldSetLevelOnLoggingSystem() { + new LoggersEndpoint(this.loggingSystem, this.loggerGroups).configureLogLevel("test", LogLevel.DEBUG); + verify(this.loggingSystem).setLogLevel("test.member", LogLevel.DEBUG); + } + + @Test + void configureLogLevelWithNullInLoggerGroupShouldSetLevelOnLoggingSystem() { + new LoggersEndpoint(this.loggingSystem, this.loggerGroups).configureLogLevel("test", null); + verify(this.loggingSystem).setLogLevel("test.member", null); + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointWebIntegrationTests.java index 97de0498354..a3b857c6d3b 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/logging/LoggersEndpointWebIntegrationTests.java @@ -19,16 +19,22 @@ package org.springframework.boot.actuate.logging; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import net.minidev.json.JSONArray; +import org.hamcrest.collection.IsIterableContainingInAnyOrder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.mockito.Mockito; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingSystem; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; @@ -50,6 +56,8 @@ import static org.mockito.Mockito.verifyZeroInteractions; * @author EddĂș MelĂ©ndez * @author Stephane Nicoll * @author Andy Wilkinson + * @author HaiTao Zhang + * @author Madhura Bhave */ class LoggersEndpointWebIntegrationTests { @@ -57,29 +65,35 @@ class LoggersEndpointWebIntegrationTests { private LoggingSystem loggingSystem; + private LoggerGroups loggerGroups; + @BeforeEach @AfterEach void resetMocks(ConfigurableApplicationContext context, WebTestClient client) { this.client = client; this.loggingSystem = context.getBean(LoggingSystem.class); + this.loggerGroups = context.getBean(LoggerGroups.class); Mockito.reset(this.loggingSystem); given(this.loggingSystem.getSupportedLogLevels()).willReturn(EnumSet.allOf(LogLevel.class)); } @WebEndpointTest - void getLoggerShouldReturnAllLoggerConfigurations() { + void getLoggerShouldReturnAllLoggerConfigurationsWithLoggerGroups() { + setLogLevelToDebug("test"); given(this.loggingSystem.getLoggerConfigurations()) .willReturn(Collections.singletonList(new LoggerConfiguration("ROOT", null, LogLevel.DEBUG))); this.client.get().uri("/actuator/loggers").exchange().expectStatus().isOk().expectBody().jsonPath("$.length()") - .isEqualTo(2).jsonPath("levels") + .isEqualTo(3).jsonPath("levels") .isEqualTo(jsonArrayOf("OFF", "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE")) .jsonPath("loggers.length()").isEqualTo(1).jsonPath("loggers.ROOT.length()").isEqualTo(2) .jsonPath("loggers.ROOT.configuredLevel").isEqualTo(null).jsonPath("loggers.ROOT.effectiveLevel") + .isEqualTo("DEBUG").jsonPath("groups.length()").isEqualTo(2).jsonPath("groups.test.configuredLevel") .isEqualTo("DEBUG"); } @WebEndpointTest void getLoggerShouldReturnLogLevels() { + setLogLevelToDebug("test"); given(this.loggingSystem.getLoggerConfiguration("ROOT")) .willReturn(new LoggerConfiguration("ROOT", null, LogLevel.DEBUG)); this.client.get().uri("/actuator/loggers/ROOT").exchange().expectStatus().isOk().expectBody() @@ -88,10 +102,19 @@ class LoggersEndpointWebIntegrationTests { } @WebEndpointTest - void getLoggersWhenLoggerNotFoundShouldReturnNotFound() { + void getLoggersWhenLoggerAndLoggerGroupNotFoundShouldReturnNotFound() { this.client.get().uri("/actuator/loggers/com.does.not.exist").exchange().expectStatus().isNotFound(); } + @WebEndpointTest + void getLoggerGroupShouldReturnConfiguredLogLevelAndMembers() { + setLogLevelToDebug("test"); + this.client.get().uri("actuator/loggers/test").exchange().expectStatus().isOk().expectBody() + .jsonPath("$.length()").isEqualTo(2).jsonPath("members") + .value(IsIterableContainingInAnyOrder.containsInAnyOrder("test.member1", "test.member2")) + .jsonPath("configuredLevel").isEqualTo("DEBUG"); + } + @WebEndpointTest void setLoggerUsingApplicationJsonShouldSetLogLevel() { this.client.post().uri("/actuator/loggers/ROOT").contentType(MediaType.APPLICATION_JSON) @@ -108,7 +131,24 @@ class LoggersEndpointWebIntegrationTests { } @WebEndpointTest - void setLoggerWithWrongLogLevelResultInBadRequestResponse() { + void setLoggerGroupUsingActuatorV2JsonShouldSetLogLevel() { + this.client.post().uri("/actuator/loggers/test") + .contentType(MediaType.parseMediaType(ActuatorMediaType.V2_JSON)) + .body(Collections.singletonMap("configuredLevel", "debug")).exchange().expectStatus().isNoContent(); + verify(this.loggingSystem).setLogLevel("test.member1", LogLevel.DEBUG); + verify(this.loggingSystem).setLogLevel("test.member2", LogLevel.DEBUG); + } + + @WebEndpointTest + void setLoggerGroupUsingApplicationJsonShouldSetLogLevel() { + this.client.post().uri("/actuator/loggers/test").contentType(MediaType.APPLICATION_JSON) + .body(Collections.singletonMap("configuredLevel", "debug")).exchange().expectStatus().isNoContent(); + verify(this.loggingSystem).setLogLevel("test.member1", LogLevel.DEBUG); + verify(this.loggingSystem).setLogLevel("test.member2", LogLevel.DEBUG); + } + + @WebEndpointTest + void setLoggerOrLoggerGroupWithWrongLogLevelResultInBadRequestResponse() { this.client.post().uri("/actuator/loggers/ROOT").contentType(MediaType.APPLICATION_JSON) .body(Collections.singletonMap("configuredLevel", "other")).exchange().expectStatus().isBadRequest(); verifyZeroInteractions(this.loggingSystem); @@ -130,6 +170,24 @@ class LoggersEndpointWebIntegrationTests { verify(this.loggingSystem).setLogLevel("ROOT", null); } + @WebEndpointTest + void setLoggerGroupWithNullLogLevel() { + this.client.post().uri("/actuator/loggers/test") + .contentType(MediaType.parseMediaType(ActuatorMediaType.V2_JSON)) + .body(Collections.singletonMap("configuredLevel", null)).exchange().expectStatus().isNoContent(); + verify(this.loggingSystem).setLogLevel("test.member1", null); + verify(this.loggingSystem).setLogLevel("test.member2", null); + } + + @WebEndpointTest + void setLoggerGroupWithNoLogLevel() { + this.client.post().uri("/actuator/loggers/test") + .contentType(MediaType.parseMediaType(ActuatorMediaType.V2_JSON)).body(Collections.emptyMap()) + .exchange().expectStatus().isNoContent(); + verify(this.loggingSystem).setLogLevel("test.member1", null); + verify(this.loggingSystem).setLogLevel("test.member2", null); + } + @WebEndpointTest void logLevelForLoggerWithNameThatCouldBeMistakenForAPathExtension() { given(this.loggingSystem.getLoggerConfiguration("com.png")) @@ -139,6 +197,19 @@ class LoggersEndpointWebIntegrationTests { .jsonPath("effectiveLevel").isEqualTo("DEBUG"); } + @WebEndpointTest + void logLevelForLoggerGroupWithNameThatCouldBeMistakenForAPathExtension() { + setLogLevelToDebug("group.png"); + this.client.get().uri("/actuator/loggers/group.png").exchange().expectStatus().isOk().expectBody() + .jsonPath("$.length()").isEqualTo(2).jsonPath("configuredLevel").isEqualTo("DEBUG").jsonPath("members") + .value(IsIterableContainingInAnyOrder.containsInAnyOrder("png.member1", "png.member2")); + } + + private void setLogLevelToDebug(String name) { + this.loggerGroups.get(name).configureLogLevel(LogLevel.DEBUG, (a, b) -> { + }); + } + private JSONArray jsonArrayOf(Object... entries) { JSONArray array = new JSONArray(); array.addAll(Arrays.asList(entries)); @@ -154,8 +225,21 @@ class LoggersEndpointWebIntegrationTests { } @Bean - LoggersEndpoint endpoint(LoggingSystem loggingSystem) { - return new LoggersEndpoint(loggingSystem); + LoggerGroups loggingGroups() { + return getLoggerGroups(); + } + + private LoggerGroups getLoggerGroups() { + Map> groups = new LinkedHashMap<>(); + groups.put("test", Arrays.asList("test.member1", "test.member2")); + groups.put("group.png", Arrays.asList("png.member1", "png.member2")); + return new LoggerGroups(groups); + } + + @Bean + LoggersEndpoint endpoint(LoggingSystem loggingSystem, + ObjectProvider loggingGroupsObjectProvider) { + return new LoggersEndpoint(loggingSystem, loggingGroupsObjectProvider.getIfAvailable()); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/logging/LoggingApplicationListener.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/logging/LoggingApplicationListener.java index f69202c8331..9742c9c90af 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/logging/LoggingApplicationListener.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/logging/LoggingApplicationListener.java @@ -17,10 +17,10 @@ package org.springframework.boot.context.logging; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -36,6 +36,8 @@ import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.logging.LoggerGroup; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingInitializationContext; import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.logging.LoggingSystemProperties; @@ -50,7 +52,6 @@ import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.util.ObjectUtils; import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; @@ -83,6 +84,7 @@ import org.springframework.util.StringUtils; * @author Phillip Webb * @author Andy Wilkinson * @author Madhura Bhave + * @author HaiTao Zhang * @since 2.0.0 * @see LoggingSystem#get(ClassLoader) */ @@ -95,8 +97,8 @@ public class LoggingApplicationListener implements GenericApplicationListener { private static final Bindable> STRING_LOGLEVEL_MAP = Bindable.mapOf(String.class, LogLevel.class); - private static final Bindable> STRING_STRINGS_MAP = Bindable.mapOf(String.class, - String[].class); + private static final Bindable>> STRING_STRINGS_MAP = Bindable + .of(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class).asMap()); /** * The default order for the LoggingApplicationListener. @@ -126,6 +128,12 @@ public class LoggingApplicationListener implements GenericApplicationListener { */ public static final String LOGFILE_BEAN_NAME = "springBootLogFile"; + /** + * The name of the{@link LoggerGroups} bean. + * @since 2.2.0 + */ + public static final String LOGGER_GROUPS_BEAN_NAME = "springBootLoggerGroups"; + private static final Map> DEFAULT_GROUP_LOGGERS; static { MultiValueMap loggers = new LinkedMultiValueMap<>(); @@ -140,7 +148,7 @@ public class LoggingApplicationListener implements GenericApplicationListener { DEFAULT_GROUP_LOGGERS = Collections.unmodifiableMap(loggers); } - private static final Map> LOG_LEVEL_LOGGERS; + private static final Map> SPRING_BOOT_LOGGING_LOGGERS; static { MultiValueMap loggers = new LinkedMultiValueMap<>(); loggers.add(LogLevel.DEBUG, "sql"); @@ -151,7 +159,7 @@ public class LoggingApplicationListener implements GenericApplicationListener { loggers.add(LogLevel.TRACE, "org.apache.catalina"); loggers.add(LogLevel.TRACE, "org.eclipse.jetty"); loggers.add(LogLevel.TRACE, "org.hibernate.tool.hbm2ddl"); - LOG_LEVEL_LOGGERS = Collections.unmodifiableMap(loggers); + SPRING_BOOT_LOGGING_LOGGERS = Collections.unmodifiableMap(loggers); } private static final Class[] EVENT_TYPES = { ApplicationStartingEvent.class, @@ -168,6 +176,8 @@ public class LoggingApplicationListener implements GenericApplicationListener { private LogFile logFile; + private LoggerGroups loggerGroups; + private int order = DEFAULT_ORDER; private boolean parseArgs = true; @@ -235,6 +245,9 @@ public class LoggingApplicationListener implements GenericApplicationListener { if (this.logFile != null && !beanFactory.containsBean(LOGFILE_BEAN_NAME)) { beanFactory.registerSingleton(LOGFILE_BEAN_NAME, this.logFile); } + if (this.loggerGroups != null && !beanFactory.containsBean(LOGGER_GROUPS_BEAN_NAME)) { + beanFactory.registerSingleton(LOGGER_GROUPS_BEAN_NAME, this.loggerGroups); + } } private void onContextClosedEvent() { @@ -261,6 +274,7 @@ public class LoggingApplicationListener implements GenericApplicationListener { if (this.logFile != null) { this.logFile.applyToSystemProperties(); } + this.loggerGroups = new LoggerGroups(DEFAULT_GROUP_LOGGERS); initializeEarlyLoggingLevel(environment); initializeSystem(environment, this.loggingSystem, this.logFile); initializeFinalLoggingLevels(environment, this.loggingSystem); @@ -308,65 +322,95 @@ public class LoggingApplicationListener implements GenericApplicationListener { } private void initializeFinalLoggingLevels(ConfigurableEnvironment environment, LoggingSystem system) { + bindLoggerGroups(environment); if (this.springBootLogging != null) { initializeLogLevel(system, this.springBootLogging); } setLogLevels(system, environment); } - protected void initializeLogLevel(LoggingSystem system, LogLevel level) { - LOG_LEVEL_LOGGERS.getOrDefault(level, Collections.emptyList()) - .forEach((logger) -> initializeLogLevel(system, level, logger)); + private void bindLoggerGroups(ConfigurableEnvironment environment) { + if (this.loggerGroups != null) { + Binder binder = Binder.get(environment); + binder.bind(LOGGING_GROUP, STRING_STRINGS_MAP).ifBound(this.loggerGroups::putAll); + } } - private void initializeLogLevel(LoggingSystem system, LogLevel level, String logger) { - List groupLoggers = DEFAULT_GROUP_LOGGERS.get(logger); - if (groupLoggers == null) { - system.setLogLevel(logger, level); - return; - } - groupLoggers.forEach((groupLogger) -> system.setLogLevel(groupLogger, level)); + /** + * Initialize loggers based on the {@link #setSpringBootLogging(LogLevel) + * springBootLogging} setting. + * @param system the logging system + * @param springBootLogging the spring boot logging level requested + * @deprecated since 2.2.0 in favor of + * {@link #initializeSpringBootLogging(LoggingSystem, LogLevel)} + */ + @Deprecated + protected void initializeLogLevel(LoggingSystem system, LogLevel springBootLogging) { + initializeSpringBootLogging(system, springBootLogging); + } + + /** + * Initialize loggers based on the {@link #setSpringBootLogging(LogLevel) + * springBootLogging} setting. By default this implementation will pick an appropriate + * set of loggers to configure based on the level. + * @param system the logging system + * @param springBootLogging the spring boot logging level requested + * @since 2.2.0 + */ + protected void initializeSpringBootLogging(LoggingSystem system, LogLevel springBootLogging) { + BiConsumer configurer = getLogLevelConfigurer(system); + SPRING_BOOT_LOGGING_LOGGERS.getOrDefault(springBootLogging, Collections.emptyList()) + .forEach((name) -> configureLogLevel(name, springBootLogging, configurer)); } + /** + * Set logging levels based on relevant {@link Environment} properties. + * @param system the logging system + * @param environment the environment + * @deprecated since 2.2.0 in favor of + * {@link #setLogLevels(LoggingSystem, ConfigurableEnvironment)} + */ + @Deprecated protected void setLogLevels(LoggingSystem system, Environment environment) { - if (!(environment instanceof ConfigurableEnvironment)) { - return; + if (environment instanceof ConfigurableEnvironment) { + setLogLevels(system, (ConfigurableEnvironment) environment); } - Binder binder = Binder.get(environment); - Map groups = getGroups(); - binder.bind(LOGGING_GROUP, STRING_STRINGS_MAP.withExistingValue(groups)); - Map levels = binder.bind(LOGGING_LEVEL, STRING_LOGLEVEL_MAP).orElseGet(Collections::emptyMap); - levels.forEach((name, level) -> { - String[] groupedNames = groups.get(name); - if (ObjectUtils.isEmpty(groupedNames)) { - setLogLevel(system, name, level); - } - else { - setLogLevel(system, groupedNames, level); - } - }); } - private Map getGroups() { - Map groups = new LinkedHashMap<>(); - DEFAULT_GROUP_LOGGERS.forEach((name, loggers) -> groups.put(name, StringUtils.toStringArray(loggers))); - return groups; + /** + * Set logging levels based on relevant {@link Environment} properties. + * @param system the logging system + * @param environment the environment + * @since 2.2.0 + */ + protected void setLogLevels(LoggingSystem system, ConfigurableEnvironment environment) { + BiConsumer customizer = getLogLevelConfigurer(system); + Binder binder = Binder.get(environment); + Map levels = binder.bind(LOGGING_LEVEL, STRING_LOGLEVEL_MAP).orElseGet(Collections::emptyMap); + levels.forEach((name, level) -> configureLogLevel(name, level, customizer)); } - private void setLogLevel(LoggingSystem system, String[] names, LogLevel level) { - for (String name : names) { - setLogLevel(system, name, level); + private void configureLogLevel(String name, LogLevel level, BiConsumer configurer) { + if (this.loggerGroups != null) { + LoggerGroup group = this.loggerGroups.get(name); + if (group != null && group.hasMembers()) { + group.configureLogLevel(level, configurer); + return; + } } + configurer.accept(name, level); } - private void setLogLevel(LoggingSystem system, String name, LogLevel level) { - try { - name = name.equalsIgnoreCase(LoggingSystem.ROOT_LOGGER_NAME) ? null : name; - system.setLogLevel(name, level); - } - catch (RuntimeException ex) { - this.logger.error("Cannot set level '" + level + "' for '" + name + "'"); - } + private BiConsumer getLogLevelConfigurer(LoggingSystem system) { + return (name, level) -> { + try { + name = name.equalsIgnoreCase(LoggingSystem.ROOT_LOGGER_NAME) ? null : name; + system.setLogLevel(name, level); + } + catch (RuntimeException ex) { + this.logger.error("Cannot set level '" + level + "' for '" + name + "'"); + } + }; } private void registerShutdownHookIfNecessary(Environment environment, LoggingSystem loggingSystem) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroup.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroup.java new file mode 100644 index 00000000000..a16cf20deac --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroup.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2019 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.logging; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.BiConsumer; + +/** + * A single logger group. + * + * @author Madhura Bhave + * @author Phillip Webb + * @since 2.2.0 + */ +public final class LoggerGroup { + + private final String name; + + private final List members; + + private LogLevel configuredLevel; + + LoggerGroup(String name, List members) { + this.name = name; + this.members = Collections.unmodifiableList(new ArrayList<>(members)); + } + + public String getName() { + return this.name; + } + + public List getMembers() { + return this.members; + } + + public boolean hasMembers() { + return !this.members.isEmpty(); + } + + public LogLevel getConfiguredLevel() { + return this.configuredLevel; + } + + public void configureLogLevel(LogLevel level, BiConsumer configurer) { + this.configuredLevel = level; + this.members.forEach((name) -> configurer.accept(name, level)); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroups.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroups.java new file mode 100644 index 00000000000..a0790c8ea2f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerGroups.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2019 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.logging; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Logger groups configured via the Spring Environment. + * + * @author HaiTao Zhang + * @author Phillip Webb + * @since 2.2.0 #see {@link LoggerGroup} + */ +public final class LoggerGroups implements Iterable { + + private final Map groups = new ConcurrentHashMap<>(); + + public LoggerGroups() { + } + + public LoggerGroups(Map> namesAndMembers) { + putAll(namesAndMembers); + } + + public void putAll(Map> namesAndMembers) { + namesAndMembers.forEach(this::put); + } + + private void put(String name, List members) { + put(new LoggerGroup(name, members)); + } + + private void put(LoggerGroup loggerGroup) { + this.groups.put(loggerGroup.getName(), loggerGroup); + } + + public LoggerGroup get(String name) { + return this.groups.get(name); + } + + @Override + public Iterator iterator() { + return this.groups.values().iterator(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java index 6c701aa49cb..603c599efba 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java @@ -50,6 +50,7 @@ import org.springframework.boot.logging.AbstractLoggingSystem; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; +import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingInitializationContext; import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.logging.LoggingSystemProperties; @@ -284,6 +285,8 @@ class LoggingApplicationListenerTests { this.loggerContext.getLogger("org.hibernate.SQL").debug("testdebugsqlgroup"); assertThat(this.output).contains("testdebugwebgroup"); assertThat(this.output).contains("testdebugsqlgroup"); + LoggerGroups loggerGroups = (LoggerGroups) ReflectionTestUtils.getField(this.initializer, "loggerGroups"); + assertThat(loggerGroups.get("web").getConfiguredLevel()).isEqualTo(LogLevel.DEBUG); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerGroupsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerGroupsTests.java new file mode 100644 index 00000000000..fb95f648257 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerGroupsTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2019 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.logging; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link LoggerGroups} + * + * @author HaiTao Zhang + * @author Madhura Bhave + */ +class LoggerGroupsTests { + + private LoggingSystem loggingSystem = mock(LoggingSystem.class); + + @Test + void putAllShouldAddLoggerGroups() { + Map> groups = Collections.singletonMap("test", + Arrays.asList("test.member", "test.member2")); + LoggerGroups loggerGroups = new LoggerGroups(); + loggerGroups.putAll(groups); + LoggerGroup group = loggerGroups.get("test"); + assertThat(group.getMembers()).containsExactly("test.member", "test.member2"); + } + + @Test + void iteratorShouldReturnLoggerGroups() { + LoggerGroups groups = createLoggerGroups(); + assertThat(groups).hasSize(3); + assertThat(groups).extracting("name").containsExactlyInAnyOrder("test0", "test1", "test2"); + } + + private LoggerGroups createLoggerGroups() { + Map> groups = new LinkedHashMap<>(); + groups.put("test0", Arrays.asList("test0.member", "test0.member2")); + groups.put("test1", Arrays.asList("test1.member", "test1.member2")); + groups.put("test2", Arrays.asList("test2.member", "test2.member2")); + return new LoggerGroups(groups); + } + +}