Rainbow Gum 1.0.0-SNAPSHOT
User Guide
Rainbow Gum: JDK 21+ SLF4J logging implementationFast, modular, GraalVM native friendly and easy to use.
Contents
Getting Started
First step is to add Rainbow Gum as a dependency.
Second if you are not familiar with logging in the Java ecosystem please refer to
the excellent Logging facade SLF4J documentation
particularly the logger API.
What logging levels are and how dotted logger names inherit levels is covered in
the JDKs logging facade System.Logger.Level as well as
Logback effective level
which Rainbow Gum follows.
Using Spring Boot
If using Spring Boot see the Rainbow Spring Boot Integration section. The short version: swapspring-boot-starter-logging for the Rainbow Gum starter and keep going: Rainbow Gum
natively understands almost all of Spring Boot's own documented logging properties (logging.level.*,
log groups, logging.file.*, logging.pattern.*, logging.charset.*, ANSI
control, and structured logging via logging.structured.format.* for ECS/GELF/Logstash), so existing
application.properties/application.yml logging config generally keeps working as-is with
no application code changes. That makes it a genuinely low-risk drop-in replacement for Logback:
same properties, same behavior, a simpler and faster implementation underneath.
Using System properties
Assuming we have installed RainbowGum as a dependency a simple configuration example using System Properties is below. Thepattern encoder needs rainbowgum-pattern as well (see
Installation); without it, leave out the two encoder lines to get the
default TTLL format.
java \
-Dlogging.appender.console.encoder.uri=pattern \
-Dlogging.appender.console.encoder.pattern.pattern="[%thread] %-5level %logger{15} - %msg%n" \
-Dlogging.level.com.myapp=DEBUG \
-jar myapp.jar
Using RainbowGum Builders
For technical organizations that have many projects it is recommended to use the programmatic builder approach and make a standardized jar (module) shared between all your projects so that logging initialization and configuration is consistent. Assuming we have installed RainbowGum as a dependency a simple configuration example using Java directly and theServiceLoader is below. It needs rainbowgum-core and
rainbowgum-pattern at compile scope (see Installation).
package snippets;
import java.lang.System.Logger.Level;
import java.util.Optional;
import io.jstach.rainbowgum.LogConfig;
import io.jstach.rainbowgum.LogOutput;
import io.jstach.rainbowgum.RainbowGum;
import io.jstach.rainbowgum.pattern.format.PatternEncoderBuilder;
import io.jstach.rainbowgum.spi.RainbowGumServiceProvider.RainbowGumProvider;
public class GettingStartedExample implements RainbowGumProvider {
@Override
public Optional<RainbowGum> provide(LogConfig config) {
return RainbowGum.builder(config) //
.route(r -> {
r.level(Level.DEBUG, "com.myapp");
r.appender("console", a -> {
a.output(LogOutput.ofStandardOut());
a.encoder(new PatternEncoderBuilder("console")
// We use the pattern encoder which follows logback pattern
// syntax.
.pattern("[%thread] %-5level %logger{15} - %msg%n")
// We use properties to override the above pattern if set.
.fromProperties(config.properties())
.build());
});
}) //
.optional();
}
}
ServiceLoader, so register it in
META-INF/services/io.jstach.rainbowgum.spi.RainbowGumServiceProvider as a line with its
fully qualified name (snippets.GettingStartedExample here), or with
provides io.jstach.rainbowgum.spi.RainbowGumServiceProvider with snippets.GettingStartedExample;
in a module-info.java. RainbowGumServiceProvider has additional
documentation on registering a service loader aware jar.
Using rainbowgum-simple
For tests and small applications, or when you prefer the traditional method of letting the logging system handle loading its own configuration, addrainbowgum-simple and logging works without configuration. Add
logging.properties to
src/test/resources (or src/main/resources for an application)
when you need to customize it. See Simple logging and properties
for the Maven dependency and profile options.
Description
Rainbow Gum is a secure, GraalVM native and jlink friendly, modular JDK 21+ SLF4J implementation that aims to be easier to use while leveraging newer JDK technology. Rainbow Gum unlike Logback or Log4J (2) does not offer as much flexibility but is simpler and has less overhead. The readme in the Rainbow Gum project discusses more extensively on the opinionated design and philosophy.Project Information
- Source Control
- https://github.com/jstachio/rainbowgum
- Team
-
- Adam Gent (agentgt), lead
- Issues
- https://github.com/jstachio/rainbowgum/issues
- Community
- https://github.com/jstachio/rainbowgum/discussions
- User Guide
- This document
- Javadoc
- This document (modules listing at bottom)
Requirements
- Java 21 or greater
- A build system that supports running the Java compiler annotation processor
java.base
Limitations
Currently Rainbow Gum does not provide support for:- Default external config file (e.g. log4j2.xml). This is by design; Rainbow Gum provides lots of extensions to use your applications configuration system, and system properties are supported out of the box.
- Calendar/date based log file rotation or compression on a schedule; there is a deliberately basic built-in size based rolling output for keeping a small/desktop app's disk from filling up, and full support for rolling without losing events using an external tool such as logrotate, see the Rolling Files section.
- SLF4J Marker support (libraries and applications rarely use it compared to MDC).
- It is actually fairly expensive to load resources from the module/classpath on initialization. If your configuration system is already loading a resource it should be used instead.
- In GraalVM native and in some cases modular environment loading module/classpath resources is more complicated. We do not want an out of the box experience where it works in normal HotSpot but not in GraalVM native.
- It increases the security surface and Rainbow Gum aims to have security and integrity by default. Implicit loading of resources we feel is against this and if done should be a chosen opt-in which you can have with various extensions.
How it works
Most users will use Rainbow Gum through a logging facade such as SLF4J and the only interaction with Rainbow Gum is configuration of output and formatting. Rainbow Gum provides three ways to configure out of the box:- Simple String key/value properties often derived from System properties / env variables
- Programmatic Java configuration using builders.
- Dependency driven configuration where including a jar as dependency changes behavior automatically
Architecture
Rainbow Gum's design has many superficial similarities to Logback, Log4j2, and Reload4j in that there are Appenders and Encoders as well as how log levels are inherited but most of Rainbow Gum's design is very different!The key difference in Rainbow Gum is that once configured and initialized the logging system is locked in and cannot be changed. Certain parts of the system can be changed at runtime but require opt-in which includes changing levels of logger names.
Another major difference is that Rainbow Gum is highly modularized, immutable and componentized. In other frameworks
OOP inheritance is heavily abused and riddled with state management. Furthermore there are components that
have too much responsibility which is the case with Appenders in Logback or "Managers" in Log4j2.
Furthermore Rainbow Gum uses zero reflection other than the Service Loader and follows the builder pattern
extensively to separate configuration from immutable runtime components. Almost all components have
a builder to programmatically configure and the builders can take flat string key values as configuration.
The other logging frameworks use an enormous amount of reflection which slows initialization time.
(While the ServiceLoader is technically reflection it is GraalVM native friendly and
is the preferred way for pluggable components in modern JDKs.)
The results of the above choices make Rainbow Gum far lighter than logback, log4j2, and reload4j particularly in initialization time as well as security surface area.
The key components expressed in the flow of log events is as follows:- Logging Facade (SLF4J)
RainbowGumLogRouterLogPublisherLogAppenderLogEncoderLogOutput
RainbowGum's own documentation.
In the next sections we will cover
Config,
Alerts, Metrics, and Reporting,
Publishers,
Appenders,
Encoders,
and
Outputs.
Roughly the hiearchy (through composition) of components is:
- Routes have a single publisher a level resolver and a list of appenders.
- Publishers are given the list of appenders from the route on start.
- Appenders have a single encoder and output.
Config
A defining characteristic of Rainbow Gum is that it does not have a special configuration format like Logback, Log4j2, and Reload4j (all three use XML as the default). The expectation is that for simple configuration simple properties interface analogous toFunction<String,String> is good enough. For more complicated configuration
programmatic configuration using the builders and service loader should be used.
For implementers of plugins LogConfig allows registering of services
and plugins (it also contains the global LogProperties ) however
most users will not need to know about it. For those coming from Logback
LogConfig is analogous to what Logback calls "Context".
Properties based
Rainbow Gum out of the box uses System properties forLogProperties
but several optional modules provide other properties sources. Other configuration systems will be added
in the future but an important requirement is that these systems do not do any logging or if they do allow
it to be turned off, intercepted or blocked.
The current optional properties modules are:
- rainbowgum-simple-props (
io.jstach.rainbowgum.simple.props): loadslogging.propertiesfiles with profiles and a level file, without any third party dependency. rainbowgum-avaje-config(io.jstach.rainbowgum.avaje): uses Avaje Config as the properties provider.
LogProperties
backing implementation and the values retrieved should already be interpolated.
NOTE:
Throughout the documentation Properties and URI syntax is used to express
configurable properties but it is a not a requirement and
your configuration system may have a different format like YAML or JSON.
An appender's encoder and output are configured under the appender itself: uri picks the
type, and the type's own settings sit under its URI scheme, so one appender's settings line up in one block:
logging.appender.{name}.encoder.uri=URI
logging.appender.{name}.encoder.{scheme}.{propertyName}=someStringThatIsConverted
logging.appender.{name}.output.uri=URI
logging.appender.{name}.output.{scheme}.{propertyName}=someStringThatIsConverted
logging.appender.console.encoder.uri=json5
logging.appender.console.encoder.json5.prettyPrint=spacing
logging.appender.console.output.uri=stdout
logging.appender.console.output.stdout.stream=follow
uri to another type leaves the old type's settings
unread rather than misread.
An encoder or output built in code without an appender uses the same keys with its own name in place of the
appender's.
An interpolated key (note key not value) example might be:
logging.appender.example.encoder.uri=gelf
logging.appender.example.encoder.gelf.host=myhost
logging.appender.example.encoder.gelf.prettyPrint=true
gelf is actually gelf:///
Because the first property is a URI, its query string is also searched for the other lines' properties,
using {propertyName} as a bare query parameter with no
logging.appender.{name}.encoder.{scheme}. (or logging.appender.{name}.output.{scheme}.) prefix. So instead of two separate properties, one can write everything on the URI line:
logging.appender.example.encoder.uri=gelf:///?host=myhost&prettyPrint=true
encoder/gelf: every URI-configured sub-component looks
its own properties up the same way. The built-in rolling file output is another example:
logging.appenders=myapp
logging.appender.myapp.output.uri=rolling:///var/log/app.log?maxFileSize=10MB
# the above is equivalent to the more verbose:
logging.appender.myapp.output.uri=rolling:///var/log/app.log
logging.appender.myapp.output.rolling.maxFileSize=10MB
logging.appender.example.encoder.gelf.prettyPrint and a ?prettyPrint= query parameter on the
encoder URI are set, the plain property wins and the URI's value is ignored. This lets a URI act as a
terse, single-line default that a separately set property can still override without editing the URI
itself. This is not configurable: every LogBuilder resolves its properties
this way via LogProperties.of(java.net.URI, String, io.jstach.rainbowgum.LogProperties).
URIs play an important part for component provision as the URI scheme
is used for plugin lookup as well as single line configuration. This follows
12 factor recommendation of resources and configuration.
Plural keys for enabling/disabling
Because Rainbow Gum's property config model is a lookup of key to value and not a Map like collection special plural keys are used to indicate what is activated. These keys canonical end in"s" and are comma separated.
# appenders that are enabled.
logging.appenders=appender1,appender2
logging.appender.appender1.encoder.uri=...
logging.appender.appender2.output.uri=...
logging.appender.appender3.output.uri=....
appender1,${optional}) still works, while an empty element in the middle is kept and reported by
whatever reads the list. Map properties are either a single value (a=1&b=2) or, for sources that
only hold flat values such as environment variables, a list of keys with one property per value:
logging.appender.myappender.encoder.gelf.headers.keys=environment,region
logging.appender.myappender.encoder.gelf.headers.environment=prod
logging.appender.myappender.encoder.gelf.headers.region=us east
appender3 will not be used
and will not produce a configuration error if incorrect. This pattern
allows large groups of configuration to be turned on or off similar to
profiles in other systems.
Implicit default configuration
Rainbow Gum will happily start with zero properties set because most of the properties described above have a built-in default when missing. Nothing here is hidden magic though: the defaults are just what get used when a property lookup falls through to code instead of a configured value, and that same "out of the box" setup can be reproduced explicitly with:logging.level=INFO
logging.routes=default
logging.route.default.publisher.uri=sync
# logging.appenders is shorthand for logging.route.default.appenders: it only
# works as a fallback for the "default" route, not any other route name.
logging.appenders=console
logging.appender.console.output.uri=stdout
logging.appender.console.encoder.uri=ttll
-
logging.appendersdefaults to justconsole, notconsole,file. A file appender is only ever added automatically, and only when "logging.file.name" (logging.file.name) is itself set, in which case the implicit default becomeslogging.appenders=file,console(file first, console still included, not replaced). Settinglogging.appendersyourself to any value, including justfile, disables this auto-add behavior entirely since an explicit value always wins. -
stdoutandttllabove areURIschemes (see the plain properties example earlier in this section) resolved through the sameLogOutputRegistry/LogEncoderRegistrylookup any explicitoutput/encodervalue goes through -ttllis Rainbow Gum's built-in Logback-esque "Time Thread Level Logger" line formatter, not a Spring Boot-ism. -
logging.route.default.publisher.uri=syncmeans every appender on the default route runs synchronously on the calling thread by default (no background queue/thread) - seeLogPublisherRegistryfor theasyncalternative and its own defaults/properties.
Builder based
Rainbow Gum can be configured programmatically through builders. The builders normally will ignore whatever properties (key values) are set externally but most builders can be configured by properties throughLogBuilder.fromProperties(io.jstach.rainbowgum.LogProperties)
and thus a combination of properties and builders can be used.
Config Change during runtime
While most of the logging components in Rainbow Gum are immutable and cannot be changed during runtime there are some exceptions including custom components.A supporting configuration system (a LogProperties provider) can notify that there has been a change by using the Change Publisher. This is how level resolving changes can be published.
Config change is by default off even on supporting configuration systems and can only be turned on by setting
"logging.change.mode" to on (or with
LogConfig.Builder.changeMode(io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeMode)).
Which loggers may change is then chosen per logger with
"logging.change.logger".
Router
A Router has aLevelResolver, appenders and a Publisher.
A Level Resolver takes a logger name and produces a level.
The logging facade implementation then decides based on the level whether or not to construct an event.
It then passes the event to the router which in turn uses the publisher to schedule the event.
For those familiar with other logging frameworks
Rainbow Gum has no actual concept of named "Loggers" but a route from a router is the closest analog.
When configuring routers you are configuring
Level Resolvers which are logger names to levels,
which publisher to use and which appenders should be associated with the publisher.
We call this configuration a "route". As discussed later on
a publisher has appenders and appenders have outputs thus
an important consequnce of this is if you want outputs to have different level thresholds
(e.g. a debug.log and an error.log) you will need multiple routes.
example".
logging.routes=example
logging.route.example.appenders=appender1,appender2
logging.route.example.publisher.uri=async
logging.route.example.level.com.mycompany=DEBUG
Level Resolvers
As mentioned previously routers have a level resolver. A level resolver simply resolves the level (an enum) from a logger name (String). Because ofLevelResolver functional interface we can chain them
with fallbacks. An important fallback level resolver that almost all routers use
is the global level resolver. These levels can be configured with
properties where logger names prefixed with
"logging.level" as the key and the level as the value.
Below is an example:
# Descendant of "com.mycompany"
logging.level.com.mycompany.stuff=DEBUG
# Ancestor to "com.mycompany.stuff"
logging.level.com.mycompany=INFO
logging.level=ERROR
trace, debug, info, warn, warning, error,
off, all (the same as trace), and the JUL names finest,
fine, and severe, ignoring case. Other JUL names such as config and Log4j's
fatal are not accepted. The logger name is case sensitive.
The root level is the bare logging.level key. Spring Boot's logging.level.root
is understood by the Spring Boot integration; elsewhere root is just
a logger named root.
Although not required most level resolvers including the global follow an inheritance or prefix model where
com.mycompany.stuff.foo will resolve to DEBUG
and com.mycompany.bar will resolve to INFO
and anything not prefixed with com.mycompany will resolve to ERROR.
An analog is to think of the "." as directory path separators like in filesystems
and resolution happens by going up the directories.
This behavior is stated more formally in
Logback's Effective Level
which is what Rainbow Gum follows as well.
Changing Levels at Runtime
Rainbow Gum by default assumes Level Resolvers are cached or static and will never change at runtime. This is a critical feature of Rainbow Gum as it allows facade loggers like in SLF4J to be lower overhead than almost all other logging frameworks! That being said RainbowGum allows levels to be changed at runtime if the backing configuration framework supports reloading and the logger name is allowed to be changed.
Below is an example of configuring to allow "changing" loggers# this is required to turn on level changing.
logging.change.mode=on
# only logger names starting with com.mycompany can have their levels changed.
logging.change.logger.com.mycompany=level
LogConfig.ChangePublisher
is used and depending on configuration backend may require a manual publish call.
Route Level Priority
A router's level resolver is actually a chain: the route's own level configuration is checked first, and only if the route has no opinion for a particular logger name does resolution fall through to the global level resolver. This means a route's level configuration always takes priority over the global configuration for any logger name the route has configured regardless of whether the global resolver has a deeper/more specific prefix match for that same name.
# global: specific override
logging.level.com.mycompany.orders=DEBUG
# route "errors": broad override
logging.route.errors.level=ERROR
Without this priority rule a naive merge of the two resolvers into one combined prefix walk would let the
more specific global entry (com.mycompany.orders=DEBUG) win, silently defeating the route's
intent to restrict itself to ERROR and above. Because the route's resolver is consulted
first and independently of the global one, the "errors" route above correctly resolves
com.mycompany.orders to ERROR no matter what the global resolver says about
that name. A route only falls back to the global resolver for logger names it has not configured an
opinion about itself.
Level Groups
Rainbow Gum out of the box supports Spring Boot like Log Groups . However unlike Spring Boot groups are not enabled unless "logging.groups" contains the group. (this is largely because Spring Boots configuration model is like a Map and Rainbow Gums a Function.)Furthermore group levels but not group definitons can be assigned on the router itself. Using Spring Boots example:
# This is required otherwise the tomcat group will not be resolved
logging.groups=tomcat
logging.group.tomcat=org.apache.catalina,org.apache.coyote,org.apache.tomcat
logging.level.tomcat=trace
logging.route.myroute.level.tomcat=info
org.apache.catalina, org.apache.coyote, and org.apache.tomcat
and their descedants
will resolve to INFO for "myroute" but TRACE for other routes.
Note that just like logger names groups are case sensitive.
Groups for difficult logger names
A group is also the easiest way to configure a logger whose name is difficult to write as a property
key. The logger names go in the group's value, and only the group name has to be a key. This matters most
with environment variables: a key maps to a variable name by replacing
. with _, and shells only allow letters, digits and _ in variable
names, so there is no way to write a key for a logger named access-log or
com.example.Router$Handler. With a group:
export RAINBOWGUM_groups=web
export RAINBOWGUM_group_web='access-log,com.example.Router$Handler'
export RAINBOWGUM_level_web=DEBUG
The same groups work for changing loggers and caller info:
logging.change.logger.web=level and logging.caller.logger.web=true apply to the
loggers in web and their children. As with levels, a logger's own key wins over a group, and
the last group listed in logging.groups wins when a logger is in more than one. Group values
are comma separated, so a logger name containing a comma cannot be listed.
Additivity
Those familiar with Logback and Log4j2/1 "additivity" may wonder how that is achieved. Additivity is associated with a logger name in those frameworks and means that the logger level settings will apply to other appenders associated with logger (name) and its ancestors. If false the settings only apply to the associated appenders at that logger and descendants.In rainbowgum the configuration including logger name to level is done on the route and rainbowgum allows multiple appenders on a route and multiple routes.
For example let us assume we want logger namecom.mycompany (and descendants)
at
DEBUG or greater to go to a file called mycompany.log and
WARN for console com.mycompany (and everything else).
In Logback and others this is achieved by setting the appenders
on com.mycompany and additivity=false but in rainbowgum this is done
by configuring separate routes:
logging.level=INFO
logging.routes=console,mycompany
logging.route.mycompany.appenders=mycompany
logging.route.mycompany.level.com.mycompany=DEBUG
logging.route.console.level=WARN
logging.route.console.appenders=console
logging.appender.mycompany.output.uri=file:///./mycompany.log
logging.appender.console.output.uri=stdout
WARN or greater (ERROR)
to stdout (terminal/console output).
The route "mycompany" will log DEBUG and greater for
com.mycompany (and descendants like com.mycompany.something)
to the file mycompany.log. However the "mycompany" route has no opinion
about any other logger name, so for everything else it falls back to the global level
(INFO). That means at this point mycompany.log is also catching
INFO and greater from every other logger, not just com.mycompany.
To actually isolate mycompany.log to just com.mycompany (and
descendants) the route needs to stop falling back to the global level for every other
logger name. We do this by giving the route its own default of OFF, which the
more specific com.mycompany override above still takes priority over:
logging.route.mycompany.level=OFF
Caller Information
Somewhat related to level resolvers and config change is how to enable caller info.LogEvent.Caller has stack trace like information on where the logging call was made.
It is incredibly slow so like changing loggers it must be enabled and you can enable it with logger name inheritance
so not all loggers will be slow.
Caller info does not need logging.change.mode.
Below is an example of configuring caller info for a set of logger names:
# only logger names starting with com.mycompany will have caller info
logging.caller.logger.com.mycompany=true
com.mycompany does not also make com.mycompany level
changeable, and a more specific logging.change.logger.com.mycompany.app entry does not clobber
com.mycompany.app's inherited caller info the way it would if the two shared one property. The
value is a LogEvent.Caller.CallerType (or
true/false), where BASIC (what true means today) is the only strategy implemented so far.
Publishers
ALogPublisher schedules delivery of a LogEvent to a group of
Appenders. Publishers come in two types:
- synchronous (the default)
- asynchronous
An example configuring a route named "example" to use the default async publisher:
logging.route.example.publisher.uri=async
logging.route.example.publisher.async.bufferSize=1024
logging.route.example.publisher.async.shutdownTimeout=10000
Publisher settings are under the scheme written in uri, so the async
settings above apply to whichever publisher is registered as async.
To select batch swap explicitly, even if a plugin or future release changes the default async
publisher, use batchswap. It accepts the same settings, under its own scheme, or as URI
options:
logging.route.example.publisher.uri=batchswap
logging.route.example.publisher.batchswap.bufferSize=1024
# or
logging.route.example.publisher.uri=batchswap:///?bufferSize=1024&shutdownTimeout=10000
The default async implementation is BatchSwapAsyncLogPublisher.
Producers fill a bounded buffer while a single worker writes the previous batch to its appenders.
Producers block by default when the pending buffer is full. Each buffer holds up to bufferSize
events (1024 by default), so up to two batches may be retained, plus events held by blocked callers.
The events.queued metric counts pending events, excluding the batch being written.
Overload handling is explicit. The defaults retain blocking behavior without early discarding:
overflow=blockwaits for space when full.overflow=dropdiscards the incoming event at any level when full. It avoids waiting for capacity, but still acquires a short publisher lock and may invoke an alert listener.discardThresholdreserves pending slots for higher-priority events. Zero (the default) disables early discarding. Otherwise, events at or belowdiscardLevelare discarded when remaining slots are at or below the threshold. The threshold must be less thanbufferSize, and excludes the batch currently being written.discardLevel=infois the default cutoff for early discarding. For example,debugretains INFO events too.offdisables early discarding.
For example, reserve 128 of 1024 slots for warnings and errors, blocking those events if the buffer
fills (discardLevel=info and overflow=block are the defaults, shown for
clarity). Use overflow=drop if even warnings and errors should be discarded instead of
waiting:
logging.route.example.publisher.uri=async
logging.route.example.publisher.async.bufferSize=1024
logging.route.example.publisher.async.discardThreshold=128
logging.route.example.publisher.async.discardLevel=info
logging.route.example.publisher.async.overflow=block
The same options are available under publisher.batchswap when selecting
batchswap, or as URI query parameters. Accepted events are never evicted, and output
always runs on the worker. Discarding is never silent: every event discarded by these options
increments the events.discarded metric, and the first discard for
each option produces a warning alert. Later discards are only counted. Events
lost for other reasons, such as a failed worker, count as events.dropped with an error
alert instead.
For programmatic configuration, use
BatchSwapAsyncLogPublisher.builder(String). Its generated builder
can load the same properties with fromProperties(properties); present properties
override values already set on the builder. The shutdownTimeout setter accepts a
Duration, while the property is expressed in milliseconds.
On close, the publisher drains queued events and calls already waiting for space before closing its
appenders. New calls are rejected once shutdown begins. Close waits up to
shutdownTimeout milliseconds (10 seconds by default). If delivery takes longer,
close returns with an alert; the worker continues draining and closes the
appenders afterward. The worker is never interrupted during shutdown. The worker only drains while the
JVM is running, so events still queued when the JVM exits are lost: shutdownTimeout is how
long close waits for them.
Rejected events increment events.dropped, with one error alert per rejection reason
for each publisher. This covers logging before start, after shutdown begins, recursively from the
worker, or after worker failure. A producer interrupted while waiting for space also drops its event
and retains its interrupt flag. An interrupted caller can still enqueue when space is available.
NOTE: If you want some appenders to be async and others sync you just create multiple routes.
Rainbow Gum has an experimental async publisher that uses the LMAX Disruptor ringbuffer:io.jstach.rainbowgum.disruptor.
If the jar is found on start it will replace the default async publisher, and it can also be selected
explicitly as disruptor. Its only setting is bufferSize, which must be a power of
two; it has no shutdownTimeout.
Since publishers are where Rainbow Gum's threading concerns live, this is worth mentioning here: setting
"logging.global.threadLocal" to forbid is a
guarantee of no ThreadLocal anywhere in the logging path: it forces every
appender off ThreadLocal-backed buffer strategies and, if the SLF4J module is in use,
disables MDC too, which is the same effect as combining the appender and MDC options above individually.
Appenders
An appender contains an Encoder and anOutput.
Unlike other logging frameworks there are no custom Appenders as most of the work is done by the encoder and output.
That is an appender can be mostly thought of as a tuple of encoder and output as well as it supervises both.
Appenders are associated to the previously mentioned route. Like previously mentioned when a route is not given a name it is automatically named "default". Thus to register appenders to the default route one can use:
logging.appenders=myappender
# an example config of appender
logging.appender.myappender.type=reuse_buffer
logging.route.example.appenders=myappender
# an example config of appender
logging.appender.myappender.type=reuse_buffer
Appender Configuration
Appenders can be configured either programmatically through thebuilder
or through properties.
- Output
- "logging.appender.{name}.output.uri" = URI
- Encoder
- "logging.appender.{name}.encoder.uri" = URI
- Type
- "logging.appender.{name}.type" = a single
LogAppender.AppenderType - Flags
- "logging.appender.{name}.flags" = List of
LogAppender.AppenderFlag
ThreadLocal) for low lock contention. For deployments
that want no ThreadLocal anywhere in the logging path,
LogAppender.AppenderType.LOCK_NEW_BUFFER allocates a fresh buffer per event instead,
trading that guarantee for a per-event allocation.
An important Appender Flag is LogAppender.AppenderFlag.DISABLE_IMMEDIATE_FLUSH which
will tell the output to not flush after each event. Flushing after each event is enabled by default to follow
12 Factors requirements of events written unbuffered synchronously;
this flag should be used to opt out of that for performance reasons.
Appender Reentry Protection
A problem that can occur in any logging framework is if an output, appender, or encoder does logging which if synchronous will usually cause a stackoverflow and if asynchronous an infinite stream of events. Let us create a hypothetical example where we have an Output that uses a message queue client. The message queue client uses SLF4J for logging. If an event is sent that causes the message queue client to log an event and that event is not discarded and the output client then logs the event it created you get reentry in a synchronous publisher. With asynchronous it is far worse as there will be less immediate evidence that something is broken and instead a never ending supply of log entries.
Some logging frameworks like logback provide reentry protection
usually through threadlocals but one must understand this only works if logging is synchronous (synchronous publisher), hurts performance,
and is just a misleading bandaid over a potential serious problem if the events are being dropped (which logback does by default).
That is why Rainbow Gum does not provide reentry protection unless requested!
However the flags LogAppender.AppenderFlag.REENTRY_DROP and LogAppender.AppenderFlag.REENTRY_LOG
can be used to provide similar and arguably better protection than logback.
Overall the real solution is to fix the output so that it filters events that would cause such behavior or ideally not do any logging.
Encoders
Encoders encode an event into binary. Because most encoding is text based RainbowGum has the concept ofLogFormatter which can be turned into an Encoder and is covered
in the next section. However there are scenarios where generating bytes directly is desirable.
Rainbow Gum JSON encoder module io.jstach.rainbowgum.json has efficient JSON encoders.
They are encoders instead of formatters (which are covered next) because writting JSON as bytes is more efficient.
logging.appenders=myappender
logging.appender.myappender.encoder.uri=gelf
logging.appender.myappender.encoder.gelf.host=myhost
logging.appender.myappender.encoder.gelf.prettyPrint=true
host, and configure it to pretty print.
Encoders should have a builder as well to configure programmatically and the builders javadoc has the string properties that can configure it.
A typical practice is to use a pattern encoder that produces human readable logs for development and a JSON encoder for production switched out based on configuration.JSON Encoders
Rainbow Gum provides several JSON encoders in therainbowgum-json module. Unless prettyPrint is enabled the JSON encoders
included follow the JSON Lines standard as well as of course the JSON standard
RFC-8259. However note that there is no standard on time format in JSON so the formatting of log event
time can vary from JSON encoders depending on the formats specification. The module also can be used to create ones own custom JSON encoders.
The following sections cover the various encoders.
GELF JSON Encoder
GelfEncoderBuilder -
The Graylog Extended Log Format
(GELF) JSON encoder follows the GELF JSON specification linked above
with translations of levels that are roughly similar to syslog. Notice that although the GELF specification does not
require JSON Lines this encoder will keep each event on its own line unless
prettyPrint is enabled. Key values are additional fields; a key value named like an additional field
the encoder writes, or id, gets a keyValues. prefix (_keyValues.level).
logging.appenders=myappender
logging.appender.myappender.encoder.uri=gelf
logging.appender.myappender.encoder.gelf.host=somehost
ECS JSON Encoder
EcsEncoderBuilder -
Elastic Common Schema (ECS)
JSON, URI scheme "ecs". Two shapes are supported:
by default field names are the flattened, dotted ECS names (e.g. "log.level") as the reference
ecs-logging-java implementation writes them; setting
logging.appender.{name}.encoder.ecs.structured=true instead nests fields as JSON objects (e.g.
"log":{"level":...}) matching
Spring Boot's ECS structured
logging format. Key values are written as top-level fields using their own key name, except a key
value named like a field the encoder writes, which goes in a nested keyValues object
(keyValues. prefixed fields in the flattened shape) so no field is written twice:
logging.appenders=myappender
logging.appender.myappender.encoder.uri=ecs
logging.appender.myappender.encoder.ecs.serviceName=myapp
logging.appender.myappender.encoder.ecs.structured=true
Logstash JSON Encoder
LogstashEncoderBuilder: format produced by
logstash-logback-encoder's
LogstashEncoder, URI scheme
"logstash". Key values are flattened
top-level fields (matching the reference implementation), except a key value named like a field the encoder
writes, which goes in a nested keyValues object, and @timestamp is rendered with
an offset using a configurable zone
(defaults to the system default zone):
logging.appenders=myappender
logging.appender.myappender.encoder.uri=logstash
logging.appender.myappender.encoder.logstash.zoneId=UTC
Logback JSON Encoder
LogbackJsonEncoderBuilder: resembles
Logback's own opinionated JSON encoder,
URI scheme "logback". Nests key values
under a mdc object and, for throwables, a throwable object with a
stepArray of stack frames (recursing into cause):
logging.appenders=myappender
logging.appender.myappender.encoder.uri=logback
Formatters
Formatting of log events as textual data is so common that Rainbow Gum has special support for that withLogFormatter. While formatters can at high-level be considered
an encoder for technical API reasons they are not. However any formatter can be converted to an
encoder with LogEncoder.of(io.jstach.rainbowgum.LogFormatter).
Many of the following formatters are also encoders and can thus be referenced like normal encoders using URIs.
TTLL Formatter
TTLL (Time, Thread, Level, Logger) is the default text format and needs no extra module. By default each line also carries the event's key values (MDC and other context), percent encoded in braces after the logger name:12:00:00.123 [main] INFO com.example.App {requestId=42&user=Ada%20Lovelace} - hello world
12:00:00.123 [main] INFO com.example.App - started
keyValues=none turns it off.
Each part can be changed with properties of the encoder, much like slf4j-simple's options, so changing the key
values format or dropping the time does not require the pattern module:
logging.appender.console.encoder.ttll.timestamp=iso
logging.appender.console.encoder.ttll.keyValues=json5
2026-10-05T12:00:00.123Z [main] INFO com.example.App {requestId:"42",user:"Ada Lovelace"} - hello world
timestamp:ttll(default,HH:mm:ss.SSSUTC),iso,none, or aDateTimeFormatterpattern in UTC.thread:name(default),id, ornone.level:padded(default),plain,bracketed, ornone.logger:full(default),short(last name component), ornone.keyValues:percent(default),json,json5, ornone. JSON writes{"requestId":"42"}; JSON5 leaves valid identifier keys unquoted, as in{requestId:"42"}. Key values are written in braces;noneleaves out the key values and their braces.keyValuesWhenEmpty: whether the braces are written for an event with no key values.auto(default) leaves them out forpercentandjson5and writes{}forjson, since whatever reads JSON key values expects an object on every line;omit(aliasfalse) leaves them out and is rejected withjsonfor the same reason, so a production profile that switches to JSON cannot silently drop objects;show(aliastrue) always writes them, for a fixed line shape.color: whether to color.default(aliastrue, used when not set) colors with the theme, or rainbowgum when no theme is set, if the console supports ANSI;detectcolors the same way but only when a theme is set;forcecolors with the theme, or rainbowgum, regardless of ANSI support;off(aliasfalse) never colors.logging.global.ansi.modesets the default:offturns it to off andonto force, and the default encoder of outputs that are not a console, such as files, is never colored unlesscoloris set.theme: which colors.rainbowgum(used when not set) is the same coloring as the pattern encoder's default;springfollows Spring Boot's console colors (faint time and thread, green, yellow, or red level, cyan logger);one_darkanddarculause Atom One Dark's (magenta logger) and IntelliJ Darcula's (orange logger) colors in 24 bit color, which needs a terminal with true color support.
default, which restores the default without naming it (for example when a
profile overrides a base setting), and true or false to show or hide it;
theme accepts default only.
Percent encoding (requestId=42&user=Ada%20Lovelace) is the default key values format, both for
TTLL and for KeyValuesFormatterBuilder, because:
- It is reversible: every key and value reads back exactly with
PercentCodec.decode(CharSequence, java.nio.charset.Charset), including separators, newlines, and Unicode, and a key with a null value (written as the key alone) stays distinct from an empty one. - It always stays on one line and needs no quoting decisions, so no value can break the line or confuse a parser reading it.
- It is a standard (RFC 3986, the URI query form) that every language can decode, and the same encoding Rainbow Gum reads its own URI query properties with.
- It adds no escaping noise when the line ends up in JSON. Many applications write TTLL to the console and let a
collector or aggregator turn each line into a JSON document. Percent encoded text has no quotes or backslashes, so
it is stored exactly as written, while logfmt or JSON key values would be stored with every quote escaped
(
\"Ada Lovelace\").
Use json or json5 when a tool downstream reads that format
instead.
logging.appender.console.encoder.uri=ttll:///?keyValues=json5) and
programmatically through TTLLFormatterBuilder. See
TTLL.
logfmt Formatter
logfmt writes each event as one line ofkey=value pairs,
the format of Go's log/slog text handler. It is readable in a terminal and parsed directly by Grafana
Loki, Vector, and similar tools. It is built into core, so no extra module is needed:
logging.appenders=myappender
logging.appender.myappender.encoder.uri=logfmt
time=2026-10-05T15:04:05.123Z level=INFO logger=com.example.App thread=main msg="hello world" requestId=42
keyValues.
prefix (keyValues.level=debug). A throwable adds error and stacktrace with line
breaks escaped so every event stays on one line. See LogfmtFormatterBuilder, which also sets the level format, for example
logging.appender.myappender.encoder.logfmt.levelFormatter=right_pad_level_formatter.
Values are colored with the same color and theme properties as the
TTLL formatter: by default when the console supports ANSI, with the rainbowgum
theme. Keys, msg, error, and stacktrace are never colored, and level padding
is written after the level's color.
JSON5 Formatter
Thejson5 encoder writes each event as one line (unless prettyPrint=full) holding a JSON5 object, so an application that normally
logs TTLL can switch to structured logging, for example while hunting a bug, by flipping one property and without
adding a module:
logging.appender.console.encoder.uri=json5
{time:"2026-10-05T15:04:05.123Z",level:"INFO" ,logger:"com.example.App",thread:"main",msg:"hello world",requestId:"42"}
json5, and writes JSON5 by default, so it is not mistaken for the way to log JSON: like the
other text formatters it is mostly for people reading logs, and the JSON encoders are
for JSON logging.
The fields use logfmt's names: time, level, logger, thread,
msg, and for a throwable error and stacktrace. Every value is a string, or
null for a key value without one. Properties of the encoder:
format:json5(default), which leaves identifier keys unquoted, orjson({"time":"...","level":"INFO",...}).keyValues:merged(default) writes key values alongside the event's fields, the way logfmt and most aggregators expect, and places any key value named like one of those fields in a nestedkeyValuesobject instead, so nothing is dropped, renamed, or written twice;nestedalways writes them in thekeyValuesobject ({}when there are none);noneleaves them out.prettyPrint:level_padding(default) pads after the level's closing quote so the fields after it line up and the value stays the level name:level:"INFO" ,logger:...;spacingalso adds a space after every comma:level:"INFO" , logger:"com.example.App", thread:...;full(ortrue) puts each field on its own line, indented one space like therainbowgum-jsonencoders' pretty print, so an event is no longer one line;offadds no whitespace.colorandtheme: color values the same way the TTLL formatter does, by default when the console supports ANSI. Keys,msg,error, andstacktraceare never colored.
prettyPrint, color, and theme only apply to JSON5. With
format=json they are ignored, so the output stays plain JSON.
This encoder is a convenience, not a replacement for io.jstach.rainbowgum.json: it has one fixed set of
fields, no multi-line pretty printing, and no particular schema. For production structured logging, especially into a system
that expects ECS, GELF, or Logstash fields, use that module's encoders instead. See
Json5FormatterBuilder.
JSON Key Values Formatter
KeyValuesFormatterBuilder builds a LogFormatter
that appends key values as JSON or JSON5 text directly to a StringBuilder.
It requires no JSON library and can be composed with other formatters:
LogFormatter formatter = LogFormatter.builder()
.message()
.text(" ")
.add(new KeyValuesFormatterBuilder()
.format(KeyValuesFormatterBuilder.Format.JSON5)
.nullStrategy(LogFormatter.KeyValueNullStrategy.SKIP)
.build())
.newline()
.build();
hello and a requestId key value of 42,
this writes hello {requestId:"42"} followed by a newline.
- The default is JSON, with all keys quoted. JSON5 leaves valid identifier keys unquoted; keys containing spaces, dots, or hyphens remain quoted. All non-null values are strings.
- All keys are written in event order by default. Use
keys(List.of("requestId", "user"))to select keys and their order. - The null strategy defaults to
KEEP, which writesnull.EMPTYwrites""andSKIPomits the key. Missing selected keys follow the same strategy. - The text includes object braces, including
{}when no keys are written.
Pattern Formatter
For those coming from Logback or Log4j2 Rainbow Gum has an optional module for Logback style based pattern formatters. This allows describing an output format usingString.format percent style syntax, e.g.
"%-5level %logger - %msg%n". Rainbow Gum implements most of the builtin Logback pattern keywords with
far less overhead. The builtin keywords are listed in
PatternRegistry.KeywordKey and
PatternRegistry.ColorKey.
The pattern module is not part of the aggregate rainbow gum dependency; add it to use
patterns, and while it is present it is the default encoder for console output. Without it the console uses the
TTLL encoder, whose default rainbowgum color theme writes the same colors as the pattern
module's default pattern.
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-pattern</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
NOTE: The color pattern keywords need no further module such as JAnsi.
Rainbow Gum detects whether the process is attached to an ANSI capable terminal using only
System.console() and well known environment variables (NO_COLOR, TERM;
see AnsiSupport), and simply does not emit ANSI escape sequences when it is not.
The separate JAnsi module covered below is no longer generally needed; it is now largely a
legacy option for a handful of edge cases.
"logging.global.ansi.mode" overrides the detection for the whole
configuration: auto (the default) detects, on always emits ANSI escape sequences, and
off never does and also keeps JAnsi from installing if that module is in use. It is also set with
LogConfig.Builder.ansiMode(io.jstach.rainbowgum.LogConfig.Globals.AnsiMode).
%highlight colors its content by level. With no options it uses Logback's colors (error bold red,
warn red, info blue). As a Rainbow Gum extension it also accepts Log4j2 style LEVEL=style options,
where a style is a color plus optionally bold or faint; levels not listed keep their
default:
logging.appender.console.encoder.pattern.pattern=%highlight(%-5level){ERROR=red bold, INFO=blue bold, WARN=red} %msg%n
Selecting and Configuring the Pattern Encoder
The pattern encoder's URI scheme is "pattern". Because it is the default for console output when the module is present, setting a pattern on an appender is often all that is needed:logging.appenders=myappender
logging.appender.myappender.encoder.uri=pattern
logging.appender.myappender.encoder.pattern.pattern=[%thread] %-5level %logger{15} - %msg%n
true.
Configuring PatternConfig with Properties
PatternConfig carries the platform specific settings that keywords
need: time zone (for %d/%date), line separator (for %n), whether ANSI is disabled, and
the start time used by %r/%relative. It is resolved per encoder name through
"logging.pattern.config.{name}.", so different appenders can
each have their own:
logging.appender.myappender.encoder.uri=pattern
logging.pattern.config.myappender.zoneId=UTC
logging.pattern.config.myappender.lineSeparator=\n
logging.pattern.config.myappender.ansiDisabled=true
%property{key} is different: it looks up ad-hoc key values that are not tied to a particular
encoder, through "logging.pattern.property.":
logging.pattern.property.app=myapp
%property{app} anywhere in a pattern outputs myapp.
Custom Keywords and Programmatic Configuration
Like Logback you can add your own pattern keywords, andPatternConfig
can instead be set programmatically as a single default shared by every encoder that has no more specific
property configuration. Both (with full code examples) are covered in the
io.jstach.rainbowgum.pattern module javadoc.
Automatic Exception Rendering
Notice that none of the pattern examples above (e.g.[%thread] %-5level %logger{15} - %msg%n)
mention an exception keyword at all. As with Logback, this is intentional and safe: if a pattern does not
otherwise use %ex/%xEx/%rEx (or their aliases), Rainbow Gum automatically
appends a default exception formatter to the end of the compiled pattern, so a logged throwable is never silently
dropped just because the pattern forgot to ask for it.
%rEx/%rootException takes the same options as %xEx (depth, exclude
patterns, packaging data) but prints the root cause first and works back up through each wrapper, captioned
Wrapped by: instead of Caused by: - useful when the underlying failure is buried
under several layers of wrapping and you want it to be the first thing you see:
logging.appender.myappender.encoder.pattern.pattern=%msg%n%rEx
%nopex (or %nopexception)
anywhere in the pattern (like the other throwable keywords, its mere presence is enough to disable the
automatic append), and it renders nothing itself:
logging.appender.myappender.encoder.pattern.pattern=%msg%nopex
JAnsi support (largely legacy)
Rainbow Gum still has an optional JAnsi module for cross platform ANSI
output, but it is essentially deprecated and should not be needed by most users. It predates
AnsiSupport, the JDK-native ANSI detection the pattern
module now uses by default, and its original reasons for existing have mostly gone away:
- Windows. JAnsi's original purpose was translating ANSI escapes into Win32 console calls for the legacy Windows console, which did not understand ANSI at all. Modern Windows terminals (Windows 10+, Windows Terminal) support ANSI/VT sequences natively, so this is largely a non-issue today.
-
Stripping ANSI on piping/redirection. JAnsi works by wrapping
System.out/System.errand stripping escape sequences at write time when it decides the destination is not a real terminal, which requires JAnsi to correctly detect that in the first place.AnsiSupportinstead decides once, usingSystem.console(), whether to emit ANSI escapes at all, which is both simpler and does not depend on a terminal-detection library getting it right. This matters because terminal detection is a genuinely hard problem in practice: for example JLine's terminal detection (used by a number of other tools for the same purpose) is known to not correctly detect piped/redirected output in some cases, emitting escape codes into a pipe it should have recognized as non-interactive.
NOTE:
Future versions of the JDK may require command line arguments
for using jars packaged with native libraries, which JAnsi is. Given that Rainbow Gum favors security we
strongly agree with the draft JDK Enhancement Proposal (JEP): this is one more reason
AnsiSupport's pure-JDK approach (no
native code, no reflection, no separate terminal library) is now the preferred path.
true or "logging.global.ansi.mode" set to off.
A further reason to avoid Jansi is that it may accidentally strip ANSI escape characters for terminals that do support ANSI but Jansi cannot determine they do. The most notable case of this problem are IDE terminals/consoles particularly IntelliJ's.
The module is kept around for the remaining edge cases where its Win32 console translation is still relevant (e.g. genuinely legacy Windows environments), but new users should not need to add it. It only installs JAnsi's console wrapper and no longer provides its own console format: colors come from the pattern encoder or the default encoder, the same as without the module.
Outputs
LogOutput do the actual work of outputting an event.
Configuring an appenders output with properties
looks something like:
logging.appenders=myappender
logging.appender.myappender.output.uri=stdout
./.
logging.appenders=myappender
# logging.appender.myappender.output.uri=app.log this is wrong.
logging.appender.myappender.output.uri=./app.log
app.log is a URI scheme if it is not prefixed.
Of course fully qualified file URI are supported as well like: file:///./app.log.
For Spring Boot compatibility "logging.file.name" is also supported
if custom routers and appenders are not configured. Unlike the generic output property above,
"logging.file.name" is not subject to the ./
caveat: since it is documented to always be a file path (never a URI scheme reference), Rainbow Gum resolves
it as one directly, so both of the following work as expected without needing a ./ prefix:
logging.file.name=app.log
logging.file.name=logs/app.log
file:///./app.log is still honored as-is.
Here are some of the supported outputs (not a complete listing):
Console
stdout and stderr are the default outputs, require no additional
module, and need no further configuration beyond the URI scheme itself:
logging.appenders=myapp
logging.appender.myapp.output.uri=stdout
logging.appenders=myapp
logging.appender.myapp.output.uri=stderr
By default the console outputs use whatever System.out or
System.err is when the output is created, so rebinding it later with
System.setOut is not seen. To read the stream on every write instead (for
example so a test that captures standard out also captures log output), set the
stream parameter to follow in the URI or as an output property:
logging.appenders=myapp
logging.appender.myapp.output.uri=stdout:///?stream=follow
# or equivalently
logging.appender.myapp.output.stdout.stream=follow
Do not use follow if something redirects standard out into logging, since
the output would then log to itself. Programmatically, use
StdOutOutputBuilder or
StdErrOutputBuilder with
ConsoleStream.
File Output
Therainbowgum-file module (not included by default;
rainbowgum-core on its own has no file I/O capability at all) provides
plain, non-rolling file output under the "file" URI scheme,
FileOutputBuilder:
logging.appenders=myapp
logging.appender.myapp.output.uri=file:///var/log/app.log
logging.appender.myapp.output.file.append=false
logging.appender.{name}.output.file., or options on the URI
(file:///var/log/app.log?append=false).
See FileOutputBuilder for the full list of supported
properties (e.g. append,
prudent,
bufferSize) and
their defaults.
JDK Flight Recorder (JFR)
The optional JFR module (inspired by JFRLog) commits each log event as a JFR event instead of writing bytes anywhere, with one event type per level (io.jstach.rainbowgum.Trace/Debug/Info/
Warn/Error) so each can be independently enabled, thresholded, or given a stack trace setting
through a Flight Recorder configuration (.jfc file or -XX:StartFlightRecording) the
same way as any other JFR event, not through Rainbow Gum properties.
logging.appenders=myappender
logging.appender.myappender.output.uri=jfr:///
encoder unset picks up JfrLogOutput's own default encoder,
which renders the message followed by any key values, e.g. some message - {key=value}, or just the
message when there are none. There is no timestamp/level/logger prefix, since the JFR event already carries those
as separate fields. Set
logging.appender.myappender.encoder.uri explicitly to override it.
Starting A Recording
Events sitting in io.jstach.rainbowgum.* only get recorded somewhere if a Flight Recorder session
that enables them is actually running - there are two ways to make that happen.
The usual way is wiring JFR in at the JVM level, which works but has a rough edge: the flags are easy to get wrong, differ across JVM vendors/versions, and are yet another thing to remember to add to a container entrypoint or systemd unit, entirely separate from the rest of the application's logging configuration:
java -XX:StartFlightRecording:filename=recording.jfr,dumponexit=true,settings=profile -jar myapp.jar
The simpler alternative, and the one worth reaching for first, needs none of that: give the output's own URI a
real file path instead of the bare jfr:///. No JVM flags, no .jfc file - nothing
outside the logging.appender.*.output.uri property already being set for any other output:
logging.appenders=myappender
logging.appender.myappender.output.uri=jfr:///var/log/myapp.jfr
This starts and owns a Flight Recorder session for the lifetime of the output, writing directly to that file,
with every event type unconditionally enabled - unlike the bare jfr:/// form, JFR's own per-event
filtering does not apply here at all. Whether an event is captured is decided solely by Rainbow Gum's normal
level resolver, the same one that already decides whether the output is written to in the first place.
jfr print --events "io.jstach.rainbowgum.*" recording.jfr then shows the recorded events regardless
of which way the recording was started. TRACE and DEBUG defaulting to disabled (too high volume to want on by
default) only matters for the bare jfr:/// form - a URI-started recording enables every level
unconditionally; enable them explicitly in a custom .jfc settings file if using
-XX:StartFlightRecording instead.
JFR Alert Listener
The same module can also commit Rainbow Gum's own alerts (an appender
failing to write, an async publisher's queue overflowing, and similar problems with the logging system itself,
not application log lines) as JFR events, opt-in via:
logging.jfr.alerts.mode=on
Disabled by default: unlike the output above, which stays inert until an appender explicitly selects the
jfr URI scheme, this listener has no such per-appender opt-in moment - once enabled it commits
every alert as a JFR event for the life of the process.
Alerts use their own, separate JFR event type family (io.jstach.rainbowgum.AlertTrace/AlertDebug/
AlertInfo/AlertWarn/AlertError) rather than reusing the event types above, so
jfr print --events io.jstach.rainbowgum.Error only ever returns genuine application errors, never
Rainbow Gum's own internal alerts - filter on io.jstach.rainbowgum.Alert* for those instead.
Rolling Files
Built-in Rolling
The samerainbowgum-file module additionally provides a deliberately basic,
size based rolling file output under the "rolling" URI scheme,
RollingFileOutput:
logging.appenders=myapp
logging.appender.myapp.output.uri=rolling:///var/log/app.log?maxFileSize=10MB&maxHistory=7
RollingFileOutputBuilder for the full list of
properties (maxFileSize,
maxHistory,
totalSizeCap,
cleanHistoryOnStart,
fileNamePattern) and their defaults. They
work equally well as query parameters above or as
logging.appender.{name}.output.rolling.* properties.
maxFileSize and totalSizeCap take a number with an optional unit,
for example 10MB, 512k, or 1.5 GiB. Units are case
insensitive and always powers of 1024 (MB is 10242 bytes, never
10002), and a number without a unit is bytes. Every size Logback accepts means
the same thing here. See DataSize for the full format.
This is deliberately basic: just enough to keep a small/desktop app's disk
from filling up, not a substitute for a real log rotation/aggregation
policy. There is no calendar/date based rotation, no compression on a
schedule independent of rotation, and no coordination across multiple processes
writing the same file. If you need any of that, use the external-tool approach
below instead (typically
logrotate).
It remains the recommended approach for anything beyond preventing a disk from
filling up, and does not depend on the rainbowgum-file module at all.
External Tool Support (logrotate)
How this typically works:- External program moves the current log file. Rainbow Gum will continue to log to the same file but the file name is effectively changed.
- Signals to the Java process with Rainbow Gum to reopen the files. This signal is usually done via HTTP or TCP socket since Java does not support Unix signals.
- The external program then typically compresses, delete or send the log files elsewhere.
(The other method of rotating files without doing a move and reopen is called "copy truncate" but has the potential of losing events. This method avoids that problem. )
Unfortunately Rainbow Gum does not offer a way to receive the external signal as this can vary greatly across applications. The following is an example of reopening outputs using HTTP for signaling./*
* This method could be bound to an internal HTTP route say /log/rotate such that curl
* http://localhost:8080/log/rotate will block and wait till all the applicable
* outputs have reopened.
*/
@RequestMapping("/log/rotate")
@ResponseBody
public String someInternalHttpRequestHandler() {
var gum = RainbowGum.getOrNull();
if (gum != null) {
List<LogEvent> errors = gum.config() //
.outputRegistry() //
.reopen(); // Here is where we siginal to reopen outputs that support
// reopening. Anything that failed is also reported to the
// alert system (see LogConfig#alerts()) - errors here is
// just that same information handed back to the caller.
return errors.isEmpty() ? "ok" : errors.toString();
}
return "";
}
http://localhost:8080/log/rotate and we have configured our logging like
logging.appenders=myappender
logging.appender.myappender.output.uri=/var/log/app.log
/var/log/app.log
{
rotate 4
weekly
missingok
notifempty
compress
delaycompress
# we only need one call to reopen all files
sharedscripts
# in most cases it is best for rainbowgum recreate the file which wil happen after the postrotate
nocreate
postrotate
/usr/bin/curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/log/rotate | {
read status
if [ "$status" -ne 200 ]; then
logger "logrotate: Error hitting endpoint, received HTTP status $status"
fi
}
endscript
}
OpenTelemetry
The optionalio.jstach.rainbowgum.opentelemetry module (rainbowgum-opentelemetry)
adds the current OpenTelemetry span's traceId and spanId to log events through a
key values contributor.
It works with any output or encoder and can be used independently of rainbowgum-otlp.
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-opentelemetry</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
OTLP/HTTP Output
The optionalio.jstach.rainbowgum.otlp module (rainbowgum-otlp) exports logs in the
OpenTelemetry Protocol without the OpenTelemetry SDK or any protobuf library. Its only dependencies are
Rainbow Gum itself (core and rainbowgum-json) and the JDK's own HTTP client.
OtlpOutput posts batches to an OTLP/HTTP endpoint (/v1/logs) as
protobuf (the OTLP default) or JSON. Pair it with the asynchronous publisher so a log call never waits on the
network:
logging.appenders=otel
logging.appender.otel.output.uri=otlp:///
logging.appender.otel.output.otlp.endpoint=http://collector:4318/v1/logs
logging.appender.otel.output.otlp.serviceName=orders
logging.route.default.publisher.uri=async
protocol (http/protobuf or http/json),
headers, compression (gzip), timeout (milliseconds),
maxBatchSize, resourceAttributes, traceIdKey/spanIdKey,
and keyValuesIncluded/keyValuesExcluded.
Retryable responses (429, 502, 503, 504) and network errors are retried with randomized backoff.
The timeout bounds the whole export, including retries and response-body reads. A batch that still fails
is dropped and reported as an alert and the otlp.records.failed
metric.
The standard OTEL_* environment variables are not read unless you opt in, since
reading the process environment is not something Rainbow Gum does by default:
logging.appender.otel.output.otlp.environmentVariables=on
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, and the
OTEL_EXPORTER_OTLP_* (and OTEL_EXPORTER_OTLP_LOGS_*) endpoint, protocol, headers,
compression, and timeout variables fill in anything not set by a property.
OTLP/JSON Encoder
OtlpJsonEncoder writes each event as one line of OTLP/JSON, the
OpenTelemetry file format. Use it with any output, for example standard out in a container, and let the
OpenTelemetry Collector read it with its otlpjsonfile receiver or its filelog
receiver and otlpjson connector:
logging.appender.console.encoder.uri=otlp:///
logging.appender.console.encoder.otlp.serviceName=orders
thread.name, thread.id, other key
values as attributes, and exception.* attributes for a throwable. When caller info is enabled
the record also carries code.function.name, code.file.path, and
code.line.number. Event thread, caller, and exception attributes take precedence
when context contains the same names. Key values named traceId and
spanId become the record's trace context when they are valid W3C ids. These can come from the
OpenTelemetry contributor above or from Micrometer Tracing's MDC integration. A key value named
otel.event.name becomes the record's event name, as it does with the OpenTelemetry Logback and
Log4j appenders.
Every key value (MDC, SLF4J key value pairs, and contributors alike) becomes an attribute by default.
keyValuesIncluded and keyValuesExcluded select which ones with case sensitive globs
(* matches any characters, ? one character); an excluded key is never sent:
logging.appender.otel.output.otlp.keyValuesIncluded=request*,user.id
logging.appender.otel.output.otlp.keyValuesExcluded=*.password,*.token
Filtering
Like logback Rainbow Gum provides two types of filtering through functional composition:- router based filtering, similar to logbacks normal filters.
- SLF4J based filtering, similar to logback Turbo Filters
Event based Filtering
Unfortunately at this time router based filtering is not configurable through properties and can only be added using Rainbow Gum builder and composition has to be done manually.An example using router based filtering:
RainbowGum.builder(config) //
.route(rb -> {
rb.factory(RouterFactory.of(e -> {
/*
* We only log DEBUG level events.
*/
return switch (e.level()) {
case DEBUG -> e;
default -> null;
};
}));
/*
* If we do not set the level correctly the router will never get the
* event regardless of the logic of the above filtering function.
*/
rb.level(Level.DEBUG);
});
LogRouter.Router
with a function.
Event based filtering is still based on the invariants
of level resolvers not changing (unless they are configured to be dynamic) so
not all events are delivered if the level resolved does not not allow it!
A work around is to adjust the levels of the router so that it does get
all events desired and is why filtering is associated with routing.
Unfortunately this can be inefficient as the event always has to be created
so if performance matters consider using SLF4J based filtering.
SLF4J based filtering
SLF4J based filtering is not simple filtering but rather decorating of the SLF4J loggers somewhat akin to Servlet filtering. Rainbow Gum hands off its generated SLF4J logger to you and you can decorate or even disregard it and provide your own implementation. This is extremely powerful and efficient but requires more work. There is not really an analog in Logback or Log4j but as usual with great power comes great responsibility!These filters are usually loaded with the ServiceLoader so manually using the Rainbow Gum builder is not required like router based filtering.
Do not implement the decorated Logger by extending
AbstractLogger directly: getting SLF4J caller-info depth right
means depending on that class's internal dispatch shape, which is an unversioned
implementation detail and easy to get subtly wrong. Extend
AbstractFilteringLogger
instead, which owns and tests its own depth accounting. A decorator that samples DEBUG
level events and surfaces any Marker as a key value (there is no
built-in Marker support otherwise):
public class DecoratorExample extends LoggerDecoratorService {
@Override
public String name() {
return "sampling";
}
@Override
public Logger decorate(RainbowGum rainbowGum, DepthAwareLogger previousLogger, int depth) {
return new SamplingLogger(previousLogger);
}
static class SamplingLogger extends AbstractFilteringLogger {
private final AtomicInteger debugCount = new AtomicInteger();
SamplingLogger(DepthAwareLogger delegate) {
super(delegate);
}
@Override
protected boolean isEnabled(Level level, @Nullable Marker marker) {
// Cheap pre-check: keep only every 10th DEBUG event, everything else
// passes through unchanged. This runs before any LoggingEventBuilder
// is built.
if (level != Level.DEBUG) {
return true;
}
return debugCount.incrementAndGet() % 10 == 0;
}
@Override
@SuppressWarnings("CheckReturnValue")
protected boolean decorate(LoggingEventBuilder builder, @Nullable Marker marker) {
if (marker != null) {
// Rainbow Gum core has no built-in Marker support (it is not stored
// on the event), so surface it as a key value instead.
builder.addKeyValue("marker", marker.toString());
}
return true;
}
}
}
@Nullable above is org.jspecify.annotations.Nullable from org.jspecify:jspecify.
Alerts, Metrics, and Reporting
Rainbow Gum's own internal health (as opposed to the application events flowing through theRouter) is exposed through two small, separate
LogConfig accessors:
LogConfig.alerts() and LogConfig.metrics().
They are deliberately two different types rather than one combined API. An appender failing
to write is a discrete event worth recording with a message (and possibly a throwable), while
"how many times has this happened" is much cheaper to just keep as a running number. Each
is meant to be independently replaceable later (a future Micrometer backed LogMetrics,
say, without needing to also replace how alerts are recorded). A third piece,
Reporting, ties both of these together with the wired component tree
itself into one human (or agent) readable diagnostic snapshot.
Alerts
LogAlerts records problems with the logging system itself (an
appender failing to write, or an async publisher's queue overflowing) rather than application
logging. Because logging itself may be broken these are not routed back through the normal
Router but are instead kept in a small in memory ring buffer
(LogAlerts.dump() and LogAlerts.stats())
as well as immediately reported (currently to stderr).
Set -Dlogging.debug=ERROR to print the failure and collected alerts if building
LogConfig fails. Set -Dlogging.debug=INFO to also print
the collected alerts after every successful config build. Set -Dlogging.debug=ALL
to additionally print the LogReporter report after Rainbow Gum
starts its logging components. Set -Dlogging.debug=HELP to do everything
ALL does and also report misspelled property keys and fail startup on warnings; see
Troubleshooting. These dumps include alerts recorded before
properties were loaded and do not notify alert listeners. The switch is read before property
providers run, so it cannot be set in logging.properties. OFF is the
default; true and false are aliases for ALL and
OFF, respectively. The
builder option sets a fallback mode; the system property takes precedence.
You can also subscribe to alerts as they happen with
LogAlerts.addListener(io.jstach.rainbowgum.LogAlerts.Listener),
useful for bridging alerts to whatever alerting/paging system an application already has:
/*
* Forwards every alert to your own alerting/paging system as it happens, in addition
* to it still being recorded in the ring buffer.
*/
public AutoCloseable subscribeToAlerts(LogConfig config) {
return config.alerts().addListener(event -> sendToPagingSystem(event.message()));
}
Metrics
LogMetrics is for the cheap, high frequency counters that would be
wasteful to record as discrete alert events: counters like
"events.dropped" (an appender dropping events on
reentry, see Appender Reentry Protection) and
"buffer.trimmed" (a reused encoder buffer growing
past its configured max size and having to shrink itself back down) are both incremented this
way rather than as alerts. Every alert also bumps a metrics counter named after the alert's
logger name, so counts stay available even once older alerts have been evicted from the ring
buffer.
LogMetrics.errorCounter(String, long) and
LogMetrics.warnCounter(String, long) increment a named counter;
LogMetrics.snapshot() returns a snapshot of every named value
recorded so far. Counters increase over time; a downstream metrics system computes their rate
of change.
For values that can rise and fall, retain a handle from
LogMetrics.gauge(String, java.lang.System.Logger.Level) and call
its increment and decrement methods. Updates go directly to the retained
value without a name lookup. Levels below INFO and OFF return a no-op
handle without registering a metric. The blocking async publisher uses
"events.queued" at INFO to count events
waiting in its queues across all publishers sharing the config. An event stops being counted
when removed for processing. Snapshots are approximate during concurrent queue activity.
LogMetrics.StandardMetric, useful for something wanting to bind
every standard metric to an external system without hand listing each name/level pair.
LogMetrics.StandardMetric.isGauge() distinguishes gauges from counters:
/*
* Binds every well known metric (see LogMetrics.StandardMetric) to your own metrics
* system without hand listing each name/level pair - a future new StandardMetric
* constant is picked up automatically.
*/
public void reportMetrics(LogConfig config) {
List<LogMetrics.Metric> snapshot = config.metrics().snapshot();
for (var metric : LogMetrics.StandardMetric.values()) {
long value = snapshot.stream()
.filter(c -> c.name().equals(metric.metricName()) && c.level() == metric.level())
.mapToLong(LogMetrics.Metric::value)
.findFirst()
.orElse(0);
reportToMetricsSystem(metric.metricName(), value);
}
}
For a Spring Boot 4 application, these StandardMetric values, and the
diagnostic report below, are both surfaced through Spring Boot's own
/actuator endpoints: see Actuator under
Spring Boot.
Reporting
LogReporter, from LogConfig.reporter(),
renders a plain text diagnostic report of a config and the RainbowGum
built from it: the wired component tree itself (which concrete
appender, encoder, and output actually got selected for each route, not just what was
configured), plus alerts and metrics above, all in
one place. It is meant to be read by a human, or an agent, while setting things up or
debugging an incident, not parsed by anything expecting a stable schema. The rendered text
carries no compatibility guarantee and may change between any two releases, including patch
releases.
LogReporter.Options controls which
sections are printed
(VERSION, COMPONENTS, METRICS, ALERTS, LOGGERS, FACADES).
LOGGERS lists the logger names the logging facades have created, each with its resolved level
unless loggerLevels(false) is set, and
FACADES lists which facades (SLF4J, JUL, and so on) have actually created loggers. The default is
just VERSION and COMPONENTS: the other sections are usually more useful pulled
independently the way the sections above already show, so a report only includes them if asked.
/*
* A full diagnostic report of what actually got wired up: version, the component tree
* (routers, publishers, appenders, encoders, outputs), metrics, and recent alerts.
* Cheap to build on demand - print it on an unhandled exception, expose it from an
* admin endpoint, or just log it once at startup.
*/
public String diagnosticReport(RainbowGum gum) {
var options = LogReporter.Options.builder()
.sections(EnumSet.of(Section.VERSION, Section.COMPONENTS, Section.METRICS, Section.ALERTS))
.build();
return gum.config().reporter().report(options);
}
COMPONENTS also lists properties worth seeing in every report, such as
logging.change.mode, with their current values. Rather than a fixed list, the
component that reads such a property adds its key with
LogReporter.addProperty(Class, String), so a module's own switches (for
example logging.jul.disable) appear only when that module is in use. Each key shows the
packages that read it.
Since alerts are just LogEvents like any other,
LogReporter.Options.Builder.alertFormatter(io.jstach.rainbowgum.LogFormatter)
lets you swap in a different LogFormatter for how each one is
rendered, for example to leave the (otherwise non-deterministic) timestamp out of a report you
intend to golden-string test.
A component that wants to contribute its own description to a report instead of just its
concrete class name (a custom LogOutput or
LogEncoder, say) can opt in by implementing
LogReporter.Reportable. This is entirely optional and has no
effect on anything except the rendered report.
Example Configuration
Everything above introduced routes, level resolvers, publishers, appenders, encoders, and outputs one at a time. This section puts them together into one non-trivial configuration, first as properties and then the equivalent using builders, so the full shape of a realistic setup is visible in one place rather than spread across many small snippets.
Assume a hypothetical service com.mycompany.orders that wants:
- A human readable, synchronous console appender for local development.
- A structured (JSON) file appender for log aggregation, using an async publisher so encoding and file IO do not block request threads.
- A separate
errorsonly file, independent of the two routes above, following the multiple routes pattern discussed earlier. - Noisy third party packages (
org.apache.http,io.netty,com.zaxxer.hikari) turned down toWARNeverywhere via a level group.
Properties
logging.level=INFO
logging.level.com.mycompany.orders=DEBUG
logging.groups=noisy
logging.group.noisy=org.apache.http,io.netty,com.zaxxer.hikari
logging.level.noisy=WARN
logging.routes=console,structured,errors
logging.route.console.appenders=console
logging.route.console.level=INFO
logging.route.console.level.noisy=WARN
logging.route.structured.appenders=jsonfile
logging.route.structured.publisher.uri=async
logging.route.structured.publisher.async.bufferSize=2048
logging.route.structured.level=DEBUG
logging.route.structured.level.noisy=WARN
logging.route.errors.appenders=errorfile
logging.route.errors.level=ERROR
logging.appender.console.output.uri=stdout
logging.appender.console.encoder.uri=pattern
logging.appender.jsonfile.output.uri=./logs/orders.json.log
logging.appender.jsonfile.encoder.uri=gelf
logging.appender.jsonfile.type=reuse_buffer
logging.appender.errorfile.output.uri=./logs/orders-error.log?bufferSize=10000
logging.appender.errorfile.encoder.uri=pattern
logging.appender.console.encoder.pattern.pattern=[%thread] %-5level %logger{36} - %msg%n
logging.appender.jsonfile.encoder.gelf.host=orders-service
logging.appender.jsonfile.encoder.gelf.prettyPrint=false
logging.appender.errorfile.encoder.pattern.pattern=%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n%ex
A route's own levels shadow the global ones. The console and
structured routes each set a root level, so the global logging.level.noisy=WARN
alone would not reach them: their own INFO and DEBUG would win for the noisy
packages too. That is why each of those routes repeats the group level as
logging.route.<route>.level.noisy=WARN. The errors route needs no override
because its ERROR threshold is already stricter.
Encoder and output configuration lives under the appender. The
console appender's encoder is picked by logging.appender.console.encoder.uri and
configured by the logging.appender.console.encoder.pattern.* keys, named after the type
uri picks, so everything about one appender sits together. The same is true for
jsonfile and errorfile. Switching encoder.uri to another type
leaves the old type's keys unread rather than misread.
A route level threshold does act as a hard ceiling. As covered in
Route Level Priority, a route's own level configuration is
checked first and takes priority over the global resolver for any logger name the route has an
opinion about, regardless of whether the global resolver has a more specific prefix match for that
name. That is why the bare logging.route.errors.level=ERROR above is enough on its
own: even though the global logging.level.com.mycompany.orders=DEBUG override applies
everywhere else, the errors route's own ERROR threshold wins for that
logger too, with no need for a separate logging.route.errors.level.com.mycompany.orders
override to restate it.
A property value that is itself a URI can carry its own query parameters, as an
alternative to separate logging.appender.<name>.output.<scheme>.<param>
properties. logging.appender.errorfile.output.uri above is set to
./logs/orders-error.log?bufferSize=10000 rather than a bare path: the part before
? is still resolved as the file path exactly as with a bare path, and everything
after ? is parsed the same way a query string would be and made available as
properties scoped to that output: here, bufferSize, equivalent to also having
written logging.appender.errorfile.output.file.bufferSize=10000 on its own line. This works for a
relative, scheme-less path just as well as it does for an explicit file:// URI; which
style to use is purely a matter of preference for how compact versus explicit the properties file
should read.
Builder
The same configuration (minus the JSON encoder, see the note below) using builders:RainbowGum.builder(config) //
.route("console", r -> {
r.level(Level.INFO);
r.level(Level.WARNING, "org.apache.http");
r.level(Level.WARNING, "io.netty");
r.level(Level.WARNING, "com.zaxxer.hikari");
r.appender("console", a -> {
a.output(LogOutput.ofStandardOut());
a.encoder(new PatternEncoderBuilder("console").pattern("[%thread] %-5level %logger{36} - %msg%n")
.fromProperties(config.properties())
.build());
});
}) //
.route("structured", r -> {
r.level(Level.DEBUG, "com.mycompany.orders");
r.level(Level.WARNING, "org.apache.http");
r.level(Level.WARNING, "io.netty");
r.level(Level.WARNING, "com.zaxxer.hikari");
r.publisher(PublisherFactory.async().bufferSize(2048).build());
r.appender("detailfile", a -> {
/*
* A URI-scheme based encoder like the JSON module's "gelf" is
* resolved through the service loader, which is why it is comfortable
* to reference from properties (see the properties example above)
* without the aggregate rainbowgum module needing to require the JSON
* module at compile time. Referencing an optional module's encoder
* class directly the way this builder is written would require adding
* that module as an explicit dependency.
*/
a.output(new FileOutputBuilder("detailfile").fileName("./logs/orders-detail.log").build());
a.encoder(new PatternEncoderBuilder("detailfile")
.pattern("%d{ISO8601} [%thread] %-5level %logger{50} - %msg%n")
.fromProperties(config.properties())
.build());
a.appenderType(AppenderType.REUSE_BUFFER);
});
}) //
.route("errors", r -> {
r.level(Level.ERROR);
r.appender("errorfile", a -> {
// Appenders flush after every event by default (see
// AppenderFlag#DISABLE_IMMEDIATE_FLUSH), so no flag is needed
// here to get synchronous, unbuffered error writes.
a.output(new FileOutputBuilder("errorfile").fileName("./logs/orders-error.log").build());
a.encoder(new PatternEncoderBuilder("errorfile")
.pattern("%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n%ex")
.fromProperties(config.properties())
.build());
});
});
Notice the structured route above uses a plain text pattern encoder instead of the
JSON module's gelf encoder used in the properties version. A URI scheme based encoder
like gelf is resolved through the service loader at runtime, which is precisely why it
is comfortable to reference from properties without the aggregate rainbowgum module
needing to requires the JSON module at compile time. Referencing
GelfEncoder directly from a builder the way the rest of
this example is written would require adding rainbowgum-json as an explicit compile
dependency of whatever module the builder code lives in, illustrating a real trade-off between
the two configuration styles rather than only a stylistic one.
Installation
For most simply including the dependencyio.jstach.rainbowgum:rainbowgum
is enough. That dependency transitively pulls in core, the SLF4J binding, and the
System.Logger and java.util.logging integrations. Core provides the
stdout and stderr outputs and the ttll, logfmt and
json5 encoders. Other URI schemes need their module added as a dependency:
| Scheme | Kind | Module |
|---|---|---|
pattern | encoder | rainbowgum-pattern |
gelf, ecs, logstash, logback | encoder | rainbowgum-json |
file, rolling | output | rainbowgum-file |
jfr | output | rainbowgum-jfr |
otlp | output and encoder | rainbowgum-otlp |
disruptor | publisher | rainbowgum-disruptor |
org.slf4j:slf4j-api at compile scope,
since rainbowgum is normally a runtime dependency.
For a more minimal setup (total application size in terms of classes)
io.jstach.rainbowgum:rainbowgum-core
and io.jstach.rainbowgum:rainbowgum-slf4j
(if using SLF4J)
should be used.
NOTE: if you are using a web framework or platform that already bundles
its own logging framework (Logback, Log4j2, etc.), you will likely need to exclude that
transitive dependency, or the two will conflict on the SLF4J provider slot. See the
Spring Boot section below for a concrete example of excluding
spring-boot-starter-logging.
Maven
<properties>
<rainbowgum.version>1.0.0-SNAPSHOT</rainbowgum.version>
</properties>
...
<dependencies>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum</artifactId>
<version>${rainbowgum.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
rainbowgum-pattern for
PatternEncoderBuilder) at compile scope:
<properties>
<rainbowgum.version>1.0.0-SNAPSHOT</rainbowgum.version>
</properties>
...
<dependencies>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-core</artifactId>
<version>${rainbowgum.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
Gradle
Groovy DSL (build.gradle):
dependencies {
runtimeOnly 'io.jstach.rainbowgum:rainbowgum:1.0.0-SNAPSHOT'
}
Kotlin DSL (build.gradle.kts):
dependencies {
runtimeOnly("io.jstach.rainbowgum:rainbowgum:1.0.0-SNAPSHOT")
}
If you plan on configuring Rainbow Gum programmatically you will need to make a module and create a
service loader registration. In that case you will want the dependency below, plus any module whose classes
your configuration code uses (for example rainbowgum-pattern for
PatternEncoderBuilder) at compile scope:
dependencies {
implementation 'io.jstach.rainbowgum:rainbowgum-core:1.0.0-SNAPSHOT'
}
dependencies {
implementation("io.jstach.rainbowgum:rainbowgum-core:1.0.0-SNAPSHOT")
}
Extensions and Integrations
SLF4J 2.0
Rainbow Gum SLF4J moduleio.jstach.rainbowgum.slf4j supports:
MDC defaults to a ThreadLocal-backed implementation; setting
logging.mdc.type to NOOP swaps in a no-op implementation instead, for deployments
that want no ThreadLocal anywhere in the logging path and are fine losing MDC to get it.
There is no built-in Marker support (it is not stored on the event
or used for routing/filtering by default), but a
decorator can
observe markers and act on them, e.g. surface one as a key value or filter on it.
Rainbow Gum supports key value pairs
in LoggingEventBuilder by overlaying on top
of the current MDC at the time the event is constructed and put into the
events key values.
The value parameter in
LoggingEventBuilder.addKeyValue(String,Object) are
converted to String immediately as only String values are
supported at this time.
Rainbow Gum SLF4J implementation is unique in that it has two special implementation of loggers:
- Level Logger: logger based on level threshold and can never change!
- Changing Logger: level and other configuration can change.
The SLF4J API does not document nullability for MDCAdapter or
LoggingEventBuilder.addArgument(Object) (see
slf4j#493). Rainbow Gum accepts
null more widely than SLF4J's own signatures require, e.g. MDC values, and
logged arguments, for compatibility with existing SLF4J usage and other implementations,
but this is not a recommendation: prefer not passing null where SLF4J itself
gives no guarantee it is supported.
Like SLF4J's own reference LoggingEventBuilder implementation,
Rainbow Gum's is mutable: each fluent call mutates and returns the same instance rather
than a new one.
Scoped Key Values (ScopedValue based MDC alternative)
Rainbow Gum has an alternative to
MDC for request/task-scoped key values, backed by
ScopedValue instead of ThreadLocal:
ScopedKeyValues.
MDC's ThreadLocal storage has two long-standing problems this removes by construction:
-
Unbinding. A value put on a pooled thread stays there until something remembers to call
MDC.remove(...), usually in afinallyblock. Forget one, or throw before reaching it, and the value leaks into whatever unrelated request or task happens to reuse that thread next. -
Fan-out. Nothing about
ThreadLocalprevents one concurrent task from mutating values another sibling task (or the parent) is relying on, since they all just share the same backing map if it is naively passed around.
ScopedValue bindings are immutable for the dynamic extent of one
ScopedValue.where(...).run(...)/.call(...) call and are automatically, unconditionally
unbound the instant that call returns, exception or not, with no finally required. A
StructuredTaskScopePREVIEW fork started inside that call inherits the binding as a
frozen snapshot; nothing a sibling task or the parent does afterward can change what that snapshot
contains, so correct fan-out isolation falls out of the JDK's own semantics instead of anything Rainbow
Gum has to implement.
ScopedKeyValues pushes one small, immutable batch of key
values at once, at a well-known request/task boundary, the same shape
JEP 506's own examples use, not something called from every
layer of business logic - typically a single push wrapping an entire request, e.g. in a servlet filter
or similar entry point:
// e.g. a servlet filter's doFilter, or any other single request/task entry point
ScopedKeyValues.builder()
.add("requestId", requestId)
.add("userId", userId)
.run(() -> {
// every log call for the duration of this Runnable/Callable, including inside
// any StructuredTaskScope fork started in here, sees requestId/userId, e.g.:
logger.info("handling request"); // logs with requestId and userId attached
service.handle(request);
});
LoggingEventBuilder.addKeyValue(String,Object). Ad-hoc, imperative, single-call-site
key values are still MDC's/addKeyValue's job - the two compose normally, since
ScopedKeyValues is always merged in as the lowest-precedence source (see below), so an
addKeyValue(...) call on one particular log line still overrides a key pushed here; this is for
the opposite shape, a handful of values that should be visible on every log line for an entire
request/task.
Two modules: API and provider
This feature is split into two modules the same way SLF4J itself is split intoslf4j-api and a chosen binding:
-
io.jstach.rainbowgum.scopedkeyvaluesis thecompile-scoped API application/library code depends on to callScopedKeyValues. It has no dependency beyond (optionally) JSpecify, does not referenceScopedValueat all, and targets Rainbow Gum's usual JDK 21 baseline, so it is safe to depend on from code that has not committed to Rainbow Gum, or even to JDK 25, as its logging backend. It discovers the real storage mechanism at class-init time via a smallio.jstach.rainbowgum.scopedkeyvalues.spi.ScopedKeyValuesProviderServiceLoaderlookup, the same bootstrap shape SLF4J itself uses fororg.slf4j.spi.SLF4JServiceProvider. With no provider found, pushing still runs the body normally, it just does not record or propagate anything, the same safe-with-nothing-bound behavior SLF4J's own facade falls back to. -
io.jstach.rainbowgum.scopedkeyvalues.provideris theruntime-scoped Rainbow Gum provider, like the rest of Rainbow Gum's optional modules. Simply depending on it is enough: it supplies the actualScopedValue-backedScopedKeyValuesProvider, and separately registers aLogEventFactoryin theServiceRegistrywhose values are merged in as the lowest-precedence source underneath MDC, so MDC and any fluent builderaddKeyValue(...)calls always win on a key collision, and nothing changes for applications that do not push anything.
Requirement: ScopedValue was finalized in JDK 25
(JEP 506), higher than Rainbow Gum's usual JDK 21 baseline. Only
the provider module targets JDK 25+; the API module does not, and is not part of the default
io.jstach.rainbowgum:rainbowgum dependency either way. Add both, with the API module at
compile scope, unlike the rest of Rainbow Gum, which is runtime-scoped since application
code is not meant to compile directly against implementation classes:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-scopedkeyvalues-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-scopedkeyvalues</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
Default Key Values
EXPERIMENTALio.jstach.rainbowgum.keyvalues.contributor adds the same key values to every
event, such as a build version or an instance id, without putting them in every thread's context. Every other
key values source (SLF4J MDC, scoped key values, ...) overrides them on a key collision. Values come from a property,
in the order they are declared:
logging.keyvalues.contributor.defaults=git.commit=abc123&instance.id=i-42
DefaultKeyValues.of(config.serviceRegistry()).put("app_status", "READY");
logging.keyvalues.disabled=defaults; that property takes a comma separated list of
key values source names
(defaults, opentelemetry, scoped_key_values, jboss_logging,
log4j2, slf4j, user, ignoring case) and leaves those sources out of every event. When logs go through a collector such as the OpenTelemetry Collector, per process
values like these usually belong in resource attributes instead, which the OTLP output and JSON encoders support.
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-keyvalues-contributor</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
java.lang.System.Logger and java.util.logging
Rainbow Gumio.jstach.rainbowgum.jdk module provides special integration and adapters for the builtin JDK logging facilities.
The impetus for this is these logging facilities can be used early in the JDK boot processes well before
logging has fully initialized. The default rainbow gum dependency has this integration as a transitive dependency.
The integration will make sure that the System.Logger does not initialize
Rainbow Gum too early by queueing the events if some other facade is detected like SLF4J. When a Rainbow Gum initializes and
set as global the events will be replayed.
If the events level are equal to System.Logger.Level.ERROR and a normal Rainbow Gum has not been bound
the messages will be printed to System.err.
The idea is something catastrophic has happened that will probably cause Rainbow Gum to never load and thus never replay the events
and you will not be able to figure out what happened otherwise.
The exception of the above is if no other facade is detected
the module will initialize Rainbow Gum. The idea is that your application only uses the JDK logging facilities
(the Rainbow Gum SLF4J implementation is not found in the module/classpath). Thus only having the dependencies
rainbowgum-core, and rainbowgum-jdk will work as normal.
java.util.logging records logged before Rainbow Gum starts are not queued. Plain
java.util.logging has no hook that lets Rainbow Gum load first, so the bridge handler is only installed when
Rainbow Gum starts. Until then records go to java.util.logging's own configured handlers (by default its console
handler on System.err, in its own format) and are not replayed; records logged after Rainbow Gum
starts go through Rainbow Gum. To capture the early ones too, install the
Rainbow Gum LogManager, which makes java.util.logging itself load
Rainbow Gum.
rainbowgum-jul by itself will not initialize Rainbow Gum the way rainbowgum-jdk does. Depend on
rainbowgum-jdk instead (which pulls rainbowgum-jul in transitively) to get the JUL
bridge activated automatically. If neither an existing System.Logger/SLF4J call site nor
rainbowgum-jdk is an option, call RainbowGum.of() explicitly
before any JUL logging happens. See io.jstach.rainbowgum.jul's module Javadoc for why.
SLF4J does
provide an adapter/bridge for the System.Logger (org.slf4j:slf4j-jdk-platform-logging),
routing System.Logger calls into whatever SLF4J provider is bound (the SLF4J adapter
will initialize Rainbow Gum on System.Logger usage if using
io.jstach.rainbowgum.slf4j). Using it is not recommended: it is a
third party module maintained by the SLF4J project itself, not Rainbow Gum, so its own future
compatibility is outside Rainbow Gum's control. rainbowgum-jul/rainbowgum-jdk
(described above) give the same System.Logger coverage plus java.util.logging
bridging, are maintained directly by this project, and are the better choice for compatibility and
future proofing.
The following System properties allow greater control of the queueing and initialization. Because of how early initialization can happen this configuration can only be done with System properties.
- "logging.global.queue.level" = level
- "logging.global.queue.error" = level
- "logging.systemlogger.initialize" = init option
NOTE: While the JDK System.Logger is good for low level libraries that rarely log it's API (and Rainbow Gum implementation) is not designed for performance. For applications and frameworks that do a lot of logging the SLF4J facade is the preferred choice.
Because java.util.logging (JUL) and System.Logger can be used very early on currently MDC (set by SLF4J) and caller info diagnostics is not provided at this time. Caller info maybe provided later as it is agnostic of SLF4J and if you would like that please file an issue. Support for changeable logging levels is provided for the System Logger provided the loggers are created after Rainbow Gum initializes.
Avoid java.util.logging if you can
Since JEP 264 (JDK 9), the JDK's own internals
log through java.lang.System.Logger, not java.util.logging - JUL is no
longer something the platform itself leans on, and today is mostly a legacy API kept
alive by libraries and frameworks that predate JEP 264, or that have simply never
migrated off it. If you have any choice in the matter (your own code, or a dependency you
can swap), preferring SLF4J or System.Logger directly avoids JUL's dispatch
overhead entirely rather than routing around it.
As a last resort, if some required dependency logs through JUL directly with no other
integration seam, io.jstach.rainbowgum.jul.logmanager provides
RainbowGumLogManager, a
java.util.logging.LogManager replacement - see its own javadoc for installation
and GraalVM native-image notes. It is a more invasive, JVM-wide alternative to the
default rainbowgum-jul bridge, worth reaching for only when the bridge genuinely
cannot see a given Logger in time.
Simple logging and properties
rainbowgum-simple
rainbowgum-simple is a ready-to-use bundle for tests and small applications.
It pulls in rainbowgum-slf4j through the rainbowgum dependency,
along with JDK logging integrations and
rainbowgum-simple-props. Adding it activates logging without application
code or a configuration file. It formats with the core formatters,
TTLL by default; add rainbowgum-pattern for
pattern encoders. For library tests, use Maven's test scope:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-simple</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
runtime scope instead when an application uses the bundle. Place an
optional logging.properties in src/test/resources or
src/main/resources, respectively. The bundle is similar in purpose to
slf4j-simple,
with JDK logging support and the property sources described below.
rainbowgum-simple-props
Addrainbowgum-simple-props to an existing Rainbow Gum setup when you want
its property loading without the entire simple bundle. For example, alongside the
standard rainbowgum dependency:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-simple-props</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
Loading order
To opt in, having rainbowgum-simple-props on the classpath or module
path is not enough: logging.properties (or the base resource name chosen
with SimpleProperties.builder().resource(...))
must exist. Without that resource, this module contributes no properties,
including system properties and environment variables, even when profiles were selected.
Once the base resource is found, property values resolve in this order (highest priority first):
- The level file, when watched, for
logging.levelkeys only. - System properties.
RAINBOWGUM_-prefixed environment variables. For example,logging.level.com.myappmaps toRAINBOWGUM_level_com_myappand the rootlogging.leveltoRAINBOWGUM_level.- Selected
logging-<profile>.propertiesclasspath resources, with the first listed profile taking priority. - The base
logging.propertiesclasspath resource.
Profiles
Profile selection uses the first configured source in this order:
- The
logging.profilessystem property, such as-Dlogging.profiles=local-dev,dev. - The
RAINBOWGUM_profilesenvironment variable. SimpleProperties.builder().profiles(...).
- Each source replaces the whole profile list, including an explicitly empty list. Files cannot select additional profiles.
- Profile names allow ASCII letters, digits, underscores, and hyphens.
- When the base resource exists, every selected profile resource must also exist or
startup fails, except for the optional
defaultprofile.
When no profiles are selected, the default profile loads
logging-default.properties if it exists. Selecting other profiles replaces
this default selection; values from that file are absent unless default
is explicitly listed. Put settings such as a custom pattern there when another profile
should be able to use Rainbow Gum's built-in defaults. Settings in
logging.properties remain a fallback for every profile.
Level file
A level file changes log levels while the application runs, without a restart. Turn it on with
logging.simpleprops.levelFile=watch (or true) as a system property, an environment
variable, or in the base resource; it is off by default. The file is
level.properties in the working directory unless logging.simpleprops.levelFilePath
names another path, where a relative path is resolved against the working directory. Neither setting is read
from profile resources or from the level file itself.
# level.properties
level.com.myco=debug
level.org.hibernate.SQL=trace
- Only
logging.levelkeys are read, and likelogging.propertiesthey may leave off thelogging.prefix. Other keys are ignored with a warning. - Its levels win over every other source. That includes a group level:
level.web=DEBUGin the file wins over a level set for a logger inwebanywhere else. Groups themselves are defined in the application's properties, not in the level file. - An edit with an invalid level is reported once and leaves all previously loaded levels unchanged. A later valid edit is applied normally.
- The file is checked for changes every two seconds, and changed levels apply to loggers that already exist.
Watching supplies
logging.change.mode=onandlogging.change.logger=levelas lowest precedence defaults, so explicit change settings still win. - A missing file is picked up when it appears.
- Whoever can write the file controls what reaches the logs, so where POSIX permissions are available the file is refused while it or its directory can be written by group or others, or it is owned by someone other than the user running the application or root. A symbolic link also has its target's directory checked, and a missing link target is refused. A refused file is reported as an error alert, the last levels read are kept, and the file is read again once its permissions are fixed.
Diagnostics and native images
The provider records the selected profiles and each loaded resource as informational
alerts. If the base resource is absent,
it records that instead. Informational alerts are retained without printing to stderr
by default.
Keys without the logging. prefix get it by default, in the base resource and in profile resources,
so the file can leave it out:
level.com.myco=debug
appenders=console
is the same as logging.level.com.myco=debug and logging.appenders=console. When a key is
given both ways the later line wins, as with any repeated key, and errors name the key as it is written in the file.
Since a java.util.logging configuration file has the same name, a base resource with keys only it uses,
such as handlers, .level, or java.util.logging.ConsoleHandler.level, stops
initialization with an error saying it looks like java.util.logging configuration. This is the short mode of logging.simpleprops.strict, which itself is always written with its
full key. Set logging.simpleprops.strict=fail in the base resource, or call
SimpleProperties.builder().strict(StrictType.FAIL),
to require the prefix instead: each unprefixed key then produces an error alert that includes its resource and line
number, and initialization stops with an exception. alert reports the same errors and continues, and
off ignores unprefixed keys. The resource setting takes precedence over the builder.
false is an alias for off; true is an alias for fail.
The module includes GraalVM native-image resource metadata for
logging.properties and logging-<profile>.properties.
Custom resource names need their own native-image metadata. For dynamic configuration
or different property sources, use a custom
RainbowGumServiceProvider.PropertiesProvider or
programmatic builders.
Spring Boot
Rainbow Gum provides Spring Boot 3 and 4 support that is maintained by the Rainbow Gum project, as two separate modules/artifacts, one per Spring Boot major version. Rainbow Gum cannot compete with Spring Boot's own bundled Logback/Log4j2 integration on "we maintain this forever regardless of what Spring Boot does next", so rather than bet on one major version staying source-compatible with the next (they usually are not), each supported major gets its own artifact:io.jstach.rainbowgum.spring.boot4/rainbowgum-spring-boot4-starter: for Spring Boot 4 (recommended for new projects).io.jstach.rainbowgum.spring.boot3/rainbowgum-spring-boot3-starter: for Spring Boot 3.
io.jstach.rainbowgum.spring.boot3/boot4) are not
exported and never imported by consumers. Spring Boot discovers them itself through META-INF/spring.factories
and the Java service loader, so those packages are duplicated per major version rather than shared, and are not
necessarily identical in behavior. Each module's own javadoc has the authoritative full supported/not-supported
property table for which Spring Boot logging properties (logging.*, spring.output.ansi.enabled,
etc.) it supports, kept next to the code that implements it rather than duplicated here where it could drift out of
sync:
io.jstach.rainbowgum.spring.boot4 and io.jstach.rainbowgum.spring.boot3. The one package you might
actually import,
io.jstach.rainbowgum.spring.boot.spi, lives in its own genuinely shared module (rainbowgum-spring-spi,
pulled in transitively by both starters) because it only depends on org.springframework.core.env.Environment,
which is expected to remain stable across Spring Boot major versions, so if you have implemented
SpringRainbowGumServiceProvider yourself, that import never needs to change when you bump major versions.
Spring Boot allows configuration through simple properties for logging that tries to be logging framework agnostic. How this works is Spring Boot configures the logging framework from its own set of properties. There is a shocking amount of code Spring Boot requires to do this for Logback and Log4J2 because both of those frameworks were not designed well for programmatic configuration.
Rainbow Gum key value configuration is purposely very similar and largely compatible with
Spring Boot's however Spring Boot still requires special integration if you would like
to use its configuration system (for example application.properties).
This is because Spring Boot uses/needs logging for initialization of its configuration system.
<dependencies>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-spring-boot4-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!--
This is the important part as logback will be used otherwise
regardless if rainbow gum spring boot integration is used.
-->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
rainbowgum-spring-boot4-starter with
rainbowgum-spring-boot3-starter above; everything else is identical.
Note that if one chooses to not use one of the rainbowgum-spring-boot*-starter artifacts
or the underlying rainbowgum-spring-boot3/rainbowgum-spring-boot4 module directly,
Spring will re-initialize java.util.logging and take over it so again the integration is recommended.
Actuator
io.jstach.rainbowgum.spring.boot4.actuator bridges Rainbow Gum's own diagnostics into
Spring Boot 4's Actuator endpoints: every LogMetrics.StandardMetric
as a Micrometer counter or gauge, and the diagnostic report as Actuator info.
Both activate independently of each other; neither requires a bean to register.
Metrics. Every LogMetrics.StandardMetric is bridged to
Micrometer as rainbowgum.<name>, tagged with its level. Increasing metrics use
counters (e.g. rainbowgum.events.dropped); rainbowgum.events.queued uses
a gauge. It activates automatically (no properties to set) as long as Micrometer is on
the classpath, which it already is once spring-boot-starter-micrometer-metrics (Spring
Boot 4's own dedicated metrics starter) is a dependency; add just the module and the metrics show
up under /actuator/metrics and any Micrometer registry the application already has
configured (Prometheus, etc.).
Only the fixed StandardMetric set is bound: deliberately not the per-logger-name counters
LogMetrics also accumulates as a side effect of every alert, since logger
names are application-chosen and effectively unbounded, which would be an unbounded cardinality source
in a metrics backend. LogAlerts itself is not bridged to Micrometer at
all; only its metrics side effect is.
Info. An InfoContributor renders the
LogReporter to /actuator/info: one map entry per
LogReporter.Section (version, components,
metrics, alerts, loggers, facades), nested under a
single rainbowgum key so it cannot collide with any other contributor's own top level
keys. Unlike the metrics bridge above, this is disabled by default and needs
management.info.rainbowgum.enabled=true set explicitly: the report can include internal
detail (appender/output/encoder names, URIs), matching how Spring Boot's own
env/java/os/process info contributors are also
disabled unless explicitly enabled, for the same reason.
There is currently no Spring Boot 3 equivalent of this module; it is Boot 4 only.
Helidon
Rainbow Gum provides Helidon SE support throughrainbowgum-helidon4 and
rainbowgum-helidon27, native implementations of Helidon's own io.helidon.logging.common.spi.LoggingProvider extension
point (the same one helidon-logging-jul/helidon-logging-log4j implement).
Like Spring Boot, each supported Helidon major version gets its own artifact rather than one module
trying to track every release:
io.jstach.rainbowgum.helidon4/rainbowgum-helidon4: for Helidon SE 4.x.io.jstach.rainbowgum.helidon27/rainbowgum-helidon27: for Helidon SE 27.x, which requires Java 27.
Add the dependency and it activates automatically. No code change is needed at all: Helidon's own
LogConfig selects and initializes the highest weighted LoggingProvider it
finds before config, the service registry, or the webserver ever start, well before any application
log line. See io.jstach.rainbowgum.helidon4's own module Javadoc for the full mechanism.
rainbowgum-helidon27 for Helidon 27):
<dependencies>
<dependency>
<groupId>io.jstach.rainbowgum.helidon</groupId>
<artifactId>rainbowgum-helidon4</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-simple-props</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-pattern</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
rainbowgum-simple-props and rainbowgum-pattern are not required by
the Helidon modules themselves. Together they give a classpath logging.properties
file (Rainbow Gum's own property format, not java.util.logging's) and a pattern based
encoder, the same combination this repository's own examples/helidon uses.
Both Helidon modules pull in rainbowgum-jdk at runtime scope,
providing both java.lang.System.Logger support through
rainbowgum-systemlogger and java.util.logging (JUL) bridging through
rainbowgum-jul. This covers JDK and library calls to System.Logger
as well as Helidon's own JUL logging. The Helidon LoggingProvider bootstraps
Rainbow Gum; rainbowgum-jul then installs the bridge Handler.
No separate JDK logging dependency is needed.
Micronaut
Micronaut fully embraces SLF4J as its own facade, soio.jstach.rainbowgum.slf4j alone
is a complete, low effort integration: no bootstrap ordering to worry about, since Micronaut's own
internals call LoggerFactory.getLogger(...) immediately on startup. Rainbow Gum also
provides rainbowgum-micronaut5, a native
io.micronaut.management.endpoint.loggers.ManagedLoggingSystem bean, closing two gaps
that plain SLF4J alone leaves open:
- Micronaut's own
/loggersmanagement endpoint needs a bean of that type, and the bundled candidates for Logback/Log4j2 both disable themselves once those frameworks are not on the classpath, leaving nothing for a Rainbow Gum based application to fall back on. application.properties/application.yml's ownlogger.levels.*keys have no effect on Rainbow Gum without this bean: Micronaut's bundledPropertiesLoggingLevelsConfigurerreads those keys and callssetLogLevel(...)on whicheverLoggingSystembean it finds, and without one, there is nothing to call.
Add the dependency and both are closed automatically: being a Micronaut bean is the entire
integration, no ServiceLoader registration or early lifecycle hook needed.
rainbowgum-micronaut5 has no module-info.java of its own (see its
Javadoc jar's Automatic-Module-Name), so it is not part of this aggregated
Javadoc, but its source is on GitHub for the full mechanism.
rainbowgum-micronaut5 includes rainbowgum-slf4j,
rainbowgum-jdk, and rainbowgum-pattern as runtime dependencies.
This provides the SLF4J implementation, System.Logger support, JUL bridging,
and pattern encoding. Remove the application's existing logging implementation
(e.g. logback-classic) and add the Micronaut integration:
<dependency>
<groupId>io.jstach.rainbowgum.micronaut</groupId>
<artifactId>rainbowgum-micronaut5</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
For classpath logging.properties loading, optionally add
rainbowgum-simple-props, as this repository's examples/micronaut
does for its configuration and smoke tests:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-simple-props</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
That file uses Rainbow Gum's own property format, not java.util.logging's.
The Micronaut integration supports logger.levels.* in
application.properties/application.yml, but does not expose
Micronaut's environment as a general source of Rainbow Gum properties. Appender,
output, and encoder settings can be supplied through logging.properties
with rainbowgum-simple-props.
JBoss Logging
JBoss Logging is sadly another logging facade, but unlike Commons Logging below it does not auto-select SLF4J just becauseslf4j-api is
on the classpath - without help it silently falls back to java.util.logging, still reporting
e.g. isInfoEnabled() correctly the whole time, so nothing looks broken.
The rainbowgum-jboss-logging module is a true, native
LoggerProvider implementation (not a bridge through SLF4J at all),
registered the same way any ServiceLoader provider is. Add the dependency
and it is picked up automatically, no configuration needed:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-jboss-logging</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
If rainbowgum-jboss-logging is not on the classpath, Rainbow Gum's SLF4J implementation
still tries to help the SLF4J-bridge path along: when it initializes it sets
org.jboss.logging.provider=slf4j if that system property is not already set.
if (System.getProperty("org.jboss.logging.provider") == null) {
System.setProperty("org.jboss.logging.provider", "slf4j");
}
LoggerProviders class loads - if a JBoss-Logging-using library logs something before the
application has touched SLF4J at all, this is too late and the silent java.util.logging
fallback still happens. rainbowgum-jboss-logging does not have this ordering problem, since
ServiceLoader is consulted before any of JBoss Logging's own bridge fallbacks.
Log4j2
Log4j2 is a logging facade in its own right (not just an implementation) with a much larger method surface than SLF4J: dozens of per-level overloads across message type, parameter count,Marker, and Throwable.
The rainbowgum-log4j2 module is a true, native
LoggerContextFactory implementation (not a bridge through SLF4J at
all), registered via ServiceLoader. Add the dependency and it is picked up
automatically, no configuration needed:
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-log4j2</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
Log4j2's AbstractLogger (the base class every real Log4j2 backend
extends) absorbs almost all of that combinatorial surface itself: it declares no abstract methods of its
own, funneling every overload down to three methods a backend must actually implement:
isEnabled(Level),
isEnabled(Level, Marker),
and
logMessage(...),
plus around fifteen further isEnabled(Level, Marker, <message-type>, Throwable...)
overloads declared directly on ExtendedLogger that
AbstractLogger leaves unimplemented (confirmed by inspection, matching
org.apache.logging.log4j:log4j-to-slf4j's own upstream reference implementation), all of
which just delegate to the level+marker check since Rainbow Gum has no message-content-based filtering.
Marker is accepted but otherwise ignored, matching
rainbowgum-jboss-logging's own treatment of markers: Rainbow Gum's router has no
marker-based filtering or routing concept.
Commons Logging (jcl)
Commons Logging's own story has changed enough over time that it boils down to three cases, and in every one of them the fix is either nothing or a version bump, neverjcl-over-slf4j:
| Case | What's actually happening | Action needed |
|---|---|---|
| Spring Framework 6 / Spring Boot 3 (or earlier) | Ships spring-jcl
(Spring's own minimal LogFactory), which has auto-detected SLF4J (or the
Log4j2 API) on the classpath since Spring Framework 5.0, falling back to
java.util.logging only if neither is present. |
Nothing. |
| Spring Framework 7 / Spring Boot 4 (or later) | spring-jcl was dropped; Spring depends directly on the real
Apache Commons Logging
(Spring Framework 7.0.0 pins 1.3.5). |
Nothing. |
| Not using Spring, calling Commons Logging directly | Real Commons Logging's own LogFactory auto-detects org.slf4j.Logger
on the classpath and self-selects an SLF4J-backed factory - but only since
1.3.0 (November 2023). |
Upgrade to 1.3.0 or later. Rainbow Gum does not officially support Commons Logging older than that - there is no auto-detection to rely on before it. |
jcl-over-slf4j is strongly discouraged, not just unnecessary once on
Commons Logging 1.3+: it ships its own org.apache.commons.logging.LogFactory/
Log classes under the exact same fully qualified names as real
Commons Logging. Having
both on the classpath at once is not an error, it is a silent, classloader-order-dependent
conflict over which jar's classes actually get loaded. Upgrade Commons Logging instead of
reaching for it.
Extension development guide
To a develop a custom plugin it is probably easiest to look at the other rainbow gum modules. An important concept with extensions are the registries which allow you to register a provider with a URI scheme.LogConfig contains all the registries
and a special generic registry: ServiceRegistry.
The service registry is allows other plugins to share components.
To access these registries at configuration time as a plugin is to implement
a RainbowGumServiceProvider.Configurator.
Next one should construct builders that are properties friendly to create the components
so that programmatic configuration users can use your extension. Rainbow Gum provides
an annotation processor that helps create builders
io.jstach.rainbowgum.annotation.
<properties>
<rainbowgum.version>1.0.0-SNAPSHOT</rainbowgum.version>
</properties>
...
<dependencies>
<dependency>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-core</artifactId>
<version>${rainbowgum.version}</version>
<scope>compile</scope>
</dependency>
<!-- The generated builders use JSpecify nullness annotations -->
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.1</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- The annotation processor, which must never be a transitive dependency -->
<annotationProcessorPaths>
<path>
<groupId>io.jstach.rainbowgum</groupId>
<artifactId>rainbowgum-apt</artifactId>
<version>${rainbowgum.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
annotationProcessorPaths because JDK 23 and later no longer run
annotation processors found only on the class path. The generated builders reference
org.jspecify.annotations.Nullable, and JSpecify is not a transitive dependency of
rainbowgum-core, so it is declared too.
Once the annotation processor is enabled one can create a properties aware builder with a
single static factory method annotated with LogConfigurable.
GraalVM Native Image
Rainbow Gum is designed to work under GraalVM native-image with no extra configuration on the consuming side. Modules that need native-image build arguments bundle their ownnative-image.properties (at
META-INF/native-image/io.jstach.rainbowgum/<i>artifact-name</i>/), which
native-image's own embedded configuration discovery picks up automatically from any jar
on the build classpath. As of this writing, two modules bundle one:
rainbowgum-simple-propsbundles aresource-config.jsoncovering the defaultlogging.propertiesresource name (seeSimplePropertiesfor details), so it is included in the native image automatically.rainbowgum-jdkandrainbowgum-systemlogger(thejava.lang.System.LoggerFinderintegration) each bundle an--initialize-at-build-time=...argument, one coveringSystemLoggingFactoryplus a handful of core support classes (RainbowGumHolder,LogPropertiesand itsStandardPropertieshelper,PropertySuccess,ServiceLoaderCache), the other coveringRainbowGumSystemLoggerFinder$RouterProviderplusGlobalLogRouterandStaticLevelResolver. This exists because the JDK's own internals (java.time/java.util.Localeformatting, and separatelycom.oracle.svm.core.jdk.TrustStoreManagerFeatureloading the default trust store at build time) callSystem.getLogger(...)incidentally, for their own diagnostics, from unrelated code paths that end up reachable during a real native-image build, and whichever registeredSystem.LoggerFinderis on the classpath gets swept up regardless, along with whatever its resolution touches. What actually gets resolved and frozen in for these specific classes is just the cheap "nothing bound yet" state (no realRainbowGumhas loaded), the same state any freshly started JVM begins in, not anything environment-specific baked in from the build. Seeio.jstach.rainbowgum.jdk.systemlogger.SystemLoggingFactory(not part ofrainbowgum-jdk's exported API) andRainbowGumSystemLoggerFinderfor the exact flags and the full explanation.
--initialize-at-build-time, check first whether it is reachable this same way
(an incidental System.getLogger(...)/System.LoggerFinder touch resolving
to harmless "nothing bound yet" state); if so, adding the class to the relevant module's
own native-image.properties is the right fix, the same way the classes above were
found, by building with a real GraalVM install and reading what it reports. If instead
something is doing real, environment-dependent work eagerly in a constructor or field
initializer, that is a bug report against the module in question, not something to just
flag away.
Appender Locking Strategy: Native Image vs HotSpot
Real-workload benchmarking (virtual threads, both platforms) found the two thread-local-buffer appender types trading places depending on how the process is actually running:LogAppender.AppenderType.SYNCHRONIZED_THREAD_LOCAL_BUFFER wins by a
real, repeatable, double-digit-percentage margin under GraalVM native-image, while the default,
LogAppender.AppenderType.LOCK_THREAD_LOCAL_BUFFER, wins by a
comparable margin on a regular JVM (HotSpot) - a genuine cross-over, not just a shrinking gap, so
no single fixed choice is best on both.
This matters most for something distributed as more than one build of the same artifact for the
same deployment - a native executable and a plain jar, say - where picking correctly per build
would otherwise mean either shipping different configuration for each or sniffing the platform in
application code.
LogAppender.AppenderType.AUTO_DETECT does that sniffing instead,
resolving once, at appender construction time, to whichever of the two types wins on the platform
actually running (via the same org.graalvm.nativeimage.imagecode system property check
every native-image-aware framework uses, no GraalVM SDK dependency required):
logging.appender.console.type=auto_detect
auto_detect is to track whichever choice is
actually best, so that mapping may change in a future release. If a deployment needs a specific
type to always be used, pick that type explicitly instead of relying on what auto_detect
happens to resolve to right now.
Not the default - auto_detect must be requested explicitly, the same as
any other non-default LogAppender.AppenderType.
Opting Every Appender/Encoder Into Platform-Specific Defaults At Once
Settingauto_detect per appender works, but it is still a choice made per appender, and
LogAppender.AppenderType.AUTO_DETECT is not the only place this
project has found a platform-specific win - the same native-image benchmarking found
String.getBytes(Charset)-based text encoding measurably faster there too, ahead of the
default CharsetEncoder-based copy. logging.global.optimize opts every
appender/encoder that has not been given a more specific, explicit choice into whichever of these
this release currently believes is best for the platform actually running, all at once:
logging.global.optimize=true
appenderType(...), useGetBytes(...)) or via a more specific property
(LogAppender.APPENDER_TYPE_PROPERTY) - this property only ever fills
in what would otherwise be left unset.
What this currently does, and why that is not a contract: today it only has an
effect under GraalVM native-image (detected the same way auto_detect is, above) - the
default appender type becomes auto_detect, and text encoders default to
String.getBytes(Charset)-based encoding. On a plain JVM this property currently changes
nothing - the same benchmarking that motivated the native-image behavior pointed the other way on
HotSpot. Exactly like auto_detect itself, none of this is a promise: as this project
benchmarks more platforms, JDK versions, and encode strategies, what logging.global.optimize
resolves to on a given platform - including whether it does anything on a plain JVM at all - can
and will change between releases without notice. Pin an explicit appender type/encoder strategy
instead if a deployment needs a specific one to always be used.
test/rainbowgum-test-native in the project's own repository is a working,
end-to-end example: a small application depending only on rainbowgum-simple plus
a custom, ServiceLoader-discovered RainbowGumServiceProvider.RainbowGumProvider,
built to a real native executable and run as a black-box smoke test in CI, with no
manual native-image flags of its own.
Troubleshooting
When logging does not do what you expect, run the application once with-Dlogging.debug=help:
java -Dlogging.debug=help -jar app.jar
help mode Rainbow Gum:
- prints the alerts it collected while loading properties and building its configuration,
and the diagnostic report of the components it actually wired up
(the same output as
-Dlogging.debug=all, see Alerts); - reports every property key that was set but not read during startup, with the key it was probably meant to be (see below);
- fails startup if any warning or error alert was recorded, including a misspelled key, so the run stops with the reasons in front of you.
java -Dlogging.debug=help -Dlogging.appender.console.encoder.uri=ttll \
-Dlogging.appender.console.encoder.ttll.levle=plain -jar app.jar
[WARN] - RAINBOW_GUM - LogConfig - Property key 'logging.appender.console.encoder.ttll.levle' from SYSTEM_PROPERTIES[logging.appender.console.encoder.ttll.levle] was set but not read during startup. Did you mean 'logging.appender.console.encoder.ttll.level'?
java.lang.IllegalStateException: 1 alert(s) at warning or above were recorded while starting and logging.debug=help:
[WARNING] Property key 'logging.appender.console.encoder.ttll.levle' from SYSTEM_PROPERTIES[logging.appender.console.encoder.ttll.levle] was set but not read during startup. Did you mean 'logging.appender.console.encoder.ttll.level'?
ExceptionInInitializerError from a static logger field). The reason is in the
cause, and it is also printed on its own just before the stack trace.
Like the other debug modes, logging.debug is read before any property source is
loaded, so it is a system property (or the
builder option) and cannot be set in logging.properties. To fail on alerts without
printing anything extra, for example in CI, use
logging.alerts.fail instead.
Misspelled and unused property keys
A misspelled property key is otherwise silently ignored: Rainbow Gum looks properties up by key, so a key nothing asks for does nothing. With-Dlogging.debug=all,
-Dlogging.debug=help, or logging.alerts.fail=warning, Rainbow Gum
records every key it reads, and right after it starts it reports each key that was set but
never read:
- An unread key close to a key that was read is reported as a warning
with a suggestion. Close means the same key ignoring case,
-, and_(keyvalues,KEYVALUES, andkey-valuesall suggestkeyValues), or at most two characters different (apenderssuggestsappenders). Suggestions only ever name keys that something actually read. - Any other unread key is reported as information. It may be a mistake,
or it may be meant for a component that was not chosen, such as TTLL options left in
logging.propertiesby a profile that switches the encoder tojson5. A misspelled key of an encoder that was not chosen is also only information, since none of that encoder's keys were read to suggest. With the default unused-key level, only warnings can fail startup.
logging.alerts.unusedKey makes this stricter for a configuration that should
have no leftovers: warning (or warn) reports every unread key as a
warning, and error as an error. With
logging.alerts.fail at the same level
or below, any unread key then fails startup. The default, info, is the behavior
above, and
LogConfig.Builder.alertsUnusedKey(io.jstach.rainbowgum.LogAlerts.UnusedKeyLevel)
sets the default the property overrides.
# logging-ci.properties: fail on any key nothing reads
logging.alerts.fail=warning
logging.alerts.unusedKey=warning
- System properties starting with
logging., always. A-Dtypo is the most common case and is caught without any other module. logging.propertiesand its profile files when using rainbowgum-simple-props.- Any other property source implementing
LogProperties.Listable. Environment variables are not checked, because their names do not keep the dots and case of the keys.
logging.level keys (and route level keys) and
logging.change.logger keys, which are read per logger as loggers are created. A misspelled prefix
such as logging.levle.com.myapp is therefore only information, since no level key is
read up front to suggest. Also never reported are keys read before properties load, such as
logging.debug and logging.profiles; and other libraries' system
properties that happen to start with logging., such as Spring Boot's
logging.config and JBoss LogManager's logging.configuration. Unless
debug mode is all or help, or logging.alerts.fail would
fail on a misspelled key, nothing is recorded, so normal runs pay nothing for the check.
Failing on alerts
logging.alerts.fail fails startup when an alert at or above a
level is recorded while starting, similar to treating compiler warnings as errors. It is
off by default; error fails on errors; warning (or
warn or true) fails on warnings too, including misspelled keys. Unlike
logging.debug it is an ordinary property, so it can be set in
logging.properties, a profile, a system property, or an environment variable, and
LogConfig.Builder.alertsFail(io.jstach.rainbowgum.LogAlerts.FailLevel)
sets the default it overrides:
# logging-ci.properties, selected with -Dlogging.profiles=ci
logging.alerts.fail=warning
logging.alerts.unobservedErrorsAction is different: it only acts
on errors, and only when no alert listener is registered.
Reporting a bug
If the problem is still unclear, run with-Dlogging.debug=help (or
-Dlogging.debug=all if you do not want the run to stop) and paste the printed
alerts and report, from Rainbow Gum started: on, into a
GitHub issue, along with the
properties you set. The report shows the Rainbow Gum version, where properties came from, and
which appenders, encoders, and outputs were actually chosen, which usually answers the first
questions. Look it over before posting: alerts and errors can include property values.
FAQ
There is a typo in this documentation how can I fix it?
If you would like to make corrections please file an issue or even better fork, edit, PR this file:doc/overview.html
Where is the Javadoc?
Shockingly this document is the Javadoc! To be precise it is the aggregate javadocoverview.html.
The modules javadocs should be at the bottom of this document and the search bar at the top can be
used to find documented classes.
FileOutput registered under the
file URI scheme - plus bare minimum size based rolling on top of it, see
RollingFileOutput for the full property list and
scope - deliberately does not support calendar/date based rotation or Logback's own
fileNamePattern conventions, only a numbered-suffix (%i) scheme.LoggingProvider extension point
(the same one io.helidon.logging.jul.JulProvider/
io.helidon.logging.log4j.Log4jProvider implement), registered via
ServiceLoader.LoggingProvider extension point
(the same one io.helidon.logging.jul.JulProvider/
io.helidon.logging.log4j.Log4jProvider implement), registered via
ServiceLoader.LoggerProvider
implementation, not a bridge through SLF4J.LogOutput that commits each log event as a
JDK
Flight Recorder event instead of writing bytes anywhere, with URI scheme
"jfr", as well as a
LogAlerts.Listener that does the same for alerts (opt-in,
see JfrAlertListenerConfigurator).java.util.logging) integration.LogManager replacement that routes every
java.util.logging.Logger directly through Rainbow Gum, bypassing the
standard JUL Handler/Level dispatch machinery entirely.LoggerContextFactory implementation, not a
bridge through SLF4J.traceId and spanId, the names Micrometer
Tracing uses and the OTLP output reads.ScopedKeyValues.io.jstach.rainbowgum.scopedkeyvalues.spi.ScopedKeyValuesProvider for
io.jstach.rainbowgum.scopedkeyvalues.ScopedKeyValues - request/task-scoped key
values backed by java.lang.ScopedValue instead of ThreadLocal.LogProperties from system properties, environment
variables, profile resources, and a classpath logging.properties resource.io.jstach.rainbowgum.spring.boot3 and
io.jstach.rainbowgum.spring.boot4.