JULBridge.java

package io.jstach.rainbowgum.jul;

import java.time.Instant;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.logging.LogRecord;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.KeyValues;
import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogEventFactory.KeyValuesContributor;
import io.jstach.rainbowgum.LogMessageFormatter.StandardMessageFormatter;
import io.jstach.rainbowgum.LogRouter;

/**
 * Translates {@code java.util.logging.LogRecord} into Rainbow Gum
 * {@link io.jstach.rainbowgum.LogEvent} and routes it through {@link LogRouter#global()}.
 * Shared by two independent JUL integration strategies: this module's own
 * {@link java.util.logging.Handler}-based bridge ({@code SystemLoggerQueueJULHandler},
 * installed on the default JUL root logger), and the separate
 * <code>rainbowgum-jul-logmanager</code> module's {@code java.util.logging.LogManager}
 * replacement (which routes every {@code java.util.logging.Logger} call directly,
 * bypassing JUL's normal Handler/Level dispatch entirely). Kept here, exported, so
 * neither integration duplicates the level mapping or event construction.
 */
public final class JULBridge {

	private JULBridge() {
	}

	/*
	 * JUL has no context of its own so every registered contributor (SLF4J MDC, scoped
	 * key values, ...) applies.
	 */
	private static final KeyValuesContributor KEY_VALUES = KeyValuesContributor.global();

	private static final int TRACE_LEVEL_THRESHOLD = java.util.logging.Level.FINEST.intValue();

	private static final int DEBUG_LEVEL_THRESHOLD = java.util.logging.Level.FINE.intValue();

	private static final int INFO_LEVEL_THRESHOLD = java.util.logging.Level.INFO.intValue();

	private static final int WARN_LEVEL_THRESHOLD = java.util.logging.Level.WARNING.intValue();

	/**
	 * Maps a {@code java.util.logging.Level} to the closest
	 * {@link java.lang.System.Logger.Level}, the same threshold buckets JUL's own builtin
	 * levels (FINEST/FINE/INFO/WARNING/SEVERE) fall into, so custom intermediate levels
	 * still translate sensibly.
	 * @param level JUL level, including custom levels with arbitrary
	 * {@link java.util.logging.Level#intValue()}.
	 * @return closest matching System.Logger level.
	 */
	public static System.Logger.Level toLevel(java.util.logging.Level level) {
		int lv = level.intValue();
		if (Integer.MIN_VALUE == lv) {
			return System.Logger.Level.TRACE;
		}
		else if (TRACE_LEVEL_THRESHOLD >= lv) {
			return System.Logger.Level.TRACE;
		}
		else if (DEBUG_LEVEL_THRESHOLD >= lv) {
			return System.Logger.Level.DEBUG;
		}
		else if (INFO_LEVEL_THRESHOLD >= lv) {
			return System.Logger.Level.INFO;
		}
		else if (WARN_LEVEL_THRESHOLD >= lv) {
			return System.Logger.Level.WARNING;
		}
		else if (Integer.MAX_VALUE == lv) {
			return System.Logger.Level.OFF;
		}
		return System.Logger.Level.ERROR;
	}

	/**
	 * Whether a logger name at a given JUL level is currently enabled, per Rainbow Gum's
	 * own level resolution rather than anything cached on a JUL
	 * {@code Logger}/{@code Handler}.
	 * @param loggerName logger name, as JUL would pass to
	 * {@link java.util.logging.Logger#getName()}.
	 * @param level JUL level being checked.
	 * @return true if a record at this name/level would actually be routed.
	 */
	public static boolean isLoggable(String loggerName, java.util.logging.Level level) {
		return LogRouter.global().route(loggerName, toLevel(level)).isEnabled();
	}

	/**
	 * Translates a JUL {@link LogRecord} into a Rainbow Gum event and routes it, if the
	 * record's logger name/level is currently enabled. A {@code null} record or a record
	 * with a {@code null} logger name is silently ignored, matching
	 * {@link java.util.logging.Handler#publish(LogRecord)}'s own contract for
	 * {@code null}.
	 * @param rec JUL log record, possibly {@code null}.
	 */
	public static void publish(@Nullable LogRecord rec) {
		if (rec == null) {
			return;
		}
		String loggerName = rec.getLoggerName();
		if (loggerName == null) {
			return;
		}
		var level = toLevel(rec.getLevel());
		var router = LogRouter.global();
		var route = router.route(loggerName, level);
		if (!route.isEnabled()) {
			return;
		}
		@Nullable Throwable cause = rec.getThrown();
		@Nullable String msg = getMessage(rec);
		var args = rec.getParameters();

		Instant timestamp = rec.getInstant();
		long threadId = rec.getLongThreadID();
		String threadName = "";
		/*
		 * Ambient key values are only meaningful on the thread that made the record. A
		 * handler publishing on another thread (async) would otherwise attach the
		 * publishing thread's context to someone else's record.
		 */
		KeyValues keyValues = KeyValues.of();
		long currentThreadId = Thread.currentThread().threadId();
		if (currentThreadId == threadId) {
			threadName = Thread.currentThread().getName();
			keyValues = KEY_VALUES.keyValues();
		}
		// TODO fix caller info
		var event = LogEvent.ofAll(timestamp, threadName, threadId, level, loggerName, msg, keyValues, cause,
				StandardMessageFormatter.JUL, args);
		route.log(event);
	}

	private static @Nullable String getMessage(LogRecord record) {
		String message = record.getMessage();
		if (message == null) {
			return null;
		}
		ResourceBundle bundle = record.getResourceBundle();
		if (bundle != null) {
			try {
				return bundle.getString(message);
			}
			catch (MissingResourceException e) {
				// no translation found - fall through and use the raw message instead
			}
		}
		return message;
	}

}