From b86091d97db3bb362ca3e6760e8c9c3cd6a4ff41 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 15:13:39 -0400 Subject: [PATCH 01/11] Add Rainbow Gum logging driven by the Jooby environment Add the optional jooby-rainbowgum module with native Typesafe Config properties, queued bootstrap logging, configured global replacement, and temporary server logger muting. A default no-op LoggingService initialization hook receives the actual lazy or programmatically supplied environment. Read the current environment config on level-change publications and document Jooby Run restarts, explicit rereading, startup behavior and configuration examples. Include the module in dependency management and the generated BOM. Draft pending a Rainbow Gum release with dynamic OFF and global router rebinding fixes. Validated on JDK 21 against the patched 1.0.0-SNAPSHOT: 1,311 Jooby core tests, 10 integration tests, clean verify, Javadoc, and a JPMS service-loading/bootstrap smoke test. Refs jooby-project/jooby#4021. Co-authored-by: Codex --- docs/asciidoc/configuration.adoc | 9 +- docs/asciidoc/modules/modules.adoc | 3 + docs/asciidoc/modules/rainbowgum.adoc | 73 ++++++ jooby/src/main/java/io/jooby/Jooby.java | 7 +- .../main/java/io/jooby/LoggingService.java | 24 +- .../java/io/jooby/LoggingServiceTest.java | 17 ++ modules/jooby-bom/pom.xml | 5 + modules/jooby-rainbowgum/pom.xml | 43 ++++ .../io/jooby/rainbowgum/ConfigProperties.java | 83 ++++++ .../jooby/rainbowgum/RainbowGumBootstrap.java | 35 +++ .../jooby/rainbowgum/RainbowGumService.java | 138 ++++++++++ .../io/jooby/rainbowgum/package-info.java | 3 + .../src/main/java/module-info.java | 25 ++ .../META-INF/services/io.jooby.LoggingService | 1 + ...h.rainbowgum.spi.RainbowGumServiceProvider | 1 + .../rainbowgum/RainbowGumServiceTest.java | 241 ++++++++++++++++++ modules/pom.xml | 1 + pom.xml | 7 + 18 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 docs/asciidoc/modules/rainbowgum.adoc create mode 100644 modules/jooby-rainbowgum/pom.xml create mode 100644 modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java create mode 100644 modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java create mode 100644 modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java create mode 100644 modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/package-info.java create mode 100644 modules/jooby-rainbowgum/src/main/java/module-info.java create mode 100644 modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jooby.LoggingService create mode 100644 modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jstach.rainbowgum.spi.RainbowGumServiceProvider create mode 100644 modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java diff --git a/docs/asciidoc/configuration.adoc b/docs/asciidoc/configuration.adoc index bf5c46989a..df878ecf91 100644 --- a/docs/asciidoc/configuration.adoc +++ b/docs/asciidoc/configuration.adoc @@ -157,13 +157,20 @@ Jooby uses **SLF4J**, allowing you to plug in your preferred logging framework. 1. **Add Dependencies:** `log4j-slf4j-impl` and `log4j-core`. 2. **Configure:** Place `log4j2.xml` in your `conf` directory or classpath root. +===== Rainbow Gum + +Add `jooby-rainbowgum` to configure logging directly in `application.conf`, including environment-specific +files. The module includes the SLF4J provider and pattern encoder and handles logging before the +application environment is available. See link:{uiVersion}/modules/rainbowgum[Rainbow Gum] for examples +and configuration updates. + ===== Environment-Aware Logging Logging is also environment-aware. Jooby will look for `logback.[env].xml` or `log4j2.[env].xml` and favor them over the default files. [IMPORTANT] ==== -To ensure environment-specific logging works correctly, avoid using **static** loggers in your main App class before `runApp` is called. Static loggers force the logging framework to initialize before Jooby can apply the environment-specific configuration. Use an instance logger or Jooby's `getLog()` method instead. +With Logback or Log4j2, to ensure environment-specific logging works correctly, avoid using **static** loggers in your main App class before `runApp` is called. Static loggers force the logging framework to initialize before Jooby can apply the environment-specific configuration. Use an instance logger or Jooby's `getLog()` method instead. ==== ==== Application Properties diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index e538064796..f6c1fb16bf 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -49,6 +49,9 @@ Modules are distributed as separate dependencies. Below is the catalog of offici * link:{uiVersion}/modules/jackson3[Jackson3]: Jackson3 module for Jooby. * link:{uiVersion}/modules/yasson[Yasson]: JSON-B module for Jooby. +==== Logging + * link:{uiVersion}/modules/rainbowgum[Rainbow Gum]: SLF4J logging configured through the application environment. + ==== Metrics * link:{uiVersion}/modules/metrics[Metrics]: Application metrics from the excellent metrics library. * link:{uiVersion}/modules/opentelemetry[Open Telemetry]: Application metrics using Open Telemetry library. diff --git a/docs/asciidoc/modules/rainbowgum.adoc b/docs/asciidoc/modules/rainbowgum.adoc new file mode 100644 index 0000000000..edc04e3c97 --- /dev/null +++ b/docs/asciidoc/modules/rainbowgum.adoc @@ -0,0 +1,73 @@ +== Rainbow Gum + +https://jstach.io/rainbowgum/[Rainbow Gum] is an SLF4J implementation for Java 21 and newer. +The `jooby-rainbowgum` module configures it directly from Jooby's resolved Typesafe Config environment. + +=== Usage + +Add the dependency: + +[dependency, artifactId="jooby-rainbowgum"] +. + +Use this module in place of `jooby-logback`, `jooby-log4j`, or another SLF4J provider. +No `install(...)` call or separate logging configuration file is needed. The module includes the +SLF4J provider, pattern encoder and file output support. + +.application.conf +[source, hocon] +---- +logging { + level { + root = info + "com.myapp" = debug + } + appenders = [console] + appender.console { + output.uri = stdout + encoder { + uri = pattern + pattern.pattern = "[%thread] %-5level %logger - %msg%n" + } + } +} +---- + +All Rainbow Gum properties are available, including appenders, encoders, routes and asynchronous +publishers. Native HOCON lists and maps are supported. Quote dotted logger names so they remain +one object member. Use `logging.level.root` for the root level when defining other logger levels +in the same object; HOCON cannot keep `logging.level` as both a scalar and an object. + +Jooby resolves `application.conf`, environment-specific files such as `application.prod.conf`, +substitutions and system properties before passing the environment to Rainbow Gum. Programmatic +environments supplied through `setEnvironment(...)` and `setEnvironmentOptions(...)` also configure +logging. Logging is global to the JVM, as with Jooby's other logging implementations. + +=== Early logging + +A bootstrap Rainbow Gum queues messages emitted before Jooby loads its environment, including messages +from static loggers. When the environment is available, the module installs the configured Rainbow +Gum, replays queued messages through its routes, and updates existing SLF4J loggers. It then closes +the previous instance. An explicitly installed Rainbow Gum is left under the application's control. + +=== Configuration changes + +Jooby core does not watch configuration files or publish configuration-change events. Jooby Run's +development watcher restarts the application; the new environment configures Rainbow Gum again. + +If the application replaces the configuration of an existing environment, it can reread logger levels +without reopening its appenders: + +[source, java] +---- +getEnvironment().setConfig(updatedConfig); +RainbowGum.of().config().changePublisher().publish(); +---- + +Existing loggers follow the new levels. The module enables level changes by default, also allowing +Jooby to temporarily mute server loggers during startup and shutdown. Rainbow Gum's +`logging.change.mode` and `logging.change.logger` properties can restrict these changes; doing so +also restricts temporary muting. Replacing the Jooby environment with `setEnvironment(...)` rebuilds +the complete logging configuration, including outputs and encoders. + +See the https://jstach.io/rainbowgum/[Rainbow Gum guide] for the complete configuration reference. diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index 77d758880d..6437386796 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -151,7 +151,7 @@ public Jooby setRouterOptions(RouterOptions options) { */ public Environment getEnvironment() { if (env == null) { - env = Environment.loadEnvironment(environmentOptions); + setEnvironment(Environment.loadEnvironment(environmentOptions)); } return env; } @@ -213,6 +213,7 @@ public Config getConfig() { */ public Jooby setEnvironment(Environment environment) { this.env = environment; + LoggingService.configure(environment); return this; } @@ -224,9 +225,9 @@ public Jooby setEnvironment(Environment environment) { */ public Environment setEnvironmentOptions(EnvironmentOptions options) { this.environmentOptions = options; - this.env = + setEnvironment( Environment.loadEnvironment( - options.setClassLoader(options.getClassLoader(getClass().getClassLoader()))); + options.setClassLoader(options.getClassLoader(getClass().getClassLoader())))); return this.env; } diff --git a/jooby/src/main/java/io/jooby/LoggingService.java b/jooby/src/main/java/io/jooby/LoggingService.java index 727cceaec0..5a57e46e45 100644 --- a/jooby/src/main/java/io/jooby/LoggingService.java +++ b/jooby/src/main/java/io/jooby/LoggingService.java @@ -18,8 +18,8 @@ import org.jspecify.annotations.Nullable; /** - * Describe the underlying logging system. Jooby provides two implementation: jooby-logback and - * jooby-log4j. You might want to add one of these to your classpath, yet still these are optional. + * Describe the underlying logging system. Jooby provides jooby-logback, jooby-log4j and + * jooby-rainbowgum. These integrations are optional; add one to your classpath. * * @since 3.0.1 */ @@ -32,6 +32,26 @@ public interface LoggingService { */ String getPropertyName(); + /** + * Initializes logging using the application's resolved environment. Called when Jooby loads or + * replaces its environment, including environments supplied programmatically. The default does + * nothing, for logging systems configured by their own files. + * + * @param environment Application environment. + */ + default void initialize(Environment environment) {} + + /** + * Initializes the installed logging service using the application's resolved environment. + * + * @param environment Application environment. + */ + static void configure(Environment environment) { + ServiceLoader.load(LoggingService.class, environment.getClassLoader()) + .findFirst() + .ifPresent(service -> service.initialize(environment)); + } + /** * List of possible names for configuration file. Like: logback.xml, log4j.properties, etc.. * . diff --git a/jooby/src/test/java/io/jooby/LoggingServiceTest.java b/jooby/src/test/java/io/jooby/LoggingServiceTest.java index cfa155f6d0..6538a83f64 100644 --- a/jooby/src/test/java/io/jooby/LoggingServiceTest.java +++ b/jooby/src/test/java/io/jooby/LoggingServiceTest.java @@ -58,6 +58,23 @@ private void restore(String key, String value) { } } + @Test + public void configureResolvedEnvironment() { + var environment = + new Environment( + getClass().getClassLoader(), com.typesafe.config.ConfigFactory.empty(), "test"); + try (MockedStatic serviceLoaderMock = mockStatic(ServiceLoader.class)) { + ServiceLoader loader = mock(ServiceLoader.class); + LoggingService service = mock(LoggingService.class); + when(loader.findFirst()).thenReturn(Optional.of(service)); + serviceLoaderMock + .when(() -> ServiceLoader.load(LoggingService.class, environment.getClassLoader())) + .thenReturn(loader); + LoggingService.configure(environment); + verify(service).initialize(environment); + } + } + @Test public void isBinaryPath() { assertFalse(LoggingService.isBinary(Paths.get("src", "main", "java"))); diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 600911803b..0c482e0967 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -300,6 +300,11 @@ jooby-quartz ${project.version} + + io.jooby + jooby-rainbowgum + ${project.version} + io.jooby jooby-reactor diff --git a/modules/jooby-rainbowgum/pom.xml b/modules/jooby-rainbowgum/pom.xml new file mode 100644 index 0000000000..e1e44946d7 --- /dev/null +++ b/modules/jooby-rainbowgum/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + io.jooby + modules + 4.5.5-SNAPSHOT + + jooby-rainbowgum + jooby-rainbowgum + + + io.jooby + jooby + + + io.jstach.rainbowgum + rainbowgum-slf4j + ${rainbowgum.version} + + + io.jstach.rainbowgum + rainbowgum-pattern + ${rainbowgum.version} + + + io.jstach.rainbowgum + rainbowgum-file + ${rainbowgum.version} + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.jacoco + org.jacoco.agent + runtime + test + + + diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java new file mode 100644 index 0000000000..79a7212f41 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java @@ -0,0 +1,83 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.rainbowgum; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigList; +import com.typesafe.config.ConfigObject; +import com.typesafe.config.ConfigValue; +import io.jstach.rainbowgum.LogProperties; + +/** Reads native HOCON objects, including quoted logger names, lists and maps. */ +final class ConfigProperties extends LogProperties.AbstractNested { + private final Supplier config; + + ConfigProperties(Supplier config) { + this.config = config; + } + + @Override + protected ConfigValue doRootNodeOrNull() { + return config.get().root(); + } + + @Override + protected @Nullable ConfigValue doMemberNodeOrNull(ConfigValue node, String name) { + return node instanceof ConfigObject object ? object.get(name) : null; + } + + @Override + protected @Nullable String doValueNodeOrNull(ConfigValue node) { + return switch (node.valueType()) { + case STRING, NUMBER, BOOLEAN -> String.valueOf(node.unwrapped()); + default -> null; + }; + } + + @Override + protected @Nullable List doListNodeOrNull(ConfigValue node) { + if (!(node instanceof ConfigList list)) { + return null; + } + return list.stream().map(this::scalar).toList(); + } + + @Override + protected @Nullable Map doMapNodeOrNull(ConfigValue node) { + if (!(node instanceof ConfigObject object)) { + return null; + } + Map result = new LinkedHashMap<>(); + object.forEach((key, value) -> result.put(key, scalar(value))); + return result; + } + + private String scalar(ConfigValue value) { + var scalar = doValueNodeOrNull(value); + if (scalar == null) { + throw new IllegalArgumentException( + "Expected a scalar logging value at " + value.origin().description()); + } + return scalar; + } + + @Override + public int order() { + return 300; + } + + @Override + public String description(String key) { + return "Jooby configuration[" + key + "]"; + } +} diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java new file mode 100644 index 0000000000..19f4afd3d7 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.rainbowgum; + +import java.util.List; +import java.util.Optional; + +import io.jstach.rainbowgum.LogAlerts; +import io.jstach.rainbowgum.LogConfig; +import io.jstach.rainbowgum.LogProperties; +import io.jstach.rainbowgum.RainbowGum; +import io.jstach.rainbowgum.ServiceRegistry; +import io.jstach.rainbowgum.spi.RainbowGumServiceProvider; + +/** Queues logging before Jooby's application environment is available. */ +public final class RainbowGumBootstrap + implements RainbowGumServiceProvider.RainbowGumProvider, + RainbowGumServiceProvider.PropertiesProvider { + /** For service loading. */ + public RainbowGumBootstrap() {} + + @Override + public List provideProperties(ServiceRegistry registry, LogAlerts alerts) { + RainbowGumService.register(registry); + return List.of(RainbowGumService.DEFAULTS); + } + + @Override + public Optional provide(LogConfig config) { + return Optional.of(RainbowGum.queued(config)); + } +} diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java new file mode 100644 index 0000000000..aacc0541b4 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java @@ -0,0 +1,138 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.rainbowgum; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; + +import io.jooby.Environment; +import io.jooby.LoggingService; +import io.jooby.SneakyThrows; +import io.jstach.rainbowgum.LevelResolver; +import io.jstach.rainbowgum.LogConfig; +import io.jstach.rainbowgum.LogProperties; +import io.jstach.rainbowgum.RainbowGum; +import io.jstach.rainbowgum.ServiceRegistry; +import io.jstach.rainbowgum.spi.RainbowGumServiceProvider; + +/** Configures Rainbow Gum from Jooby's resolved application configuration. */ +public final class RainbowGumService implements LoggingService { + private static final Object LOCK = new Object(); + private static final Map MUTED = new HashMap<>(); + private static final LogProperties.MutableLogProperties LEVELS = + LogProperties.MutableLogProperties.builder() + .with(new ConcurrentHashMap<>()) + .description("Jooby temporary logger levels") + .build(); + + static final LogProperties DEFAULTS = + LogProperties.builder() + .fromProperties("logging.change.mode=on\nlogging.change.logger=level") + .description("Jooby logging defaults") + .order(-1000) + .build(); + + enum Owner { + INSTANCE + } + + /** For service loading. */ + public RainbowGumService() {} + + static void register(ServiceRegistry registry) { + registry.put(Owner.class, Owner.INSTANCE); + registry.put(LogProperties.class, LevelResolver.OVERRIDE_PROPERTIES_NAME, LEVELS); + } + + @Override + public void initialize(Environment environment) { + synchronized (LOCK) { + var previous = RainbowGum.getOrNull(); + if (previous != null && previous.config().serviceRegistry().findOrNull(Owner.class) == null) { + // An explicitly installed Rainbow Gum remains under the application's control. + return; + } + var registry = ServiceRegistry.of(); + register(registry); + var properties = + LogProperties.of( + List.of( + LogProperties.StandardProperties.SYSTEM_PROPERTIES, + new ConfigProperties(environment::getConfig), + DEFAULTS)); + var config = + LogConfig.builder() + .properties(properties) + .serviceRegistry(registry) + .serviceLoader( + ServiceLoader.load(RainbowGumServiceProvider.class, environment.getClassLoader())) + .build(); + // Validate and construct before replacing the working global instance. + var gum = RainbowGum.builder(config).build(); + RainbowGum.set(gum).get(); + if (previous != null) { + previous.close(); + } + } + } + + @Override + public String getPropertyName() { + return "logging.config"; + } + + @Override + public List getLogFileName() { + // Logging is part of the application environment, not a separately discovered file. + return List.of(); + } + + @Override + public void logOff(List loggers, SneakyThrows.Runnable task) { + var names = loggers.stream().map(RainbowGumService::levelKey).distinct().toList(); + synchronized (LOCK) { + for (var name : names) { + MUTED.merge(name, 1, Integer::sum); + LEVELS.put(name, "off"); + } + } + try { + synchronized (LOCK) { + publish(); + } + task.run(); + } finally { + synchronized (LOCK) { + for (var name : names) { + int remaining = MUTED.get(name) - 1; + if (remaining == 0) { + MUTED.remove(name); + LEVELS.put(name, null); + } else { + MUTED.put(name, remaining); + } + } + publish(); + } + } + } + + private static String levelKey(String name) { + return name.isEmpty() || name.equals("ROOT") + ? LogProperties.LEVEL_PREFIX + : LogProperties.LEVEL_PREFIX + "." + name; + } + + private static void publish() { + var gum = RainbowGum.getOrNull(); + if (gum != null) { + gum.config().changePublisher().publish(); + } + } +} diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/package-info.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/package-info.java new file mode 100644 index 0000000000..9f34a531a6 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/package-info.java @@ -0,0 +1,3 @@ +/** Rainbow Gum logging configured through the Jooby application environment. */ +@org.jspecify.annotations.NullMarked +package io.jooby.rainbowgum; diff --git a/modules/jooby-rainbowgum/src/main/java/module-info.java b/modules/jooby-rainbowgum/src/main/java/module-info.java new file mode 100644 index 0000000000..01727321a6 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/java/module-info.java @@ -0,0 +1,25 @@ +import io.jooby.LoggingService; +import io.jooby.rainbowgum.RainbowGumBootstrap; +import io.jooby.rainbowgum.RainbowGumService; +import io.jstach.rainbowgum.spi.RainbowGumServiceProvider; + +/** Rainbow Gum logging system. */ +module io.jooby.rainbowgum { + exports io.jooby.rainbowgum; + + requires io.jooby; + requires static org.jspecify; + requires org.slf4j; + requires typesafe.config; + requires io.jstach.rainbowgum; + requires io.jstach.rainbowgum.slf4j; + requires io.jstach.rainbowgum.pattern; + requires io.jstach.rainbowgum.file; + + uses RainbowGumServiceProvider; + + provides LoggingService with + RainbowGumService; + provides RainbowGumServiceProvider with + RainbowGumBootstrap; +} diff --git a/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jooby.LoggingService b/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jooby.LoggingService new file mode 100644 index 0000000000..db4cf4e74b --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jooby.LoggingService @@ -0,0 +1 @@ +io.jooby.rainbowgum.RainbowGumService diff --git a/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jstach.rainbowgum.spi.RainbowGumServiceProvider b/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jstach.rainbowgum.spi.RainbowGumServiceProvider new file mode 100644 index 0000000000..c82eba5097 --- /dev/null +++ b/modules/jooby-rainbowgum/src/main/resources/META-INF/services/io.jstach.rainbowgum.spi.RainbowGumServiceProvider @@ -0,0 +1 @@ +io.jooby.rainbowgum.RainbowGumBootstrap diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java new file mode 100644 index 0000000000..5381937278 --- /dev/null +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -0,0 +1,241 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.rainbowgum; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.System.Logger.Level; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; +import io.jooby.Environment; +import io.jooby.EnvironmentOptions; +import io.jooby.Jooby; +import io.jooby.LoggingService; +import io.jstach.rainbowgum.LogConfig; +import io.jstach.rainbowgum.LogRouter; +import io.jstach.rainbowgum.RainbowGum; + +class RainbowGumServiceTest { + @TempDir Path directory; + + @BeforeEach + @AfterEach + void closeLogging() { + var gum = RainbowGum.getOrNull(); + if (gum != null) { + gum.close(); + } + } + + private Environment environment(Config config) { + return new Environment(getClass().getClassLoader(), config, "test"); + } + + private Config configuration(String root) { + return ConfigFactory.parseString( + """ + logging.level.root = %s + logging.level."com.example" = debug + logging.appenders = [capture] + logging.appender.capture.output.uri = "%s" + logging.appender.capture.encoder.uri = pattern + logging.appender.capture.encoder.pattern.pattern = "%%msg%%n" + """ + .formatted(root, directory.resolve("application.log").toUri())); + } + + private String output() throws Exception { + return Files.readString(directory.resolve("application.log")); + } + + @Test + void discoversServicesAndReplaysEarlyLoggingIntoConfiguredOutput() throws Exception { + assertInstanceOf( + RainbowGumService.class, + ServiceLoader.load(LoggingService.class).findFirst().orElseThrow()); + var bootstrap = RainbowGum.of(); + assertSame(LogRouter.global(), bootstrap.router()); + var early = LoggerFactory.getLogger("early.logger"); + early.info("before-environment"); + var app = new Jooby().setEnvironment(environment(configuration("debug"))); + assertNotSame(bootstrap, RainbowGum.of()); + assertTrue(early.isDebugEnabled()); + early.debug("after-environment"); + assertEquals("before-environment\nafter-environment\n", output()); + assertSame(app.getEnvironment(), app.getEnvironment()); + } + + @Test + void readsQuotedAndUnquotedLoggerNamesAndNativeValues() { + var config = + ConfigFactory.parseString( + """ + logging.level { + root = warn + "com.example" = debug + org.dotted = error + "tc.image@sha256:abc..." = trace + } + logging.groups = [database] + logging.group.database = [org.postgresql] + logging.level.database = info + logging.change.logger."com.example" = [level] + logging.caller.logger."com.example" = true + logging.extra = { "user.name" = alice, retries = 3, active = true } + logging.nullValue = null + """); + var properties = new ConfigProperties(() -> config); + assertEquals(List.of("database"), properties.listOrNull("logging.groups")); + assertEquals( + Map.of("user.name", "alice", "retries", "3", "active", "true"), + properties.mapOrNull("logging.extra")); + assertNull(properties.valueOrNull("logging.nullValue")); + var gum = new RainbowGumService(); + gum.initialize(environment(config)); + var levels = RainbowGum.of().config().levelResolver(); + assertEquals(Level.WARNING, levels.resolveLevel("other")); + assertEquals(Level.DEBUG, levels.resolveLevel("com.example.Child")); + assertEquals(Level.ERROR, levels.resolveLevel("org.dotted.Child")); + assertEquals(Level.TRACE, levels.resolveLevel("tc.image@sha256:abc...")); + assertEquals(Level.INFO, levels.resolveLevel("org.postgresql.Driver")); + assertTrue(RainbowGum.of().config().changePublisher().callerInfoEnabled("com.example.Child")); + } + + @Test + void applicationProfilesAndEnvironmentOptionsConfigureLogging() throws Exception { + Files.writeString(directory.resolve("application.conf"), configuration("warn").root().render()); + Files.writeString(directory.resolve("application.test.conf"), "logging.level.root=error"); + var app = new Jooby(); + app.setEnvironmentOptions( + new EnvironmentOptions().setBasedir(directory).setActiveNames("test")); + assertEquals(Level.ERROR, RainbowGum.of().config().levelResolver().resolveLevel("other")); + assertEquals(Level.DEBUG, RainbowGum.of().config().levelResolver().resolveLevel("com.example")); + } + + @Test + void lazyEnvironmentInitializesLoggingOnce() { + var bootstrap = RainbowGum.of(); + var app = new Jooby(); + assertSame(bootstrap, RainbowGum.getOrNull()); + var environment = app.getEnvironment(); + var configured = RainbowGum.of(); + assertNotSame(bootstrap, configured); + assertNotSame(LogRouter.global(), configured.router()); + assertSame(environment, app.getEnvironment()); + assertSame(configured, RainbowGum.of()); + } + + @Test + void publishRereadsLevelsFromTheCurrentEnvironmentConfig() throws Exception { + var env = environment(configuration("warn")); + new Jooby().setEnvironment(env); + var logger = LoggerFactory.getLogger("reload.logger"); + assertFalse(logger.isDebugEnabled()); + var gum = RainbowGum.of(); + env.setConfig( + env.getConfig().withValue("logging.level.root", ConfigValueFactory.fromAnyRef("debug"))); + gum.config().changePublisher().publish(); + assertSame(gum, RainbowGum.of()); + assertTrue(logger.isDebugEnabled()); + logger.debug("reloaded"); + assertEquals("reloaded\n", output()); + } + + @Test + void replacingEnvironmentClosesThePreviousOutputsAndUpdatesExistingLoggers() throws Exception { + var app = new Jooby().setEnvironment(environment(configuration("warn"))); + var first = RainbowGum.of(); + var logger = LoggerFactory.getLogger("replacement.logger"); + assertFalse(logger.isDebugEnabled()); + app.setEnvironment(environment(configuration("debug"))); + assertNotSame(first, RainbowGum.of()); + assertTrue(logger.isDebugEnabled()); + logger.debug("replacement"); + assertEquals("replacement\n", output()); + } + + @Test + void logOffRestoresInheritedLevelsEvenWhenNestedTaskThrows() throws Exception { + new Jooby().setEnvironment(environment(configuration("debug"))); + var logger = LoggerFactory.getLogger("mute.logger"); + var service = new RainbowGumService(); + assertEquals( + io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeMode.ON, + RainbowGum.of().config().globals().changeMode()); + assertEquals( + java.util.Set.of(io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeType.LEVEL), + RainbowGum.of().config().changePublisher().allowedChanges("mute.logger")); + assertThrows( + IllegalStateException.class, + () -> + service.logOff( + List.of("mute.logger"), + () -> { + assertEquals( + Level.OFF, + RainbowGum.of().config().levelResolver().resolveLevel("mute.logger")); + assertFalse(logger.isErrorEnabled()); + service.logOff( + List.of("mute.logger"), () -> assertFalse(logger.isErrorEnabled())); + assertFalse(logger.isErrorEnabled()); + logger.error("hidden"); + throw new IllegalStateException("expected"); + })); + assertTrue(logger.isDebugEnabled()); + logger.debug("restored"); + assertEquals("restored\n", output()); + } + + @Test + void muteSurvivesBootstrapReplacementDuringServerStartup() { + RainbowGum.of(); + var service = new RainbowGumService(); + service.logOff( + List.of("server.logger"), + () -> { + new Jooby().setEnvironment(environment(configuration("debug"))); + assertEquals( + Level.OFF, RainbowGum.of().config().levelResolver().resolveLevel("server.logger")); + assertFalse(LoggerFactory.getLogger("server.logger").isErrorEnabled()); + }); + assertTrue(LoggerFactory.getLogger("server.logger").isDebugEnabled()); + } + + @Test + void invalidConfigurationDoesNotUnbindTheWorkingInstance() throws Exception { + var app = new Jooby().setEnvironment(environment(configuration("debug"))); + var current = RainbowGum.of(); + var invalid = + configuration("debug") + .withValue( + "logging.appender.capture.encoder.uri", + ConfigValueFactory.fromAnyRef("missing-encoder")); + assertThrows(RuntimeException.class, () -> app.setEnvironment(environment(invalid))); + assertSame(current, RainbowGum.of()); + LoggerFactory.getLogger("still.working").debug("still-working"); + assertEquals("still-working\n", output()); + } + + @Test + void doesNotReplaceAnExplicitlyInstalledRainbowGum() { + var custom = RainbowGum.builder(LogConfig.builder().build()).set(); + new Jooby().setEnvironment(environment(configuration("debug"))); + assertSame(custom, RainbowGum.of()); + } +} diff --git a/modules/pom.xml b/modules/pom.xml index a294ea167a..fad3dbe0be 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -20,6 +20,7 @@ jooby-logback jooby-log4j + jooby-rainbowgum jooby-conscrypt diff --git a/pom.xml b/pom.xml index 7c622061a1..d26238ab8c 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,8 @@ 1.85.1 + + 0.16.0 1.6.5 2.26.1 2.0.20 @@ -290,6 +292,11 @@ ${jooby.version} + + io.jooby + jooby-rainbowgum + ${jooby.version} + io.jooby From 0ea4c5f13dac6eae9cf7ab667e4ecb5643dcf002 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 15:19:32 -0400 Subject: [PATCH 02/11] Keep temporary logger levels independent of root property aliases Use a programmatic level override so muting a logger literally named root does not change the root default. Treat null HOCON map entries as absent and document caller/decorator bootstrap capabilities. Validation: JDK 21 core and integration tests pass against the patched Rainbow Gum snapshot (1,311 core tests and 11 integration tests). Co-authored-by: Codex --- docs/asciidoc/modules/rainbowgum.adoc | 6 ++++- .../io/jooby/rainbowgum/ConfigProperties.java | 8 ++++++- .../jooby/rainbowgum/RainbowGumService.java | 22 +++++++------------ .../rainbowgum/RainbowGumServiceTest.java | 17 +++++++++++++- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/asciidoc/modules/rainbowgum.adoc b/docs/asciidoc/modules/rainbowgum.adoc index edc04e3c97..87e52a959d 100644 --- a/docs/asciidoc/modules/rainbowgum.adoc +++ b/docs/asciidoc/modules/rainbowgum.adoc @@ -47,9 +47,13 @@ logging. Logging is global to the JVM, as with Jooby's other logging implementat A bootstrap Rainbow Gum queues messages emitted before Jooby loads its environment, including messages from static loggers. When the environment is available, the module installs the configured Rainbow -Gum, replays queued messages through its routes, and updates existing SLF4J loggers. It then closes +Gum, replays queued messages through its routes, and updates existing SLF4J logger levels and routes. It then closes the previous instance. An explicitly installed Rainbow Gum is left under the application's control. +Caller information and decorators are chosen when a logger is created. For loggers created during +bootstrap, configure these capabilities before startup (for example, +`-Dlogging.caller.logger.root=true`); the later environment configuration does not retrofit them. + === Configuration changes Jooby core does not watch configuration files or publish configuration-change events. Jooby Run's diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java index 79a7212f41..3a3a880381 100644 --- a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/ConfigProperties.java @@ -16,6 +16,7 @@ import com.typesafe.config.ConfigList; import com.typesafe.config.ConfigObject; import com.typesafe.config.ConfigValue; +import com.typesafe.config.ConfigValueType; import io.jstach.rainbowgum.LogProperties; /** Reads native HOCON objects, including quoted logger names, lists and maps. */ @@ -58,7 +59,12 @@ protected ConfigValue doRootNodeOrNull() { return null; } Map result = new LinkedHashMap<>(); - object.forEach((key, value) -> result.put(key, scalar(value))); + object.forEach( + (key, value) -> { + if (value.valueType() != ConfigValueType.NULL) { + result.put(key, scalar(value)); + } + }); return result; } diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java index aacc0541b4..8a78aa72b0 100644 --- a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java @@ -14,7 +14,6 @@ import io.jooby.Environment; import io.jooby.LoggingService; import io.jooby.SneakyThrows; -import io.jstach.rainbowgum.LevelResolver; import io.jstach.rainbowgum.LogConfig; import io.jstach.rainbowgum.LogProperties; import io.jstach.rainbowgum.RainbowGum; @@ -25,11 +24,7 @@ public final class RainbowGumService implements LoggingService { private static final Object LOCK = new Object(); private static final Map MUTED = new HashMap<>(); - private static final LogProperties.MutableLogProperties LEVELS = - LogProperties.MutableLogProperties.builder() - .with(new ConcurrentHashMap<>()) - .description("Jooby temporary logger levels") - .build(); + private static final Map LEVELS = new ConcurrentHashMap<>(); static final LogProperties DEFAULTS = LogProperties.builder() @@ -47,7 +42,6 @@ public RainbowGumService() {} static void register(ServiceRegistry registry) { registry.put(Owner.class, Owner.INSTANCE); - registry.put(LogProperties.class, LevelResolver.OVERRIDE_PROPERTIES_NAME, LEVELS); } @Override @@ -69,6 +63,7 @@ public void initialize(Environment environment) { var config = LogConfig.builder() .properties(properties) + .config(LEVELS::get) .serviceRegistry(registry) .serviceLoader( ServiceLoader.load(RainbowGumServiceProvider.class, environment.getClassLoader())) @@ -95,11 +90,11 @@ public List getLogFileName() { @Override public void logOff(List loggers, SneakyThrows.Runnable task) { - var names = loggers.stream().map(RainbowGumService::levelKey).distinct().toList(); + var names = loggers.stream().map(RainbowGumService::loggerName).distinct().toList(); synchronized (LOCK) { for (var name : names) { MUTED.merge(name, 1, Integer::sum); - LEVELS.put(name, "off"); + LEVELS.put(name, System.Logger.Level.OFF); } } try { @@ -113,7 +108,7 @@ public void logOff(List loggers, SneakyThrows.Runnable task) { int remaining = MUTED.get(name) - 1; if (remaining == 0) { MUTED.remove(name); - LEVELS.put(name, null); + LEVELS.remove(name); } else { MUTED.put(name, remaining); } @@ -123,10 +118,9 @@ public void logOff(List loggers, SneakyThrows.Runnable task) { } } - private static String levelKey(String name) { - return name.isEmpty() || name.equals("ROOT") - ? LogProperties.LEVEL_PREFIX - : LogProperties.LEVEL_PREFIX + "." + name; + private static String loggerName(String name) { + // SLF4J's root name is ROOT; lowercase root is an ordinary logger name here. + return name.equals("ROOT") ? "" : name; } private static void publish() { diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java index 5381937278..f4f0df600f 100644 --- a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -97,7 +97,7 @@ void readsQuotedAndUnquotedLoggerNamesAndNativeValues() { logging.level.database = info logging.change.logger."com.example" = [level] logging.caller.logger."com.example" = true - logging.extra = { "user.name" = alice, retries = 3, active = true } + logging.extra = { "user.name" = alice, retries = 3, active = true, unset = null } logging.nullValue = null """); var properties = new ConfigProperties(() -> config); @@ -202,6 +202,21 @@ void logOffRestoresInheritedLevelsEvenWhenNestedTaskThrows() throws Exception { assertEquals("restored\n", output()); } + @Test + void mutingLiteralRootDoesNotMuteUnrelatedLoggers() { + new Jooby().setEnvironment(environment(configuration("debug"))); + var root = LoggerFactory.getLogger("root"); + var other = LoggerFactory.getLogger("unrelated.logger"); + new RainbowGumService() + .logOff( + List.of("root"), + () -> { + assertFalse(root.isErrorEnabled()); + assertTrue(other.isDebugEnabled()); + }); + assertTrue(root.isDebugEnabled()); + } + @Test void muteSurvivesBootstrapReplacementDuringServerStartup() { RainbowGum.of(); From 04d240604d4558b535111f698161a0a02b7df3b6 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 17:10:52 -0400 Subject: [PATCH 03/11] Use released Rainbow Gum 0.16.1 for the Jooby integration Remove the release-blocker comment now that the OFF and global router rebinding fixes are published. Validate against Maven Central artifacts without a snapshot version override. Validation: JDK 21 clean verify (1,311 core and 11 integration tests) and JPMS service-loading/bootstrap smoke test. Co-authored-by: Codex --- pom.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index d26238ab8c..a3bf24c429 100644 --- a/pom.xml +++ b/pom.xml @@ -90,8 +90,7 @@ 1.85.1 - - 0.16.0 + 0.16.1 1.6.5 2.26.1 2.0.20 From 8e3ed32a7113568cf658556396e8b81aa2bbede8 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 17:51:34 -0400 Subject: [PATCH 04/11] Mark only the queued bootstrap Rainbow Gum as Jooby's The ownership marker was added by a PropertiesProvider, which runs for every service loaded LogConfig. A Rainbow Gum the application installed from such a config, or supplied by its own RainbowGumProvider, was therefore treated as Jooby's and closed and replaced when the environment loaded. The bootstrap provider now builds its own config with the marker, as the Spring Boot pre-boot provider does, and has the lowest priority so application providers win. Found in review of the branch, requested by Adam Gent. Co-Authored-By: Claude Opus 5.5 --- docs/asciidoc/modules/rainbowgum.adoc | 3 ++- .../jooby/rainbowgum/RainbowGumBootstrap.java | 21 ++++++++++++------- .../rainbowgum/RainbowGumServiceTest.java | 12 +++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/asciidoc/modules/rainbowgum.adoc b/docs/asciidoc/modules/rainbowgum.adoc index 87e52a959d..f576e6999e 100644 --- a/docs/asciidoc/modules/rainbowgum.adoc +++ b/docs/asciidoc/modules/rainbowgum.adoc @@ -48,7 +48,8 @@ logging. Logging is global to the JVM, as with Jooby's other logging implementat A bootstrap Rainbow Gum queues messages emitted before Jooby loads its environment, including messages from static loggers. When the environment is available, the module installs the configured Rainbow Gum, replays queued messages through its routes, and updates existing SLF4J logger levels and routes. It then closes -the previous instance. An explicitly installed Rainbow Gum is left under the application's control. +the previous instance. An explicitly installed Rainbow Gum, or one supplied by the application's own +`RainbowGumProvider`, is left under the application's control. Caller information and decorators are chosen when a logger is created. For loggers created during bootstrap, configure these capabilities before startup (for example, diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java index 19f4afd3d7..2473192fab 100644 --- a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java @@ -8,7 +8,6 @@ import java.util.List; import java.util.Optional; -import io.jstach.rainbowgum.LogAlerts; import io.jstach.rainbowgum.LogConfig; import io.jstach.rainbowgum.LogProperties; import io.jstach.rainbowgum.RainbowGum; @@ -16,20 +15,26 @@ import io.jstach.rainbowgum.spi.RainbowGumServiceProvider; /** Queues logging before Jooby's application environment is available. */ -public final class RainbowGumBootstrap - implements RainbowGumServiceProvider.RainbowGumProvider, - RainbowGumServiceProvider.PropertiesProvider { +public final class RainbowGumBootstrap implements RainbowGumServiceProvider.RainbowGumProvider { /** For service loading. */ public RainbowGumBootstrap() {} @Override - public List provideProperties(ServiceRegistry registry, LogAlerts alerts) { + public Optional provide(LogConfig config) { + // Only this queued instance is marked as Jooby's, not every service loaded config. + var registry = ServiceRegistry.of(); RainbowGumService.register(registry); - return List.of(RainbowGumService.DEFAULTS); + var properties = + LogProperties.of( + List.of( + LogProperties.StandardProperties.SYSTEM_PROPERTIES, RainbowGumService.DEFAULTS)); + var bootstrap = LogConfig.builder().properties(properties).serviceRegistry(registry).build(); + return Optional.of(RainbowGum.queued(bootstrap)); } @Override - public Optional provide(LogConfig config) { - return Optional.of(RainbowGum.queued(config)); + public int priority() { + // Application providers win; their Rainbow Gum is left under the application's control. + return Integer.MIN_VALUE; } } diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java index f4f0df600f..120b1f7dab 100644 --- a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -30,6 +30,7 @@ import io.jstach.rainbowgum.LogConfig; import io.jstach.rainbowgum.LogRouter; import io.jstach.rainbowgum.RainbowGum; +import io.jstach.rainbowgum.spi.RainbowGumServiceProvider; class RainbowGumServiceTest { @TempDir Path directory; @@ -253,4 +254,15 @@ void doesNotReplaceAnExplicitlyInstalledRainbowGum() { new Jooby().setEnvironment(environment(configuration("debug"))); assertSame(custom, RainbowGum.of()); } + + @Test + void doesNotReplaceAnExplicitlyInstalledServiceLoadedRainbowGum() { + var config = + LogConfig.builder() + .serviceLoader(ServiceLoader.load(RainbowGumServiceProvider.class)) + .build(); + var custom = RainbowGum.builder(config).set(); + new Jooby().setEnvironment(environment(configuration("debug"))); + assertSame(custom, RainbowGum.of()); + } } From b491322e9fdaec0f2551ce20d77f7390f2843ade Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 18:08:34 -0400 Subject: [PATCH 05/11] Configure logging once and allow level changes only for muted server loggers Loggers with a fixed level stay bound to the Rainbow Gum that created them. Rebuilding logging for a later environment closed that instance, so their events were lost; making every logger changeable hid this at a cost on every logger. Logging is now configured from the first environment only, replacing the queued bootstrap instance, as with Logback and Log4j. Level changes are allowed by default only for the loggers Jetty and Undertow mute during start and stop. Rereading levels with the change publisher is opt in through logging.change.logger. The docs describe both and Jooby Run keeping the first configuration. Requested by Adam Gent. Co-Authored-By: Claude Opus 5.5 --- docs/asciidoc/modules/rainbowgum.adoc | 37 +++++++---- .../jooby/rainbowgum/RainbowGumBootstrap.java | 2 +- .../jooby/rainbowgum/RainbowGumService.java | 32 +++++++--- .../rainbowgum/RainbowGumServiceTest.java | 62 ++++++++++++------- 4 files changed, 89 insertions(+), 44 deletions(-) diff --git a/docs/asciidoc/modules/rainbowgum.adoc b/docs/asciidoc/modules/rainbowgum.adoc index f576e6999e..453d7cf3e2 100644 --- a/docs/asciidoc/modules/rainbowgum.adoc +++ b/docs/asciidoc/modules/rainbowgum.adoc @@ -41,14 +41,18 @@ in the same object; HOCON cannot keep `logging.level` as both a scalar and an ob Jooby resolves `application.conf`, environment-specific files such as `application.prod.conf`, substitutions and system properties before passing the environment to Rainbow Gum. Programmatic environments supplied through `setEnvironment(...)` and `setEnvironmentOptions(...)` also configure -logging. Logging is global to the JVM, as with Jooby's other logging implementations. +logging. + +Logging is global to the JVM and configured once, from the first environment Jooby loads, as with +Jooby's other logging implementations. Later environments, such as those of additional applications +started by `runApp` or another `setEnvironment(...)` call, do not change it. === Early logging A bootstrap Rainbow Gum queues messages emitted before Jooby loads its environment, including messages from static loggers. When the environment is available, the module installs the configured Rainbow -Gum, replays queued messages through its routes, and updates existing SLF4J logger levels and routes. It then closes -the previous instance. An explicitly installed Rainbow Gum, or one supplied by the application's own +Gum, replays queued messages through its routes, and moves loggers created during bootstrap to its +levels and routes. It then closes the bootstrap instance. An explicitly installed Rainbow Gum, or one supplied by the application's own `RainbowGumProvider`, is left under the application's control. Caller information and decorators are chosen when a logger is created. For loggers created during @@ -58,10 +62,20 @@ bootstrap, configure these capabilities before startup (for example, === Configuration changes Jooby core does not watch configuration files or publish configuration-change events. Jooby Run's -development watcher restarts the application; the new environment configures Rainbow Gum again. +development watcher restarts the application but keeps project dependencies loaded, so logging keeps +the configuration from the first start. Restart Jooby Run to apply logging changes. + +Loggers have a fixed level once created. To let existing loggers follow level changes, allow them +with Rainbow Gum's `logging.change.logger` property: + +.application.conf +[source, hocon] +---- +logging.change.logger = level +---- -If the application replaces the configuration of an existing environment, it can reread logger levels -without reopening its appenders: +The application can then replace the configuration of its environment and reread logger levels +without reopening appenders: [source, java] ---- @@ -69,10 +83,11 @@ getEnvironment().setConfig(updatedConfig); RainbowGum.of().config().changePublisher().publish(); ---- -Existing loggers follow the new levels. The module enables level changes by default, also allowing -Jooby to temporarily mute server loggers during startup and shutdown. Rainbow Gum's -`logging.change.mode` and `logging.change.logger` properties can restrict these changes; doing so -also restricts temporary muting. Replacing the Jooby environment with `setEnvironment(...)` rebuilds -the complete logging configuration, including outputs and encoders. +=== Server logger muting + +Jooby turns off some server loggers while the server starts and stops. The module allows level +changes for those loggers only: `org.eclipse.jetty.server` for Jetty, and `io.undertow`, `org.xnio` +and `org.jboss.threads` for Undertow. `logging.change.mode = off` turns off all level changes, +including this muting. See the https://jstach.io/rainbowgum/[Rainbow Gum guide] for the complete configuration reference. diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java index 2473192fab..47f246bdd2 100644 --- a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumBootstrap.java @@ -23,7 +23,7 @@ public RainbowGumBootstrap() {} public Optional provide(LogConfig config) { // Only this queued instance is marked as Jooby's, not every service loaded config. var registry = ServiceRegistry.of(); - RainbowGumService.register(registry); + RainbowGumService.markBootstrap(registry); var properties = LogProperties.of( List.of( diff --git a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java index 8a78aa72b0..595980900d 100644 --- a/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java +++ b/modules/jooby-rainbowgum/src/main/java/io/jooby/rainbowgum/RainbowGumService.java @@ -26,34 +26,49 @@ public final class RainbowGumService implements LoggingService { private static final Map MUTED = new HashMap<>(); private static final Map LEVELS = new ConcurrentHashMap<>(); + /* + * Only the loggers servers mute during start and stop can change level, so other loggers keep + * Rainbow Gum's fixed level fast path. + */ static final LogProperties DEFAULTS = LogProperties.builder() - .fromProperties("logging.change.mode=on\nlogging.change.logger=level") + .fromProperties( + """ + logging.change.mode=on + logging.change.logger.org.eclipse.jetty.server=level + logging.change.logger.io.undertow=level + logging.change.logger.org.xnio=level + logging.change.logger.org.jboss.threads=level + """) .description("Jooby logging defaults") .order(-1000) .build(); - enum Owner { + enum Bootstrap { INSTANCE } /** For service loading. */ public RainbowGumService() {} - static void register(ServiceRegistry registry) { - registry.put(Owner.class, Owner.INSTANCE); + static void markBootstrap(ServiceRegistry registry) { + registry.put(Bootstrap.class, Bootstrap.INSTANCE); } @Override public void initialize(Environment environment) { synchronized (LOCK) { var previous = RainbowGum.getOrNull(); - if (previous != null && previous.config().serviceRegistry().findOrNull(Owner.class) == null) { - // An explicitly installed Rainbow Gum remains under the application's control. + if (previous != null + && previous.config().serviceRegistry().findOrNull(Bootstrap.class) == null) { + /* + * Logging is configured once per JVM from the first environment, as with Logback and Log4j. + * Loggers with a fixed level stay bound to the instance that created them, so replacing it + * would leave them writing to a closed instance. An explicitly installed Rainbow Gum also + * remains under the application's control. + */ return; } - var registry = ServiceRegistry.of(); - register(registry); var properties = LogProperties.of( List.of( @@ -64,7 +79,6 @@ public void initialize(Environment environment) { LogConfig.builder() .properties(properties) .config(LEVELS::get) - .serviceRegistry(registry) .serviceLoader( ServiceLoader.load(RainbowGumServiceProvider.class, environment.getClassLoader())) .build(); diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java index 120b1f7dab..c74c300c70 100644 --- a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -144,7 +144,10 @@ void lazyEnvironmentInitializesLoggingOnce() { @Test void publishRereadsLevelsFromTheCurrentEnvironmentConfig() throws Exception { - var env = environment(configuration("warn")); + var env = + environment( + configuration("warn") + .withValue("logging.change.logger", ConfigValueFactory.fromAnyRef("level"))); new Jooby().setEnvironment(env); var logger = LoggerFactory.getLogger("reload.logger"); assertFalse(logger.isDebugEnabled()); @@ -159,41 +162,47 @@ void publishRereadsLevelsFromTheCurrentEnvironmentConfig() throws Exception { } @Test - void replacingEnvironmentClosesThePreviousOutputsAndUpdatesExistingLoggers() throws Exception { + void laterEnvironmentsKeepTheFirstConfigurationAndExistingLoggersWorking() throws Exception { var app = new Jooby().setEnvironment(environment(configuration("warn"))); var first = RainbowGum.of(); var logger = LoggerFactory.getLogger("replacement.logger"); - assertFalse(logger.isDebugEnabled()); app.setEnvironment(environment(configuration("debug"))); - assertNotSame(first, RainbowGum.of()); - assertTrue(logger.isDebugEnabled()); - logger.debug("replacement"); - assertEquals("replacement\n", output()); + new Jooby().setEnvironment(environment(configuration("debug"))); + assertSame(first, RainbowGum.of()); + assertFalse(logger.isDebugEnabled()); + logger.warn("kept"); + assertEquals("kept\n", output()); } @Test void logOffRestoresInheritedLevelsEvenWhenNestedTaskThrows() throws Exception { new Jooby().setEnvironment(environment(configuration("debug"))); - var logger = LoggerFactory.getLogger("mute.logger"); + var logger = LoggerFactory.getLogger("io.undertow.Undertow"); var service = new RainbowGumService(); assertEquals( io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeMode.ON, RainbowGum.of().config().globals().changeMode()); assertEquals( java.util.Set.of(io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeType.LEVEL), - RainbowGum.of().config().changePublisher().allowedChanges("mute.logger")); + RainbowGum.of().config().changePublisher().allowedChanges("io.undertow.Undertow")); + assertEquals( + java.util.Set.of(), + RainbowGum.of().config().changePublisher().allowedChanges("com.example.Service")); assertThrows( IllegalStateException.class, () -> service.logOff( - List.of("mute.logger"), + List.of("io.undertow.Undertow"), () -> { assertEquals( Level.OFF, - RainbowGum.of().config().levelResolver().resolveLevel("mute.logger")); + RainbowGum.of() + .config() + .levelResolver() + .resolveLevel("io.undertow.Undertow")); assertFalse(logger.isErrorEnabled()); service.logOff( - List.of("mute.logger"), () -> assertFalse(logger.isErrorEnabled())); + List.of("io.undertow.Undertow"), () -> assertFalse(logger.isErrorEnabled())); assertFalse(logger.isErrorEnabled()); logger.error("hidden"); throw new IllegalStateException("expected"); @@ -205,7 +214,12 @@ void logOffRestoresInheritedLevelsEvenWhenNestedTaskThrows() throws Exception { @Test void mutingLiteralRootDoesNotMuteUnrelatedLoggers() { - new Jooby().setEnvironment(environment(configuration("debug"))); + new Jooby() + .setEnvironment( + environment( + configuration("debug") + .withValue( + "logging.change.logger.root", ConfigValueFactory.fromAnyRef("level")))); var root = LoggerFactory.getLogger("root"); var other = LoggerFactory.getLogger("unrelated.logger"); new RainbowGumService() @@ -223,29 +237,31 @@ void muteSurvivesBootstrapReplacementDuringServerStartup() { RainbowGum.of(); var service = new RainbowGumService(); service.logOff( - List.of("server.logger"), + List.of("org.xnio.Xnio"), () -> { new Jooby().setEnvironment(environment(configuration("debug"))); assertEquals( - Level.OFF, RainbowGum.of().config().levelResolver().resolveLevel("server.logger")); - assertFalse(LoggerFactory.getLogger("server.logger").isErrorEnabled()); + Level.OFF, RainbowGum.of().config().levelResolver().resolveLevel("org.xnio.Xnio")); + assertFalse(LoggerFactory.getLogger("org.xnio.Xnio").isErrorEnabled()); }); - assertTrue(LoggerFactory.getLogger("server.logger").isDebugEnabled()); + assertTrue(LoggerFactory.getLogger("org.xnio.Xnio").isDebugEnabled()); } @Test - void invalidConfigurationDoesNotUnbindTheWorkingInstance() throws Exception { - var app = new Jooby().setEnvironment(environment(configuration("debug"))); - var current = RainbowGum.of(); + void invalidConfigurationKeepsQueueingUntilAValidEnvironment() throws Exception { + var bootstrap = RainbowGum.of(); var invalid = configuration("debug") .withValue( "logging.appender.capture.encoder.uri", ConfigValueFactory.fromAnyRef("missing-encoder")); + var app = new Jooby(); assertThrows(RuntimeException.class, () -> app.setEnvironment(environment(invalid))); - assertSame(current, RainbowGum.of()); - LoggerFactory.getLogger("still.working").debug("still-working"); - assertEquals("still-working\n", output()); + assertSame(bootstrap, RainbowGum.of()); + LoggerFactory.getLogger("still.queued").info("still-queued"); + app.setEnvironment(environment(configuration("debug"))); + assertNotSame(bootstrap, RainbowGum.of()); + assertEquals("still-queued\n", output()); } @Test From 440bc5c12eeaec15b4a382632b464d81a332e128 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 18:08:34 -0400 Subject: [PATCH 06/11] Add review notes for the Rainbow Gum integration Working notes to remove before merging. Requested by Adam Gent. Co-Authored-By: Claude Opus 5.5 --- modules/jooby-rainbowgum/NOTE.md | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 modules/jooby-rainbowgum/NOTE.md diff --git a/modules/jooby-rainbowgum/NOTE.md b/modules/jooby-rainbowgum/NOTE.md new file mode 100644 index 0000000000..56479ca441 --- /dev/null +++ b/modules/jooby-rainbowgum/NOTE.md @@ -0,0 +1,52 @@ +# Rainbow Gum integration review notes + +Working notes for this branch. Remove before merging. + +## Changed in review + +- **Bootstrap marker.** The marker that lets Jooby replace the bootstrap Rainbow Gum was added by a + `PropertiesProvider`, which runs for every service loaded `LogConfig`. A Rainbow Gum the + application built from such a config, or supplied by its own `RainbowGumProvider`, was closed and + replaced when the environment loaded. `RainbowGumBootstrap` now builds its own config with the + marker and has the lowest priority. +- **Configured once.** Only the queued bootstrap instance is replaced. Loggers with a fixed level + stay bound to the instance that created them, so rebuilding logging for a later environment left + them writing to a closed instance and their events were lost. Making every logger changeable hid + this. Later environments (`runApp` with several applications, another `setEnvironment(...)`, test + suites starting an application per class) now keep the first configuration, as Logback and Log4j + do. +- **Level changes only where muting needs them.** Defaults allow level changes for the loggers Jetty + and Undertow mute (`org.eclipse.jetty.server`, `io.undertow`, `org.xnio`, `org.jboss.threads`) + instead of every logger, so other loggers keep the fixed level fast path. Rereading levels with + `changePublisher().publish()` is opt in through `logging.change.logger`. + +## Open on this branch + +- **Unbounded queue.** If no Jooby environment is ever loaded (unit tests that never read the + config, command line tools), INFO and above queue in memory for the life of the JVM and are never + written. Only ERROR reaches stderr. Probably needs a Rainbow Gum side fix: a bounded queue or a + flush at shutdown. +- **Muting with an application Rainbow Gum.** Muting levels are only wired into the module's own + config, so `logOff` does nothing when the application installed its own Rainbow Gum. +- **Failed logging setup.** `Jooby.setEnvironment` stores the environment before configuring + logging. If the logging config is invalid the call throws, but `getEnvironment()` then returns that + environment and logging stays queued. +- **Jooby Run.** Dependencies stay loaded across restarts in the default mode, so logging keeps the + first start's configuration (documented). The configured instance also keeps the first + `Environment`, and with it the first project class loader, reachable. With + `useSingleClassLoader` everything reloads, so each restart configures a new instance, but nothing + closes the previous class loader's instance or removes its shutdown hook. This is from reading the + code, not tested. + +## Jooby bugs outside this module + +- **Log4j `logOff` does not restore exactly configured loggers.** `Log4jService.setLog4jLevel` reads + `logger.getLevel()` after `Configurator.setLevel`, which sets an existing `LoggerConfig` with that + exact name in place (checked against log4j-core 2.26.1). The saved level is OFF, so a muted logger + configured by name in `log4j2.xml` stays off. The first mute also adds a `LoggerConfig` with that + name, so after one start and stop every muted logger is affected on the next start in the same JVM. + Fix: read the level before setting it. +- **Jetty mutes `org.eclipse.jetty.server.Server` twice.** `JettyServer.getLoggerOff()` lists it + twice. Logback and Log4j `logOff` record the previous level per name, so the second entry records + OFF over the original and the logger stays off after startup. Fix: drop the duplicate, or apply + `distinct()` in `MutedServer`. From 5fea00aed70fba5e7653979776133fcb6cf4feee Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 18:19:54 -0400 Subject: [PATCH 07/11] Note GraalVM native image results for the Rainbow Gum integration The module needs no native image metadata. Jooby needs a resource entry for application*.conf. Requested by Adam Gent. Co-Authored-By: Claude Opus 5.5 --- modules/jooby-rainbowgum/NOTE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/modules/jooby-rainbowgum/NOTE.md b/modules/jooby-rainbowgum/NOTE.md index 56479ca441..dc6586ad7b 100644 --- a/modules/jooby-rainbowgum/NOTE.md +++ b/modules/jooby-rainbowgum/NOTE.md @@ -38,6 +38,20 @@ Working notes for this branch. Remove before merging. closes the previous class loader's instance or removes its shutdown hook. This is from reading the code, not tested. +## GraalVM native image + +Checked with GraalVM CE 25.0.4 `native-image` on a small Jetty application with a static logger +and a `logging` block in `application.conf`. This module needs no native image metadata: its +service providers are registered from `META-INF/services`, and the native executable printed the +same output as the JVM run (early message replayed through the configured pattern, debug levels +from the config, Jetty startup muted). + +Jooby itself ships no native image metadata. Without it, a classpath `application.conf` is not in +the image, and Jooby silently falls back to defaults (port 8080, default logging). The application +had to add `META-INF/native-image///reachability-metadata.json` with +`{"resources": [{"glob": "application*.conf"}]}`. Jooby could ship that resource entry in its own +jar. + ## Jooby bugs outside this module - **Log4j `logOff` does not restore exactly configured loggers.** `Log4jService.setLog4jLevel` reads From 1aa5fac1235a91630ed58b72a2e84168d67c8cde Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Mon, 26 Oct 2026 18:56:54 -0400 Subject: [PATCH 08/11] Compare Rainbow Gum test output by lines for Windows The pattern's %n writes the platform line separator, so comparing against "\n" failed on Windows. Requested by Adam Gent. Co-Authored-By: Claude Opus 5.5 --- .../jooby/rainbowgum/RainbowGumServiceTest.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java index c74c300c70..c82b0a35bc 100644 --- a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -61,8 +61,9 @@ private Config configuration(String root) { .formatted(root, directory.resolve("application.log").toUri())); } - private String output() throws Exception { - return Files.readString(directory.resolve("application.log")); + // Lines, because %n is the platform line separator. + private List output() throws Exception { + return Files.readAllLines(directory.resolve("application.log")); } @Test @@ -78,7 +79,7 @@ void discoversServicesAndReplaysEarlyLoggingIntoConfiguredOutput() throws Except assertNotSame(bootstrap, RainbowGum.of()); assertTrue(early.isDebugEnabled()); early.debug("after-environment"); - assertEquals("before-environment\nafter-environment\n", output()); + assertEquals(List.of("before-environment", "after-environment"), output()); assertSame(app.getEnvironment(), app.getEnvironment()); } @@ -158,7 +159,7 @@ void publishRereadsLevelsFromTheCurrentEnvironmentConfig() throws Exception { assertSame(gum, RainbowGum.of()); assertTrue(logger.isDebugEnabled()); logger.debug("reloaded"); - assertEquals("reloaded\n", output()); + assertEquals(List.of("reloaded"), output()); } @Test @@ -171,7 +172,7 @@ void laterEnvironmentsKeepTheFirstConfigurationAndExistingLoggersWorking() throw assertSame(first, RainbowGum.of()); assertFalse(logger.isDebugEnabled()); logger.warn("kept"); - assertEquals("kept\n", output()); + assertEquals(List.of("kept"), output()); } @Test @@ -209,7 +210,7 @@ void logOffRestoresInheritedLevelsEvenWhenNestedTaskThrows() throws Exception { })); assertTrue(logger.isDebugEnabled()); logger.debug("restored"); - assertEquals("restored\n", output()); + assertEquals(List.of("restored"), output()); } @Test @@ -261,7 +262,7 @@ void invalidConfigurationKeepsQueueingUntilAValidEnvironment() throws Exception LoggerFactory.getLogger("still.queued").info("still-queued"); app.setEnvironment(environment(configuration("debug"))); assertNotSame(bootstrap, RainbowGum.of()); - assertEquals("still-queued\n", output()); + assertEquals(List.of("still-queued"), output()); } @Test From cd256c35726f255b9e43f698518c176fedd0cc9b Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Tue, 27 Oct 2026 09:28:07 -0400 Subject: [PATCH 09/11] Update Rainbow Gum to 0.16.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a3bf24c429..2a7a0d8e1d 100644 --- a/pom.xml +++ b/pom.xml @@ -90,7 +90,7 @@ 1.85.1 - 0.16.1 + 0.16.2 1.6.5 2.26.1 2.0.20 From 02b1cbd6e4e7a88dc1b0d0d95abdff7aeb568de1 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Tue, 27 Oct 2026 09:40:46 -0400 Subject: [PATCH 10/11] Use an invalid output in the Rainbow Gum queueing test for Windows The test's invalid configuration named a missing encoder. Rainbow Gum 0.16.2 opens the appender's output before resolving the encoder and does not close it when that fails, so application.log stayed open and Windows could not delete the temp directory. An unknown output scheme fails before anything is opened, and the test now checks no log file was created. Requested by Adam Gent. --- .../io/jooby/rainbowgum/RainbowGumServiceTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java index c82b0a35bc..79046bb28f 100644 --- a/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java +++ b/modules/jooby-rainbowgum/src/test/java/io/jooby/rainbowgum/RainbowGumServiceTest.java @@ -251,13 +251,19 @@ void muteSurvivesBootstrapReplacementDuringServerStartup() { @Test void invalidConfigurationKeepsQueueingUntilAValidEnvironment() throws Exception { var bootstrap = RainbowGum.of(); + /* + * An invalid output rather than an invalid encoder: Rainbow Gum 0.16.2 opens the output before + * resolving the encoder and does not close it when that fails, and Windows cannot delete the + * temp directory while the log file is open. + */ var invalid = configuration("debug") .withValue( - "logging.appender.capture.encoder.uri", - ConfigValueFactory.fromAnyRef("missing-encoder")); + "logging.appender.capture.output.uri", + ConfigValueFactory.fromAnyRef("missing-output:///")); var app = new Jooby(); assertThrows(RuntimeException.class, () -> app.setEnvironment(environment(invalid))); + assertFalse(Files.exists(directory.resolve("application.log"))); assertSame(bootstrap, RainbowGum.of()); LoggerFactory.getLogger("still.queued").info("still-queued"); app.setEnvironment(environment(configuration("debug"))); From c557e0c505a0d5000ef10bedb8260bc028e33a55 Mon Sep 17 00:00:00 2001 From: Adam Gent Date: Tue, 27 Oct 2026 11:14:43 -0400 Subject: [PATCH 11/11] Note the public API the Rainbow Gum integration adds to LoggingService Requested by Adam Gent. --- modules/jooby-rainbowgum/NOTE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/modules/jooby-rainbowgum/NOTE.md b/modules/jooby-rainbowgum/NOTE.md index dc6586ad7b..38c12cc733 100644 --- a/modules/jooby-rainbowgum/NOTE.md +++ b/modules/jooby-rainbowgum/NOTE.md @@ -2,6 +2,23 @@ Working notes for this branch. Remove before merging. +## Public API added outside this module + +Only `io.jooby.LoggingService` changes: + +- `default void initialize(Environment environment)`: a hook for logging systems configured from the + Jooby environment. The default does nothing, so `jooby-logback` and `jooby-log4j` are unchanged. +- `static void configure(Environment environment)`: finds the installed `LoggingService` with the + environment's class loader and calls `initialize`. It is public only because interface static + methods cannot be package private; `Jooby` is its only caller. It overloads the existing + `configure(ClassLoader, List)`. + +`Jooby` keeps its signatures, but `setEnvironment`, `setEnvironmentOptions` and the lazy load in +`getEnvironment` now all go through `setEnvironment`, which calls `LoggingService.configure`. Every +environment Jooby loads or is given now does a `ServiceLoader` lookup. + +The BOM and the parent pom gain `jooby-rainbowgum`, and the parent pom gains `rainbowgum.version`. + ## Changed in review - **Bootstrap marker.** The marker that lets Jooby replace the bootstrap Rainbow Gum was added by a