RainbowGum.java
package io.jstach.rainbowgum;
import java.lang.System.Logger.Level;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.ServiceLoader;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.LogRouter.RootRouter;
import io.jstach.rainbowgum.LogRouter.Router;
import io.jstach.rainbowgum.spi.RainbowGumServiceProvider;
//@formatter:off
/**
* The main entry point and configuration of RainbowGum logging.
* <p>
* RainbowGum logging loads configuration through the service loader. While you can
* manually set RainbowGum using {@link #set(Supplier)} it is better to register
* implementations through the ServiceLoader so that RainbowGum will load prior to any
* external logging.
* <p>
* To register a custom RainbowGum:
*
*
{@snippet class="snippets.RainbowGumProviderExample" region="provider" :
class RainbowGumProviderExample implements RainbowGumProvider {
@Override
public Optional<RainbowGum> provide(LogConfig config) {
Integer bufferSize = config.properties() //
.forKey("logging.custom.async.bufferSize")
.ofInt()
.or(1024)
.validateNow(RainbowGumProviderExample.class);
LogProvider<LogOutput> output = (name, cfg) -> cfg.properties()
.forKey("logging.custom.output")
.ofProvider(LogOutput::of)
.or(LogOutput.ofStandardOut())
.validateNow(RainbowGumProviderExample.class)
.provide(name, cfg);
var gum = RainbowGum.builder() //
.route(r -> {
r.publisher(PublisherFactory //
.async() //
.bufferSize(bufferSize) //
.build());
r.appender("console", a -> {
a.output(output);
});
r.level(Level.INFO);
})
.build();
return Optional.of(gum);
}
}
}
* <p>
* <strong>Initialization order</strong> - most of the actual work happens in
* {@link LogConfig.Builder#build()}, {@link LogRouter.Router.Builder}, and
* {@code LogAppenderRegistry}; this is the exact sequence for anyone debugging a
* configuration or writing a {@link RainbowGumServiceProvider.RainbowGumProvider}:
* <ol>
* <li>{@link LogConfig.Builder#build()} builds the shared {@link LogConfig}:
* <ol>
* <li>Resolve the {@link ServiceRegistry} (default: a new empty one) and
* {@link LogProperties} - manually added
* {@link RainbowGumServiceProvider.PropertiesProvider}s, else (if a
* {@link java.util.ServiceLoader} was supplied) SPI-discovered ones, with system
* properties always layered in as a final fallback. Providers receive a temporary
* {@link LogAlerts} collector.</li>
* <li>Build {@link LogAlerts} and {@link LogMetrics} from properties.</li>
* <li>Build the global level resolver ({@code logging.level.*} and
* {@code logging.group.*}, plus anything added directly on the builder).</li>
* <li>Construct the {@link LogConfig} itself and replay provider alerts into its
* configured {@link LogAlerts}. This is also where a few
* process-wide flags get latched (config change support, the appender lock, and
* thread-local-disabled globals) and the default output/encoder/publisher
* registries are created.</li>
* <li>Run every {@link RainbowGumServiceProvider.Configurator} (manually added,
* then SPI-discovered), highest {@linkplain RainbowGumServiceProvider.Configurator#priority()
* priority} first. A configurator returning {@code false} is retried in a later
* pass (see {@link RainbowGumServiceProvider#PASSES}) - useful if it depends on
* another configurator running first.</li>
* <li>Start {@link LogAlerts} - deliberately last, so a configurator's own alert
* listener is already registered before alerts decides whether an unobserved
* error should refuse startup.</li>
* </ol>
* </li>
* <li>{@link Builder#build()} builds each named {@link LogRouter.Router} (a single
* route named {@value LogProperties#DEFAULT_NAME}, unless {@code logging.routes}
* names several) via {@link LogRouter.Router.Builder}'s own (package-private)
* {@code build()}:
* <ol>
* <li>Resolve route flags ({@code logging.route.<name>.flags}).</li>
* <li>Build a route-scoped level resolver: anything configured directly on the
* route builder, layered over {@code logging.route.<name>.level.*}, falling back
* to the global level resolver from step 1 (skipped entirely if the route flag
* {@code IGNORE_GLOBAL_LEVEL_RESOLVER} is set) - and finally to {@code INFO} if
* nothing at all resolves a level.</li>
* <li>Resolve appenders - only if none were added programmatically on the route
* builder (that check is all-or-nothing: adding even one appender in code skips
* property-based resolution entirely). See <strong>Appender resolution</strong>
* below.</li>
* <li>Resolve the publisher - {@code logging.route.<name>.publisher.uri}, falling
* back to a synchronous publisher if unset.</li>
* <li>Hand the resolved appenders to the publisher and register the publisher in
* the {@link ServiceRegistry}.</li>
* </ol>
* </li>
* <li>{@link #start()} starts the root router, which starts each route's
* publisher, which starts each of its appenders, which starts its output (e.g.
* opening a file).</li>
* </ol>
* <strong>Appender resolution</strong> - the part most often mistaken for a bug,
* since no appenders are ever added programmatically in the common case:
* <ol>
* <li>{@code logging.route.<name>.appenders} names the route's appenders; for the
* {@value LogProperties#DEFAULT_NAME} route only, a missing value falls back to
* the unprefixed {@code logging.appenders}.</li>
* <li>If that is <em>still</em> missing: for the default route only, the list
* defaults to {@code [file, console]} if {@code logging.file.name} (or Spring
* Boot's convention) is set, otherwise just {@code [console]}. A non-default
* route with no appenders configured anywhere is a startup failure, not a silent
* no-op.</li>
* <li>Each named appender then resolves independently. {@code file} and
* {@code console} are the two built-in special cases, each with its own output
* property/fallback ({@code logging.file.name} then
* {@code logging.appender.file.output.uri} for {@code file};
* {@code logging.appender.console.output.uri} falling back to standard-out for
* {@code console}). Any other name is fully generic:
* {@code logging.appender.<name>.output.uri}/{@code .encoder.uri}/{@code .flags}/
* {@code .type}, with the output's own URI scheme choosing which registered
* {@link LogOutput.OutputProvider} builds it.</li>
* <li>An output can supply its own encoder by implementing
* {@link LogOutput.ProvidesEncoder} (rare, e.g. some structured outputs): with
* {@link LogOutput.ProvidesEncoder.Policy#MANDATORY} that encoder is used as-is
* and the {@code .encoder.uri} property is never consulted (configuring one anyway
* is a startup failure); with {@link LogOutput.ProvidesEncoder.Policy#DEFAULT}
* it is only a fallback and the {@code .encoder.uri} property, if set, still wins.
* An output that does not implement {@link LogOutput.ProvidesEncoder} at all is
* unaffected: the {@code .encoder.uri} property (or the registry's default for the
* output's type) always applies.</li>
* </ol>
*/
//@formatter:on
@SuppressWarnings("InvalidInlineTag")
public sealed interface RainbowGum extends AutoCloseable, LogEventLogger {
/**
* Gets the currently statically bound RainbowGum and will try to load and find one if
* there is none currently bound. <strong> This is a blocking operation as in locks
* are used and will block indefinitely till a gum has loaded. </strong> If that is
* not desired see {@link #getOrNull()}.
* @return current RainbowGum or new loaded one.
*/
public static RainbowGum of() {
return RainbowGumHolder.get();
}
/**
* Gets the currently statically bound RainbowGum or <code>null</code> if none are
* <strong>finished binding</strong>. Unlike {@link #of()} this will never load or
* start an instance but rather just gets the currently bound one. It will also never
* block and does not wait if one is currently being loaded.
* @return current RainbowGum or <code>null</code>
*/
public static @Nullable RainbowGum getOrNull() {
return RainbowGumHolder.current();
}
/**
* Provides the service loader default based RainbowGum.
* @return RainbowGum.
*/
public static RainbowGum defaults() {
return RainbowGumServiceProvider.provide();
}
/**
* Creates a RainbowGum that will <strong>ALWAYS</strong> use the global router.
* @param config config.
* @return rainbow gum that always uses global router.
*/
public static RainbowGum queued(LogConfig config) {
return new SimpleRainbowGum(config, LogRouter.global(), UUID.randomUUID());
}
/**
* Sets the global default RainbowGum. The supplied {@link RainbowGum} must not
* already be started.
* @param supplier the supplier will be memoized when {@linkplain Supplier#get()
* accessed} and {@link RainbowGum#start()} will be called.
*/
public static void set(Supplier<RainbowGum> supplier) {
RainbowGumHolder.set(supplier);
}
/**
* Subscribes to notification of a new RainbowGum becoming the globally bound one (see
* {@link #set(Supplier)}, {@link #set(RainbowGum)}, {@link Builder#set()}). This is
* primarily for components like SLF4J's logger factory that are themselves
* bootstrapped once (often before "the real" RainbowGum has loaded, e.g. a
* framework's own pre-boot sequence) and otherwise have no way of finding out that a
* different RainbowGum has since replaced the one they captured.
* <p>
* Only a {@linkplain java.lang.ref.WeakReference weak reference} to
* <code>consumer</code> is retained, so subscribing does not by itself keep the
* consumer (or whatever it is attached to) reachable - the caller is responsible for
* keeping a strong reference to <code>consumer</code> for as long as it needs to keep
* receiving notifications, otherwise it may silently stop being called once garbage
* collected.
* @param consumer called with the newly bound global RainbowGum. Never called while
* any RainbowGum internal lock is held.
*/
public static void onGlobalChange(Consumer<RainbowGum> consumer) {
RainbowGumHolder.globalChangePublisher.add(consumer);
}
/**
* Sets a Rainbow Gum and provides a supplier that will start it globally.
* @param gum that will be set immediatly.
* @return supplier that will set, get and start the supplied gum and if it is not the
* same will throw {@link IllegalStateException}.
*/
public static Supplier<? extends RainbowGum> set(RainbowGum gum) {
UUID instanceId = gum.instanceId();
RainbowGum.set(() -> gum);
return () -> {
var of = RainbowGum.of();
/*
* TODO this is a hack. The holder lock should be used to make this not
* happen.
*/
if (!instanceId.equals(of.instanceId())) {
throw new IllegalStateException("Another rainbow gum registered itself as the global. "
+ "This is rare reace condition and probably a bug");
}
return of;
};
}
/**
* The config associated with this instance.
* @return config.
*/
public LogConfig config();
/**
* The router that will route log messages to publishers.
* @return router
*/
public RootRouter router();
/**
* Starts the rainbow gum and returns it. It is returned for try-with usage
* convenience.
* @return the started RainbowGum.
*/
default RainbowGum start() {
router().start(config());
var debugMode = config().debugMode();
if (debugMode == LogConfig.DebugModeType.ALL || debugMode == LogConfig.DebugModeType.HELP) {
var report = LogReporter.builder().build().report(this);
MetaLog.error(LogEventFactory.of(RainbowGum.class.getName())
.eventNoArg(Level.INFO, "Rainbow Gum started:\n" + report, null));
}
if (config() instanceof DefaultLogConfig c) {
var check = c.unusedKeyCheck();
if (check != null) {
reportUnusedKeys(check);
}
}
if (config().alerts() instanceof DefaultLogAlerts alerts) {
try {
alerts.finishStartup();
}
catch (RuntimeException e) {
close();
throw e;
}
}
return this;
}
/*
* By default an unused key with a suggestion is almost always a typo, so it is a
* warning, which logging.alerts.fail=warning (and logging.debug=help) turns into a
* failure. One without is only information: the key may be for a component type that
* was not chosen, such as encoder options left behind by a profile that switches
* encoder. logging.alerts.unusedKey raises both for strict configurations.
*/
private void reportUnusedKeys(UnusedKeyCheck check) {
var eventFactory = LogEventFactory.of(LogConfig.class.getName());
var unusedKeyLevel = config().alerts() instanceof DefaultLogAlerts alerts ? alerts.unusedKeyLevel()
: LogAlerts.UnusedKeyLevel.INFO;
for (var u : check.unused()) {
var level = unusedKeyLevel.level(u.suggestion() != null);
var event = eventFactory.eventNoArg(level, u.message(), null);
config().alerts().alert(event);
if (level == Level.INFO && config().debugMode().checksUnusedKeys()) {
MetaLog.error(event);
}
}
}
/**
* Unique id of rainbow gum instance.
* @return random id created on creation.
*/
public UUID instanceId();
/**
* Will close the RainbowGum and all registered components as well as removed from the
* shutdown hooks. If the rainbow gum is set as global it will no longer be global and
* replaced with the bootstrapping in memory queue. {@inheritDoc}
*/
@Override
public void close();
/**
* This append call is mainly for testing as it does not avoid making events that do
* not need to be made if no logging needs to be done. {@inheritDoc}
*/
@Override
default void log(LogEvent event) {
var r = router().route(event.loggerName(), event.level());
if (r.isEnabled()) {
r.log(event);
}
}
/**
* Use to build a custom {@link RainbowGum} which will use the {@link LogConfig}
* provided by the service loader.
* @return builder.
*/
public static Builder builder() {
ServiceLoader<RainbowGumServiceProvider> loader = ServiceLoader.load(RainbowGumServiceProvider.class);
var config = RainbowGumServiceProvider.provideConfig(loader);
return builder(config);
}
/**
* Use to build a custom {@link RainbowGum} with supplied config.
* @param config the config
* @return builder.
* @see #builder()
*/
public static Builder builder(LogConfig config) {
return new Builder(config);
}
/**
* Use to build a custom {@link RainbowGum} with supplied config.
* @param config consumer that has first argument as config builder.
* @return builder.
* @see #builder()
* @apiNote this method is for ergonomic fluent reasons.
*/
public static Builder builder(Consumer<? super LogConfig.Builder> config) {
var b = LogConfig.builder();
config.accept(b);
return builder(b.build());
}
/**
* RainbowGum Builder.
*/
public class Builder {
private final LogConfig config;
private final List<Router> routes = new ArrayList<>();
private Builder(LogConfig config) {
this.config = config;
}
/**
* Adds a router.
* @param route a router.
* @return builder.
*/
public Builder route(Router route) {
bind();
this.routes.add(route);
return this;
}
/*
* Routers provision outputs/appenders into the config as soon as they are built,
* so the config must be claimed before that, not just at build().
*/
private void bind() {
((DefaultLogConfig) config).bind(this);
}
/**
* Adds a route by using a consumer of the route builder.
* @param name name of router.
* @param consumer consumer is passed router builder. The consumer does not need
* to call {@link Router#builder(String,LogConfig)}
* @return builder.
* @see io.jstach.rainbowgum.LogRouter.Router.Builder
*/
public Builder route(String name, Consumer<Router.Builder> consumer) {
bind();
var builder = Router.builder(name, config);
consumer.accept(builder);
return route(builder.build());
}
/**
* Adds a route by using a consumer of the route builder.
* @param consumer consumer is passed router builder. The consumer does not need
* to call {@link Router#builder(String,LogConfig)}
* @return builder.
* @see io.jstach.rainbowgum.LogRouter.Router.Builder
*/
public Builder route(Consumer<Router.Builder> consumer) {
bind();
var builder = Router.builder(Router.DEFAULT_ROUTER_NAME, config);
consumer.accept(builder);
return route(builder.build());
}
/**
* Builds an un-started {@link RainbowGum}.
* @return an un-started {@link RainbowGum}.
*/
public RainbowGum build() {
return build(UUID.randomUUID());
}
/**
* Builds an un-started {@link RainbowGum}.
* @param instanceId unique id for rainbow gum instance.
* @return an un-started {@link RainbowGum}.
*/
private RainbowGum build(UUID instanceId) {
((DefaultLogConfig) config).markBuilt(this);
var routes = this.routes;
var config = this.config;
if (routes.isEmpty()) {
var routeNamesResult = config.properties() //
.forKey(LogProperties.ROUTES_PROPERTY)
.ofList()
.or(List.of());
List<String> routeNames = routeNamesResult.validateNow(Builder.class);
/*
* Validated as a plain list of names, before any of them is used to build
* a Router, so a bad name is reported against this list property itself,
* not deferred until some unrelated {name}-keyed property (e.g. this
* route's own level) happens to interpolate it first.
*/
LogProperty.Validator.validateNames(LogRouter.class, "route", routeNamesResult, routeNames);
if (routeNames.isEmpty()) {
routes = List.of(Router.builder(Router.DEFAULT_ROUTER_NAME, config).build());
}
else {
routes = routeNames.stream().map(n -> Router.builder(n, config).build()).toList();
}
}
var root = InternalRootRouter.of(routes, config);
config.changePublisher().subscribe(c -> {
root.changePublisher().publish(root);
});
return new SimpleRainbowGum(config, root, instanceId);
}
/**
* Builds, starts and sets the RainbowGum as the global one picked up by logging
* facades.
* @return started and set rainbow that can be used in a try-close.
*/
public RainbowGum set() {
UUID instanceId = UUID.randomUUID();
RainbowGum.set(() -> build(instanceId));
var gum = RainbowGum.of();
/*
* TODO this is a hack. The holder lock should be used to make this not
* happen.
*/
if (!instanceId.equals(gum.instanceId())) {
throw new IllegalStateException("Another rainbow gum registered itself as the global. "
+ "This is rare reace condition and probably a bug");
}
return gum;
}
/**
* Removes the currently set Rainbow Gum which will close it if one exists and
* will be replaced by the default provision process if {@link RainbowGum#of()} is
* called before {@link #set()}.
* @apiNote This is largely an internal detail for unit testing the provision
* process.
*/
public void unset() {
RainbowGumHolder.remove(null);
}
/**
* For returning an optional for the LogProvider contract.
* @return optional that always has a rainbow gum.
* @apiNote this method is for ergonomics.
*/
public Optional<RainbowGum> optional() {
return Optional.of(this.build());
}
/**
* For returning an optional for the LogProvider contract.
* @param condition condition to check if this rainbow gum should be used.
* @return optional rainbow gum.
* @apiNote this method is for ergonomics.
*/
public Optional<RainbowGum> optional(Function<? super LogConfig, Boolean> condition) {
var cond = condition.apply(config);
if (cond) {
return Optional.of(this.build());
}
return Optional.empty();
}
}
}
final class RainbowGumHolder {
/*
* TODO perhaps a StampedLock would be better performance wise.
*/
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private static Supplier<RainbowGum> supplier = RainbowGumServiceProvider::provide;
private static volatile @Nullable RainbowGum rainbowGum = null;
static final GlobalChangePublisher globalChangePublisher = new GlobalChangePublisher();
/*
* Lock free read for per event hot paths (see KeyValuesContributor.global()) where
* possibly seeing the previous gum while another one binds is harmless.
*/
static @Nullable RainbowGum peek() {
return rainbowGum;
}
static @Nullable RainbowGum current() {
if (lock.readLock().tryLock()) {
try {
return rainbowGum;
}
finally {
lock.readLock().unlock();
}
}
return null;
}
static RainbowGum get() {
lock.readLock().lock();
try {
var r = rainbowGum;
if (r != null) {
return r;
}
}
finally {
lock.readLock().unlock();
}
if (lock.writeLock().isHeldByCurrentThread()) {
throw new IllegalStateException("RainbowGum component tried to log too early. "
+ "This is usually caused by dependencies calling logging.");
}
RainbowGum newlyStarted = null;
lock.writeLock().lock();
try {
var r = rainbowGum;
if (r != null) {
return r;
}
r = supplier.get();
start(r);
rainbowGum = r;
newlyStarted = r;
return r;
}
finally {
lock.writeLock().unlock();
/*
* Published outside of the write lock so a subscriber can never deadlock or
* trip the "tried to log too early" reentrancy guard above by, say, trying to
* read a volatile field it owns - it is not calling back into this class.
*/
if (newlyStarted != null) {
globalChangePublisher.publish(newlyStarted);
}
}
}
static boolean remove(@Nullable RainbowGum gum) {
lock.writeLock().lock();
try {
var original = rainbowGum;
if (gum != null) {
if (original != gum) {
return false;
}
}
/*
* Reset the global router
*/
if (original != null) {
LogRouter.global().close();
}
rainbowGum = null;
supplier = RainbowGumServiceProvider::provide;
return true;
}
finally {
lock.writeLock().unlock();
}
}
static void set(Supplier<RainbowGum> rainbowGumSupplier) {
Objects.requireNonNull(rainbowGumSupplier);
if (lock.writeLock().isHeldByCurrentThread()) {
throw new IllegalStateException("RainbowGum component tried to log too early. "
+ "This is usually caused by dependencies calling logging.");
}
lock.writeLock().lock();
try {
rainbowGum = null;
supplier = rainbowGumSupplier;
}
finally {
lock.writeLock().unlock();
}
}
private static void start(RainbowGum gum) {
Objects.requireNonNull(gum);
ShutdownManager.addShutdownHook(gum);
gum.start();
InternalRootRouter.setRouter(gum.router());
}
}
/*
* Unlike LogConfig.ChangePublisher/LogRouter.RouteChangePublisher (both scoped to a
* single RainbowGum/router instance and torn down with it) this is a JVM-lifetime static
* registry, so consumers are held only weakly - otherwise every SLF4J logger factory (or
* any other subscriber) ever created over the life of the JVM, e.g. across many
* short-lived RainbowGums in tests, would be pinned forever.
*/
final class GlobalChangePublisher {
private final Queue<WeakReference<Consumer<RainbowGum>>> consumers = new ConcurrentLinkedQueue<>();
void add(Consumer<RainbowGum> consumer) {
consumers.add(new WeakReference<>(consumer));
}
void publish(RainbowGum gum) {
for (var it = consumers.iterator(); it.hasNext();) {
var consumer = it.next().get();
if (consumer == null) {
it.remove();
}
else {
consumer.accept(gum);
}
}
}
}
final class SimpleRainbowGum implements RainbowGum, Shutdownable {
private final LogConfig config;
private final RootRouter router;
private final AtomicInteger state = new AtomicInteger(0);
private final UUID instanceId;
private static final int INIT = 0;
private static final int STARTED = 1;
private static final int CLOSED = 2;
public SimpleRainbowGum(LogConfig config, RootRouter router, UUID instanceId) {
super();
this.config = config;
this.router = router;
this.instanceId = instanceId;
}
@Override
public LogConfig config() {
return this.config;
}
@Override
public RootRouter router() {
return this.router;
}
@Override
public RainbowGum start() {
int current;
if ((current = state.compareAndExchange(INIT, STARTED)) == INIT) {
return RainbowGum.super.start();
}
throw new IllegalStateException("Cannot start. This rainbowgum is " + stateLabel(current));
}
@Override
public UUID instanceId() {
return this.instanceId;
}
@Override
public void close() {
if (state.compareAndSet(STARTED, CLOSED)) {
RainbowGumHolder.remove(this);
try {
shutdown();
}
finally {
ShutdownManager.removeShutdownHook(this);
}
return;
}
}
@Override
public void shutdown() {
router().close();
config().alerts().close();
config().serviceRegistry().close();
}
@Override
public String toString() {
return "SimpleRainbowGum [instanceId=" + instanceId + ", config=" + config + ", router=" + router + ", state="
+ stateLabel(state.get()) + "]";
}
private static String stateLabel(int state) {
return switch (state) {
case INIT -> "created";
case STARTED -> "started";
case CLOSED -> "closed";
default -> {
throw new IllegalArgumentException("" + state);
}
};
}
}