LogfmtFormatter.java
package io.jstach.rainbowgum.format;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.KeyValues.KeyValuesConsumer;
import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogFormatter;
import io.jstach.rainbowgum.LogFormatter.LevelFormatter;
import io.jstach.rainbowgum.LogFormatter.ThrowableFormatter;
import io.jstach.rainbowgum.LogFormatter.TimestampFormatter;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogProperty;
import io.jstach.rainbowgum.ServiceRegistry;
import io.jstach.rainbowgum.annotation.LogConfigurable;
/*
* logfmt (https://brandur.org/logfmt). Package private: the public API is the generated
* LogfmtFormatterBuilder and the logfmt encoder scheme; logfmt key values on their own
* come from KeyValuesFormatterBuilder. Quoting and escaping are shared with
* LogfmtKeyValuesFormatter.
*/
final class LogfmtFormatter implements LogFormatter.EventFormatter {
/** {@link io.jstach.rainbowgum.LogFormatter.LevelFormatter#of() level_formatter} */
static final LevelFormatter DEFAULT_LEVEL_FORMATTER = LevelFormatter.of();
/*
* Fixed width UTC milliseconds, cached per millisecond.
*/
private static final TimestampFormatter TIME = TimestampFormatter.ofISO();
private final LevelFormatter levelFormatter;
private final Palette palette;
private LogfmtFormatter(LevelFormatter levelFormatter, Palette palette) {
this.levelFormatter = levelFormatter;
this.palette = palette;
}
/**
* Formats events as logfmt, one line of key=value pairs per event like Go's log/slog
* text handler, selected with the logfmt encoder scheme.
* <p>
* Each event is written as one line, for example:
* <code>time=2026-10-05T15:04:05.123Z level=INFO logger=com.example.App thread=main msg="hello world" requestId=42</code>
* <ul>
* <li><code>time</code> is the UTC instant with milliseconds.</li>
* <li><code>level</code> is written by the level formatter, by default
* <code>TRACE</code>, <code>DEBUG</code>, <code>INFO</code>, <code>WARN</code>, or
* <code>ERROR</code>. logfmt does not define level names, and tools differ (Go's slog
* is upper case, logrus lower case).</li>
* <li>Every key value follows <code>msg</code>, in order. A key value named like one
* of these fields is prefixed with <code>keyValues.</code>.</li>
* <li>A throwable adds <code>error</code> (its <code>toString()</code>) and
* <code>stacktrace</code>.</li>
* </ul>
* A value is quoted when it is empty or contains a space, <code>=</code>,
* <code>"</code>, or a control character. Inside quotes <code>"</code>,
* <code>\</code>, newline, carriage return, and tab are backslash escaped and other
* control characters are written as <code>\</code><code>uXXXX</code>, so every event
* stays on one line. Characters logfmt does not allow in keys, and <code>%</code>,
* are percent-encoded (<code>a b</code> is <code>a%20b</code>), and an empty key is
* <code>%</code>, so different keys stay different.
* <p>
* Values can be colored like the TTLL formatter; keys, <code>msg</code>,
* <code>error</code>, and <code>stacktrace</code> are not. Padding from a padding
* level formatter is written after the level's color.
* @param name encoder name, used for property lookup.
* @param levelFormatter level formatter; as a property, a level formatter constant
* name.
* @param color whether to color values, see {@link TTLL.ColorMode}.
* @param theme which colors, see {@link TTLL.ColorTheme}.
* @param serviceRegistry where themes chosen by name in properties are looked up.
* @return formatter.
*/
@LogConfigurable(name = "LogfmtFormatterBuilder", prefix = LogProperties.ENCODER_PREFIX + "logfmt.")
static LogFormatter of(@LogConfigurable.KeyParameter String name,
@LogConfigurable.DefaultParameter("DEFAULT_LEVEL_FORMATTER") @LogConfigurable.ConvertParameter("convertLevelFormatter") LevelFormatter levelFormatter,
@LogConfigurable.ConvertParameter("convertColor") TTLL.@Nullable ColorMode color,
@LogConfigurable.ConvertParameter("convertTheme") TTLL.@Nullable ColorTheme theme,
@LogConfigurable.PassThroughParameter @Nullable ServiceRegistry serviceRegistry) {
return new LogfmtFormatter(levelFormatter, Palette.of(color == null ? TTLL.ColorMode.DEFAULT : color,
PaletteColorTheme.resolve(theme, serviceRegistry), AnsiSupport::isAnsiSupported));
}
static TTLL.ColorMode convertColor(String value) {
return TTLLFormatter.convertColor(value);
}
static TTLL.ColorTheme convertTheme(String value) {
return TTLLFormatter.convertTheme(value);
}
static LevelFormatter convertLevelFormatter(String value) {
return LogProperty.enumValue(LevelFormatterChoice.class, value).levelFormatter();
}
/*
* The level formatter property's values, named after core's level formatter constants
* so the property values stayed the same when this moved out of core.
*/
enum LevelFormatterChoice {
LEVEL_FORMATTER, RIGHT_PAD_LEVEL_FORMATTER;
LevelFormatter levelFormatter() {
return switch (this) {
case LEVEL_FORMATTER -> LevelFormatter.of();
case RIGHT_PAD_LEVEL_FORMATTER -> LevelFormatter.ofRightPadded();
};
}
}
/*
* A key value named like a field written here gets a keyValues. prefix, as in the
* JSON encoders, so no key is written twice.
*/
private static final KeyValuesConsumer<StringBuilder> KEY_VALUES = (kvs, key, value, count,
output) -> LogfmtKeyValuesFormatter.ALL.accept(kvs,
collides(key) ? Json5Formatter.KEY_VALUES_FIELD + "." + key : key, value, count, output);
private static boolean collides(String key) {
return Json5Formatter.FIELDS.contains(key) || key.startsWith(Json5Formatter.KEY_VALUES_FIELD + ".");
}
@Override
public void format(StringBuilder output, LogEvent event) {
var p = palette;
output.append("time=");
Palette.start(output, p.timestamp());
TIME.formatTimestamp(output, event.timestamp());
Palette.end(output, p.timestamp());
output.append(" level=");
String levelColor = p.levelColor(event.level());
Palette.start(output, levelColor);
int levelStart = output.length();
levelFormatter.formatLevel(output, event.level());
if (!levelColor.isEmpty()) {
int padding = Palette.removeTrailingSpaces(output, levelStart);
Palette.end(output, levelColor);
Palette.spaces(output, padding);
}
output.append(" logger=");
Palette.start(output, p.logger());
LogfmtKeyValuesFormatter.appendValue(output, event.loggerName());
Palette.end(output, p.logger());
output.append(" thread=");
Palette.start(output, p.thread());
LogfmtKeyValuesFormatter.appendValue(output, event.threadName());
Palette.end(output, p.thread());
output.append(" msg=");
int start = output.length();
event.formattedMessage(output);
LogfmtKeyValuesFormatter.quoteInPlace(output, start);
var keyValues = event.keyValues();
if (!keyValues.isEmpty()) {
output.append(' ');
Palette.start(output, p.keyValues());
keyValues.forEach(KEY_VALUES, 0, output);
Palette.end(output, p.keyValues());
}
var throwable = event.throwableOrNull();
if (throwable != null) {
output.append(" error=");
LogfmtKeyValuesFormatter.appendValue(output, throwable.toString());
output.append(" stacktrace=");
start = output.length();
ThrowableFormatter.appendThrowable(output, throwable);
trimTrailingLineBreaks(output, start);
LogfmtKeyValuesFormatter.quoteInPlace(output, start);
}
output.append('\n');
}
private static void trimTrailingLineBreaks(StringBuilder sb, int start) {
int end = sb.length();
while (end > start && (sb.charAt(end - 1) == '\n' || sb.charAt(end - 1) == '\r')) {
end--;
}
sb.setLength(end);
}
}