RainbowGumLoggerFactory.java

package io.jstach.rainbowgum.slf4j;

import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;

import org.slf4j.ILoggerFactory;
import org.slf4j.Logger;

import io.jstach.rainbowgum.KeyValues;
import io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeType;
import io.jstach.rainbowgum.LogEventFactory;
import io.jstach.rainbowgum.LogEventFactory.KeyValuesContributor;
import io.jstach.rainbowgum.LogEventLogger;
import io.jstach.rainbowgum.LogRouter.RootRouter;
import io.jstach.rainbowgum.LoggerAPI;
import io.jstach.rainbowgum.RainbowGum;
import io.jstach.rainbowgum.slf4j.spi.LoggerDecoratorService;
import io.jstach.rainbowgum.slf4j.spi.LoggerDecoratorService.DepthAwareLogger;

class RainbowGumLoggerFactory implements ILoggerFactory {

	private final ConcurrentMap<String, Logger> loggerMap;

	/*
	 * Spring Boot (and anything else with its own pre-boot bootstrap sequence) can
	 * install a bootstrap RainbowGum, have SLF4J's ServiceLoader-triggered initialize()
	 * capture it here, and only later RainbowGum.set(...) the "real" one - this factory
	 * would otherwise be stuck forever routing new getLogger(...) calls through the stale
	 * bootstrap instance's config/decorators. onGlobalChange (below) keeps this current
	 * for names looked up after the swap. Already-created Logger instances in loggerMap
	 * are a separate concern: ones allowed to change level (ReplaceableLogger, below) are
	 * kept current independently via the router's own RouteChangePublisher, which is how
	 * a bootstrap-era ReplaceableLogger stops pointing at the queued placeholder router
	 * once a real router replaces it (see subscribe()). Routing for loggers that were not
	 * allowed to change (LevelLogger) is not revisited after creation. Context
	 * contributors follow the current gum for every handler through ChangingContributor.
	 */
	private volatile RainbowGum rainbowGum;

	private final LoggerDecorator decorator;

	private final RainbowGumMDCAdapter mdc;

	private final ChangingContributor contributor;

	/*
	 * Held here (as opposed to inline at the subscribe call below) so this factory keeps
	 * it strongly reachable - RainbowGum.onGlobalChange only holds a weak reference, so
	 * an inline lambda with no other strong referrer could become eligible for GC almost
	 * immediately after subscribing. The checker flags this::setRainbowGum as an
	 * under-initialization risk (a method reference bound before the constructor
	 * finishes), but it's never actually invoked until well after construction - only
	 * when RainbowGum's global instance later changes, via the subscription registered in
	 * the constructor below.
	 */
	@SuppressWarnings("methodref.receiver.bound")
	private final Consumer<RainbowGum> onGlobalChange = this::setRainbowGum;

	public RainbowGumLoggerFactory(RainbowGum rainbowGum, RainbowGumMDCAdapter mdc) {
		super();
		this.loggerMap = new ConcurrentHashMap<>();
		this.rainbowGum = rainbowGum;
		this.decorator = LoggerDecorator.of(rainbowGum);
		this.mdc = mdc;
		this.contributor = new ChangingContributor(rainbowGum);
		RainbowGum.onGlobalChange(onGlobalChange);
	}

	private void setRainbowGum(RainbowGum gum) {
		contributor.setRainbowGum(gum);
		this.rainbowGum = gum;
	}

	@Override
	public Logger getLogger(String name) {
		Logger simpleLogger = loggerMap.get(name);
		if (simpleLogger != null) {
			return simpleLogger;
		}
		else {
			var currentRainbowGum = this.rainbowGum;
			currentRainbowGum.config().loggerRegistry().registerLoggerName(LoggerAPI.Standard.SLF4J, name);
			var router = currentRainbowGum.router();
			var changePublisher = currentRainbowGum.config().changePublisher();

			DepthAwareLogger newLogger;
			var level = router.levelResolver().resolveLevel(name);
			var allowedChanges = changePublisher.allowedChanges(name);
			/*
			 * callerInfoEnabled is resolved from its own logging.caller.<name> property,
			 * independent of allowedChanges/logging.change.<name> - fetched once here
			 * (not re-derived per branch) and threaded through to both maybeAddCallerInfo
			 * call sites and subscribe()'s captured closure, preserving the existing
			 * "decided once at construction, never revisited on change" semantics. It is
			 * also the same flag that decides, below, whether newLogger gets wrapped with
			 * LocationAwareForwardingLogger.
			 */
			boolean callerInfoEnabled = changePublisher.callerInfoEnabled(name);
			if (allowedChanges.contains(ChangeType.LEVEL)) {
				/*
				 * The level can change after this logger is created (that is the whole
				 * point of it being replaceable), so the sink must not be bound based on
				 * a level snapshot at creation time - level gating for this logger
				 * happens entirely inside ReplaceableLogger/LevelLogger instead, via
				 * setLevel().
				 */
				LogEventLogger logger = router.eventLogger(name);
				var handler = maybeAddCallerInfo(name, callerInfoEnabled, logger, 1 + CALLER_WRAP_DEPTH);
				var changeable = ReplaceableLogger.of(Levels.toSlf4jLevel(level), handler);
				subscribe(name, router, changeable, callerInfoEnabled);
				newLogger = changeable;
			}
			else {
				LogEventLogger logger = router.route(name, level);
				if (level == System.Logger.Level.OFF) {
					newLogger = new LevelLogger.OffLogger(name);
				}
				else {
					var slf4jLevel = Levels.toSlf4jLevel(level);
					LogEventHandler handler = maybeAddCallerInfo(name, callerInfoEnabled, logger, CALLER_WRAP_DEPTH);
					newLogger = LevelLogger.of(slf4jLevel, handler);
				}
			}
			if (callerInfoEnabled && newLogger instanceof HandlerSource hs) {
				newLogger = new LocationAwareForwardingLogger(newLogger, hs, name);
			}
			Logger decorated = decorator.decorate(currentRainbowGum, newLogger);
			Logger oldInstance = loggerMap.putIfAbsent(name, decorated);
			return oldInstance == null ? decorated : oldInstance;
		}
	}

	/*
	 * router here is whatever RootRouter was current at getLogger() time - during Spring
	 * Boot's pre-boot phase that is LogRouter.global() itself (the bootstrap RainbowGum's
	 * router() literally is the global router facade), so this subscribes to the *same*
	 * RouteChangePublisher that GlobalLogRouter transfers its queued subscribers into
	 * once a real router replaces the placeholder queue (see
	 * InternalRootRouter.setRouter/GlobalLogRouter._drain). r (below) is that replacement
	 * router - re-fetching the sink from it, not just the level, is what stops an
	 * already-created ReplaceableLogger from continuing to dispatch into the
	 * now-abandoned queue after the swap. eventLogger(name), not route(name, level), is
	 * used deliberately: the level is applied via setLevel() on the line above instead,
	 * so gating it a second time at the sink would be redundant.
	 */
	private void subscribe(String name, RootRouter router, ReplaceableLogger changeable, boolean callerInfoEnabled) {
		router.onChange(new Consumer<RootRouter>() {

			@Override
			public void accept(RootRouter r) {
				var level = r.levelResolver().resolveLevel(name);
				changeable.setLevel(Levels.toSlf4jLevel(level));
				var logger = r.eventLogger(name);
				var handler = maybeAddCallerInfo(name, callerInfoEnabled, logger, 1 + CALLER_WRAP_DEPTH);
				changeable.setEventHandler(handler);
			}

		});
	}

	/*
	 * getLogger() wraps with LocationAwareForwardingLogger whenever callerInfoEnabled is
	 * true, and being a ForwardingLogger it adds exactly one extra frame to the plain
	 * (non-location-aware) SLF4J call path - see ForwardingLogger's default methods,
	 * which are one-line calls to delegate(). maybeAddCallerInfo's depth is only ever
	 * consulted when callerInfoEnabled is true (see below), which is the same condition
	 * that triggers that wrap, so adding this unconditionally here is safe even on paths
	 * (OffLogger) where the wrap never actually happens.
	 */
	private static final int CALLER_WRAP_DEPTH = 1;

	private LogEventHandler maybeAddCallerInfo(String loggerName, boolean callerInfoEnabled, LogEventLogger logger,
			int depth) {
		/*
		 * Every other registered context store (scoped key values, JBoss MDC, ...) goes
		 * underneath MDC, and the application's USER contributor above it. With nothing
		 * else registered the contributor hands back MDC's own unchanged.
		 */
		if (callerInfoEnabled) {
			return LogEventHandler.ofCallerInfo(loggerName, logger, mdc, depth, contributor);
		}
		return LogEventHandler.of(loggerName, logger, mdc, contributor);
	}

	/*
	 * Router callbacks can replace a bootstrap handler before the global gum change
	 * reaches this factory. All handlers share this delegate so those already created
	 * also receive the real configuration's contributors once it is bound.
	 */
	private static final class ChangingContributor implements KeyValuesContributor {

		private volatile KeyValuesContributor delegate;

		ChangingContributor(RainbowGum gum) {
			this.delegate = resolve(gum);
		}

		void setRainbowGum(RainbowGum gum) {
			delegate = resolve(gum);
		}

		private static KeyValuesContributor resolve(RainbowGum gum) {
			return KeyValuesContributor.of(gum.config().serviceRegistry(), KeyValuesContributor.Source.Standard.SLF4J);
		}

		@Override
		public KeyValues keyValues() {
			return delegate.keyValues();
		}

		@Override
		public KeyValues keyValues(KeyValues own) {
			return delegate.keyValues(own);
		}

		@Override
		public void clear() {
			delegate.clear();
		}

	}

	sealed interface LoggerDecorator {

		public Logger decorate(RainbowGum gum, Logger logger);

		public static LoggerDecorator of(RainbowGum gum) {
			var services = gum.config()
				.serviceRegistry()
				.find(LoggerDecoratorService.class)
				.stream()
				.sorted(Comparator.comparingInt(LoggerDecoratorService::order)
					.thenComparing(LoggerDecoratorService::name))
				.toList();
			if (services.isEmpty()) {
				return Noop.INSTANCE;
			}
			return new CompositeLoggerDecorator(services);
		}

		enum Noop implements LoggerDecorator {

			INSTANCE;

			@Override
			public Logger decorate(RainbowGum gum, Logger logger) {
				return logger;
			}

		}

		record CompositeLoggerDecorator(List<LoggerDecoratorService> services) implements LoggerDecorator {

			@Override
			public Logger decorate(RainbowGum gum, Logger logger) {

				int i = 0;
				for (var p : services) {
					if (!(logger instanceof DepthAwareLogger da)) {
						return logger;
					}
					var next = Objects.requireNonNull(p.decorate(gum, da, i));
					/*
					 * Deliberately identity, not equals(): "did this particular decorator
					 * actually replace the logger instance" (used to advance the depth
					 * index), not "is the new logger value-equal to the old one".
					 */
					@SuppressWarnings("ReferenceEquality")
					boolean replaced = next != logger;
					if (replaced) {
						i++;
					}
					logger = next;
				}
				return logger;
			}

		}

	}

}