LogEventFactory.java
package io.jstach.rainbowgum;
import java.lang.System.Logger.Level;
import java.lang.ref.WeakReference;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.LogEventFactory.KeyValuesContributor;
import io.jstach.rainbowgum.LogEventFactory.KeyValuesContributor.Source;
import io.jstach.rainbowgum.annotation.CaseChanging;
/**
* A dynamic version of {@link LogEvent}'s static "<code>of</code>" factory methods - same
* method shapes (level, message, ...), but {@link #timestamp()}, {@link #threadName()},
* {@link #threadId()} and {@link #loggerName()} are resolved through overridable methods
* instead of being passed in on every call/pulled from
* {@link Instant#now()}/{@link Thread#currentThread()} inline the way those static
* methods do. Those static methods are unaffected for now; this is a first step toward
* eventually replacing them.
* <p>
* Not being able to override thread name/thread id/timestamp is a real limitation for
* anything that needs deterministic events not tied to whichever real thread happens to
* construct them - tests especially, but possibly other reasons later. A test-specific
* implementation overriding those methods once gets an entire family of
* event-construction methods that are deterministic, rather than needing to thread fixed
* values through every call site.
* <p>
* Each arity of message argument has its own, distinctly-named method
* ({@link #eventNoArg(Level, String, KeyValues, Throwable) eventNoArg}/
* {@link #eventOneArg(Level, String, KeyValues, Object) eventOneArg}/
* {@link #eventTwoArg(Level, String, KeyValues, Object, Object) eventTwoArg}/
* {@link #eventArgs(Level, String, KeyValues, Object[], Throwable) eventArgs}) rather
* than being overloaded under one name and disambiguated only by parameter shape - this
* is meant to be the one place any facade implementation (SLF4J, JCL, JUL, ...) goes to
* build events correctly, and overload resolution across five-plus differently-shaped
* methods sharing a name reads confusingly at call sites once there are this many of
* them.
* <p>
* This holds no mutable per-instance state - implementations are expected to be immutable
* too. Implement it (or extend {@link #of(String)}'s returned instance's behavior by
* writing your own implementation) and override individual methods to customize how
* events get constructed.
*
* @apiNote a builder that in turn builds/configures a factory was considered but is not
* included (yet) - implementing this directly covers today's need (mainly tests and
* facade modules) with less machinery.
*/
public interface LogEventFactory {
/**
* Creates a factory bound to the given logger name, otherwise using default behavior
* for {@link #timestamp()}, {@link #threadName()}, {@link #threadId()} and
* {@link #messageFormatter()}.
* @param loggerName logger name every event created by the returned factory will
* have.
* @return factory.
*/
public static LogEventFactory of(String loggerName) {
return new DefaultLogEventFactory(loggerName);
}
/**
* Name of the logger every event created by this factory will have.
* @return logger name.
*/
String loggerName();
/**
* Timestamp to use for the next event created by this factory. Default is
* {@link Instant#now()}.
* @return timestamp.
*/
default Instant timestamp() {
return Instant.now();
}
/**
* Thread name to use for the next event created by this factory. Default is
* {@link Thread#getName() Thread.currentThread().getName()}.
* @return thread name.
* @apiNote this maybe empty and often is if virtual threads are used.
*/
default String threadName() {
return Thread.currentThread().getName();
}
/**
* Thread id to use for the next event created by this factory. Default is
* {@link Thread#threadId() Thread.currentThread().threadId()}.
* @return thread id.
*/
default long threadId() {
return Thread.currentThread().threadId();
}
/**
* Key values to use for the next event created by this factory when a caller does not
* supply an explicit {@link KeyValues}. Default is every {@link KeyValuesContributor}
* registered with the currently bound {@link RainbowGum} merged together (see
* {@link KeyValuesContributor#global(KeyValuesContributor.Source...)}), which is
* empty if none are registered or no Rainbow Gum is bound.
* @return key values.
*/
default KeyValues defaultKeyValues() {
return KeyValuesContributors.GLOBAL.keyValues();
}
/**
* Formatter to use for rendering a message when
* {@link LogEvent#formattedMessage(StringBuilder)} is called on events created by
* this factory that take arguments. Default is
* {@link LogMessageFormatter.StandardMessageFormatter#SLF4J}.
* @return message formatter.
* @apiNote a method rather than a parameter on the {@code eventXxx} methods below
* since this rarely changes per event - override it instead if a different formatter
* is needed.
*/
default LogMessageFormatter messageFormatter() {
return LogMessageFormatter.StandardMessageFormatter.SLF4J;
}
/**
* Creates a log event whose message is already formatted (no arguments). Corresponds
* to
* {@link LogEvent#of(Instant, String, long, Level, String, String, KeyValues, Throwable)}.
* @param level the logging level.
* @param formattedMessage the unformatted message.
* @param keyValues key values that come from MDC or an SLF4J Event Builder.
* @param throwable an exception if passed maybe <code>null</code>.
* @return event.
* @apiNote the message is already assumed to be formatted as no arguments are passed.
*/
default LogEvent eventNoArg(Level level, @Nullable String formattedMessage, KeyValues keyValues,
@Nullable Throwable throwable) {
return LogEvent.of(timestamp(), threadName(), threadId(), level, loggerName(), formattedMessage, keyValues,
throwable);
}
/**
* Like {@link #eventNoArg(Level, String, KeyValues, Throwable)} but using
* {@link #defaultKeyValues()}.
* @param level the logging level.
* @param formattedMessage the unformatted message.
* @param throwable an exception if passed maybe <code>null</code>.
* @return event.
*/
default LogEvent eventNoArg(Level level, @Nullable String formattedMessage, @Nullable Throwable throwable) {
return eventNoArg(level, formattedMessage, defaultKeyValues(), throwable);
}
/**
* Creates a log event with a single message argument. Corresponds to
* {@link LogEvent#ofOneArg(Instant, String, long, Level, String, String, KeyValues, LogMessageFormatter, Object)}.
* @param level the logging level.
* @param message the unformatted message.
* @param keyValues key values that come from MDC or an SLF4J Event Builder.
* @param arg1 argument that will be passed to {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventOneArg(Level level, @Nullable String message, KeyValues keyValues, @Nullable Object arg1) {
Instant timestamp = timestamp();
String threadName = threadName();
long threadId = threadId();
String loggerName = loggerName();
var messageFormatter = messageFormatter();
return LogEvent.ofOneArg(timestamp, threadName, threadId, level, loggerName, message, keyValues,
messageFormatter, arg1);
}
/**
* Like {@link #eventOneArg(Level, String, KeyValues, Object)} but using
* {@link #defaultKeyValues()}.
* @param level the logging level.
* @param message the unformatted message.
* @param arg1 argument that will be passed to {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventOneArg(Level level, @Nullable String message, @Nullable Object arg1) {
return eventOneArg(level, message, defaultKeyValues(), arg1);
}
/**
* Creates a log event with two message arguments. Corresponds to
* {@link LogEvent#ofTwoArgs(Instant, String, long, Level, String, String, KeyValues, LogMessageFormatter, Object, Object)}.
* @param level the logging level.
* @param message the unformatted message.
* @param keyValues key values that come from MDC or an SLF4J Event Builder.
* @param arg1 argument that will be passed to {@link #messageFormatter()}.
* @param arg2 argument that will be passed to {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventTwoArg(Level level, @Nullable String message, KeyValues keyValues, @Nullable Object arg1,
@Nullable Object arg2) {
Instant timestamp = timestamp();
String threadName = threadName();
long threadId = threadId();
String loggerName = loggerName();
var messageFormatter = messageFormatter();
return LogEvent.ofTwoArgs(timestamp, threadName, threadId, level, loggerName, message, keyValues,
messageFormatter, arg1, arg2);
}
/**
* Like {@link #eventTwoArg(Level, String, KeyValues, Object, Object)} but using
* {@link #defaultKeyValues()}.
* @param level the logging level.
* @param message the unformatted message.
* @param arg1 argument that will be passed to {@link #messageFormatter()}.
* @param arg2 argument that will be passed to {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventTwoArg(Level level, @Nullable String message, @Nullable Object arg1, @Nullable Object arg2) {
return eventTwoArg(level, message, defaultKeyValues(), arg1, arg2);
}
/**
* Creates a log event with an array of message arguments. Corresponds to
* {@link LogEvent#ofAll(Instant, String, long, Level, String, String, KeyValues, Throwable, LogMessageFormatter, Object[])}.
* @param level the logging level.
* @param message the unformatted message.
* @param keyValues key values that come from MDC or an SLF4J Event Builder.
* @param args an array of arguments that will be passed to
* {@link #messageFormatter()}. The contents maybe null elements but the array itself
* should not be null.
* @param throwable an exception if passed maybe <code>null</code>.
* @return event.
*/
default LogEvent eventArgs(Level level, @Nullable String message, KeyValues keyValues,
@SuppressWarnings("exports") @Nullable Object @Nullable [] args, @Nullable Throwable throwable) {
return LogEvent.ofAll(timestamp(), threadName(), threadId(), level, loggerName(), message, keyValues, throwable,
messageFormatter(), args);
}
/**
* Like {@link #eventArgs(Level, String, KeyValues, Object[], Throwable)} with no
* throwable.
* @param level the logging level.
* @param message the unformatted message.
* @param keyValues key values that come from MDC or an SLF4J Event Builder.
* @param args an array of arguments that will be passed to
* {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventArgs(Level level, @Nullable String message, KeyValues keyValues,
@SuppressWarnings("exports") @Nullable Object @Nullable [] args) {
return eventArgs(level, message, keyValues, args, null);
}
/**
* Like {@link #eventArgs(Level, String, KeyValues, Object[], Throwable)} but using
* {@link #defaultKeyValues()}.
* @param level the logging level.
* @param message the unformatted message.
* @param args an array of arguments that will be passed to
* {@link #messageFormatter()}.
* @param throwable an exception if passed maybe <code>null</code>.
* @return event.
*/
default LogEvent eventArgs(Level level, @Nullable String message,
@SuppressWarnings("exports") @Nullable Object @Nullable [] args, @Nullable Throwable throwable) {
return eventArgs(level, message, defaultKeyValues(), args, throwable);
}
/**
* Like {@link #eventArgs(Level, String, KeyValues, Object[], Throwable)} but using
* {@link #defaultKeyValues()} and no throwable.
* @param level the logging level.
* @param message the unformatted message.
* @param args an array of arguments that will be passed to
* {@link #messageFormatter()}.
* @return event.
*/
default LogEvent eventArgs(Level level, @Nullable String message,
@SuppressWarnings("exports") @Nullable Object @Nullable [] args) {
return eventArgs(level, message, defaultKeyValues(), args, null);
}
/**
* A source of ambient key values (MDC like context) for events whose logging API has
* no context of its own, such as JUL or {@link System.Logger}. Each supported store
* of ambient key values registers one with
* {@link #register(ServiceRegistry, Source, KeyValuesContributor)}, usually from a
* {@link io.jstach.rainbowgum.spi.RainbowGumServiceProvider.Configurator}.
* <p>
* Only the stores listed in {@link Source.Standard} can contribute. When more than
* one is registered they are merged in the declaration order of
* {@link Source.Standard}, so on a key collision the later constant wins. A logging
* facade that has its own context store excludes its own {@link Source} when
* resolving the rest and passes its own key values to {@link #keyValues(KeyValues)},
* which places them above every other source except {@link Source.Standard#USER}.
*
* <p>
* {@link #clear()} clears the current thread's context in every enabled store and
* disabled stores whose {@link Source.Standard#clearWhenDisabled()} policy permits
* it, for example when a pooled thread finishes a task:
* {@snippet :
* KeyValuesContributor.global().clear();
* }
* Appender buffers kept per thread are cleared separately with
* {@link LogAppender#clearThreadLocals()}.
*
* @apiNote Rainbow Gum core deliberately has no API for putting key values into a
* context. Contributors only expose (and clear) whatever context store the
* application already uses.
*/
@FunctionalInterface
public interface KeyValuesContributor {
/**
* Key values for an event being created on the current thread. Called once per
* event on the logging thread so it should be cheap. It must not throw or log and
* should return {@link KeyValues#of()} when there is nothing to contribute. The
* returned key values may be live (not a copy) as long as they are not modified
* while the event is being created.
* @return key values, never {@code null}.
*/
KeyValues keyValues();
/**
* Like {@link #keyValues()} but with a logging facade's own key values merged in
* above every contributor except {@link Source.Standard#USER}, which still wins.
* @param own the calling facade's own context, for example its MDC.
* @return merged key values, which is {@code own} itself when nothing else
* contributes.
*/
default KeyValues keyValues(KeyValues own) {
return KeyValues.merge(keyValues(), own);
}
/**
* Clears all of the current thread's state in the underlying context store,
* including state that is not exposed as key values such as a nested diagnostic
* context (NDC) stack, so a pooled thread starts its next task with no context
* left over. A store that cannot be cleared this way, such as one bound to a
* scope, does nothing. Like {@link #keyValues()} it must not throw or log. The
* default does nothing.
* @see LogAppender#clearThreadLocals()
*/
default void clear() {
}
/**
* Identifies a context store that contributes key values.
*
* @apiNote sealed so that contributions come only from known stores: an unknown
* third party contributor could silently change or break the key values of every
* event.
*/
public sealed interface Source permits Source.Standard {
/**
* The context stores Rainbow Gum supports, in merge order: a later constant
* wins over an earlier one on a key collision.
*/
@CaseChanging
enum Standard implements Source {
/**
* Key values added to every event, such as a build version or instance
* id, which every other source overrides on a key collision. Like
* {@link #USER} it belongs to the application;
* {@code rainbowgum-keyvalues} provides one configured by properties.
*/
DEFAULTS(false),
/**
* {@code rainbowgum-opentelemetry}: the trace and span id of the current
* OpenTelemetry span. Not cleared: the OpenTelemetry context belongs to
* the application, not to Rainbow Gum.
*/
OPENTELEMETRY(false),
/**
* {@code rainbowgum-scopedkeyvalues} ({@code ScopedKeyValues}).
*/
SCOPED_KEY_VALUES(false),
/**
* {@code rainbowgum-jboss-logging} ({@code org.jboss.logging.MDC}).
*/
JBOSS_LOGGING(true),
/**
* {@code rainbowgum-log4j2}
* ({@code org.apache.logging.log4j.ThreadContext}).
*/
LOG4J2(true),
/**
* {@code rainbowgum-slf4j} ({@code org.slf4j.MDC}).
*/
SLF4J(true),
/**
* The application's own contributor, which wins over every other source
* including a logging facade's own context store. Only for applications:
* a library must not register a {@code USER} contributor, since there is
* only one per Rainbow Gum and the application decides what it is.
*/
USER(false);
private final boolean clearWhenDisabled;
Standard(boolean clearWhenDisabled) {
this.clearWhenDisabled = clearWhenDisabled;
}
/**
* Whether this source's contributor should still be cleared when its
* contribution is disabled with
* {@value LogProperties#KEY_VALUES_DISABLED_PROPERTY}. Logging facades
* still use their own context stores when their contributions to other
* facades are disabled. Application callbacks and stores with no
* clearable thread state are not invoked when disabled.
* @return whether to clear the contributor when disabled.
*/
public boolean clearWhenDisabled() {
return clearWhenDisabled;
}
}
}
/**
* Registers the contributor for a context store. A store that is already
* registered is not replaced.
* @param registry usually {@link LogConfig#serviceRegistry()}.
* @param source which context store the contributor exposes.
* @param contributor the contributor.
*/
public static void register(ServiceRegistry registry, Source source, KeyValuesContributor contributor) {
registry.put(KeyValuesContributor.class, KeyValuesContributors.registryName(source), contributor);
}
/**
* Combines the contributors currently registered in a service registry, skipping
* the excluded sources and those disabled with
* {@value LogProperties#KEY_VALUES_DISABLED_PROPERTY}. The registry is read once
* on this call, so contributors registered afterward are not included.
* @param registry where contributors are registered.
* @param excluded sources to skip, usually the caller's own.
* @return combined contributor, which returns {@link KeyValues#of()} if nothing
* is registered.
*/
public static KeyValuesContributor of(ServiceRegistry registry, Source... excluded) {
return KeyValuesContributors.of(registry, Set.of(excluded));
}
/**
* Combines the contributors registered with whichever {@link RainbowGum} is bound
* globally at the time key values are requested, skipping the excluded sources.
* Meant for logging facades that route through {@link LogRouter#global()} and so
* cannot hold a reference to a particular Rainbow Gum.
* @param excluded sources to skip, usually the caller's own.
* @return contributor that follows the globally bound Rainbow Gum and returns
* {@link KeyValues#of()} while none is bound.
*/
public static KeyValuesContributor global(Source... excluded) {
if (excluded.length == 0) {
return KeyValuesContributors.GLOBAL;
}
return new KeyValuesContributors.Global(Set.of(excluded));
}
}
}
record DefaultLogEventFactory(String loggerName) implements LogEventFactory {
}
final class KeyValuesContributors {
static final Global GLOBAL = new Global(Set.of());
private KeyValuesContributors() {
}
static String registryName(Source source) {
return switch (source) {
case Source.Standard s -> s.name();
};
}
/*
* The sources disabled by property, parsed and validated when the config is built and
* kept in its service registry so that combining contributors needs only the
* registry.
*/
record Disabled(Set<Source> sources) {
static final Disabled NONE = new Disabled(Set.of());
static Disabled of(LogProperties properties) {
var sources = properties.forKey(LogProperties.KEY_VALUES_DISABLED_PROPERTY)
.ofList()
.map(Disabled::parse)
.or(Set.of())
.validateNow(LogConfig.class);
return sources.isEmpty() ? NONE : new Disabled(sources);
}
private static Set<Source> parse(List<String> names) {
var sources = new LinkedHashSet<Source>();
for (String name : names) {
sources.add(LogProperty.enumValue(Source.Standard.class, name));
}
return Set.copyOf(sources);
}
}
static KeyValuesContributor of(ServiceRegistry registry, Set<Source> excluded) {
var below = new ArrayList<KeyValuesContributor>();
var clearable = new ArrayList<KeyValuesContributor>();
KeyValuesContributor user = null;
var disabled = registry.findOrNull(Disabled.class);
for (var source : Source.Standard.values()) {
if (excluded.contains(source)) {
continue;
}
var c = registry.findOrNull(KeyValuesContributor.class, registryName(source));
if (c == null) {
continue;
}
boolean isDisabled = disabled != null && disabled.sources().contains(source);
if (!isDisabled || source.clearWhenDisabled()) {
clearable.add(c);
}
if (isDisabled) {
continue;
}
if (source == Source.Standard.USER) {
user = c;
}
else {
below.add(c);
}
}
if (below.isEmpty() && user == null && clearable.isEmpty()) {
return Empty.INSTANCE;
}
return new Composite(below.toArray(new KeyValuesContributor[0]), user,
clearable.toArray(new KeyValuesContributor[0]));
}
enum Empty implements KeyValuesContributor {
INSTANCE;
@Override
public KeyValues keyValues() {
return KeyValues.of();
}
@Override
public KeyValues keyValues(KeyValues own) {
return own;
}
}
/*
* Merge order, lowest first: the non USER contributors in Source.Standard order, the
* caller's own key values, then USER.
*/
static final class Composite implements KeyValuesContributor {
private final KeyValuesContributor[] below;
private final @Nullable KeyValuesContributor user;
private final KeyValuesContributor[] clearable;
Composite(KeyValuesContributor[] below, @Nullable KeyValuesContributor user, KeyValuesContributor[] clearable) {
this.below = below;
this.user = user;
this.clearable = clearable;
}
@Override
public KeyValues keyValues() {
return keyValues(KeyValues.of());
}
@Override
public KeyValues keyValues(KeyValues own) {
KeyValues result = KeyValues.of();
for (var c : below) {
result = KeyValues.merge(result, c.keyValues());
}
result = KeyValues.merge(result, own);
var u = user;
if (u != null) {
result = KeyValues.merge(result, u.keyValues());
}
return result;
}
@Override
public void clear() {
for (var c : clearable) {
c.clear();
}
}
}
/*
* Resolves the registered contributors once per bound gum instead of per event. The
* registry is complete by the time a gum is bound since configurators run before.
*/
static final class Global implements KeyValuesContributor {
private record Resolved(RainbowGum gum, KeyValuesContributor contributor) {
}
private final Set<Source> excluded;
private final String registryName;
private volatile WeakReference<Resolved> resolved = new WeakReference<>(null);
Global(Set<Source> excluded) {
this.excluded = excluded;
this.registryName = excluded.stream()
.map(KeyValuesContributors::registryName)
.sorted()
.collect(Collectors.joining(","));
}
@Override
public KeyValues keyValues() {
return contributor().keyValues();
}
@Override
public KeyValues keyValues(KeyValues own) {
return contributor().keyValues(own);
}
@Override
public void clear() {
contributor().clear();
}
@SuppressWarnings("ReferenceEquality") // identity of the bound gum is the point
private KeyValuesContributor contributor() {
var gum = RainbowGumHolder.peek();
if (gum == null) {
return Empty.INSTANCE;
}
var r = resolved.get();
if (r == null || r.gum() != gum) {
var registry = gum.config().serviceRegistry();
/*
* The gum owns the strong reference, so an idle global contributor cannot
* retain a closed gum or its context stores. While bound, the weak cache
* stays live and avoids a registry lookup on every event.
*/
r = registry.putIfAbsent(Resolved.class, registryName, () -> new Resolved(gum, of(registry, excluded)));
resolved = new WeakReference<>(r);
}
return r.contributor();
}
}
}