GelfEncoder.java
package io.jstach.rainbowgum.json.encoder;
import static io.jstach.rainbowgum.json.JsonBuffer.EXTENDED_F;
import java.lang.System.Logger.Level;
import java.time.Instant;
import java.time.format.DateTimeFormatterBuilder;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.KeyValues;
import io.jstach.rainbowgum.LogEncoder;
import io.jstach.rainbowgum.LogEvent;
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.LogProvider;
import io.jstach.rainbowgum.annotation.LogConfigurable;
import io.jstach.rainbowgum.json.JsonBuffer;
import io.jstach.rainbowgum.json.JsonBuffer.ExtendedFieldPrefix;
import io.jstach.rainbowgum.json.JsonBuffer.JSONToken;
/**
* A JSON encoder in
* <a href="https://go2docs.graylog.org/5-2/getting_in_log_data/gelf.html">GELF JSON
* format</a>. Key values are written as additional fields. A key value named like an
* additional field this encoder writes (e.g. <code>level</code>), or <code>id</code>, is
* prefixed with <code>keyValues.</code> so no field is written twice.
*/
public final class GelfEncoder extends LogEncoder.AbstractEncoder<JsonBuffer> {
/**
* GELF encoder URI scheme.
*/
public static final String GELF_SCHEME = "gelf";
/*
* The additional fields this encoder writes, without their underscore, and "id",
* which GELF does not allow as an additional field.
*/
private static final KeyValuesWriter KEY_VALUES = new KeyValuesWriter(KeyValuesWriter.Placement.DOTTED,
Set.of("time", "level", "logger", "thread_name", "thread_id", "throwable", "id"), EXTENDED_F);
private final String host;
private final KeyValues headers;
private final boolean prettyPrint;
/**
* Default number of fractional second digits the <code>_time</code> field is rendered
* with, see {@link #of(String, String, Map, Boolean, Integer, Integer)}.
*/
public static final int DEFAULT_TIME_FRACTIONAL_DIGITS = 3;
private final TimestampFormatter timeFormatter;
private final int maxBufferSize;
GelfEncoder(String host, KeyValues headers, boolean prettyPrint, int timeFractionalDigits, int maxBufferSize) {
super();
this.host = host;
this.headers = headers;
this.prettyPrint = prettyPrint;
this.timeFormatter = timeFormatter(timeFractionalDigits);
this.maxBufferSize = maxBufferSize;
}
/*
* GELF's own "timestamp" field (a UNIX epoch double, see doEncode) is always
* millisecond precision by construction (derived from Instant.toEpochMilli()), so it
* needs no formatter and is unaffected by this. "_time" is a RainbowGum-only extra
* field giving a human-readable ISO-8601 instant alongside it - unlike "timestamp",
* Instant.now() on most JVMs has real nanosecond resolution, so formatting it with
* DateTimeFormatter.ISO_INSTANT (variable-width, 0-9 fractional digits depending on
* the actual instant) is both needlessly precise for a log timestamp and, because a
* fresh format() call happens on every single event, needlessly expensive. Fixing the
* width to timeFractionalDigits makes the output caching-safe the same way
* DefaultInstantFormatter.ISO's fix did (see LogFormatter/PatternFormatterFactory) -
* caching only kicks in at millisecond-or-coarser precision (<=3 digits), since finer
* precision genuinely varies within the same millisecond.
*/
private static TimestampFormatter timeFormatter(int fractionalDigits) {
if (fractionalDigits < 0 || fractionalDigits > 9) {
throw new IllegalArgumentException(
"timeFractionalDigits must be between 0 and 9 inclusive: " + fractionalDigits);
}
var dtf = new DateTimeFormatterBuilder().appendInstant(fractionalDigits).toFormatter();
return TimestampFormatter.of(dtf, fractionalDigits <= 3);
}
/**
* Creates a GELF encoder using a lambda for easier registration. The builder will
* have properties loaded after the consumer has configured the builder.
* @param consumer lambda to configure builder.
* @return GELF encoder provider.
*/
public static LogProvider<GelfEncoder> of(Consumer<GelfEncoderBuilder> consumer) {
return (s, c) -> {
var b = new GelfEncoderBuilder(s);
consumer.accept(b);
return b.fromProperties(c.properties()).build();
};
}
/**
* Creates GELF Encoder.
* @param name property name prefix.
* @param host host field in GELF.
* @param headers additional headers that will be prefix with "_".
* @param prettyPrint <code>true</code> will pretty print the JSON, default is false.
* @param timeFractionalDigits number of fractional second digits (0-9) the
* non-standard <code>_time</code> field is rendered with, default
* {@value GelfEncoder#DEFAULT_TIME_FRACTIONAL_DIGITS} (milliseconds). Values above 3
* render genuine sub-millisecond precision (most JVMs'
* {@link java.time.Instant#now()} has real nanosecond resolution) but cannot be
* cached the way millisecond-or-coarser precision can, since it can legitimately
* differ between two events in the same millisecond.
* @param maxBufferSize maximum buffer size - a soft ceiling checked between events,
* not a hard cap enforced on any single event (see
* {@link io.jstach.rainbowgum.LogEncoder.Buffer#isOversized()}). A negative value
* (the default) disables this entirely.
* @return encoder.
*/
@LogConfigurable(prefix = LogProperties.ENCODER_PREFIX + "gelf.")
static GelfEncoder of(@LogConfigurable.KeyParameter String name, String host, //
@Nullable Map<String, String> headers, //
@Nullable Boolean prettyPrint, @Nullable Integer timeFractionalDigits, @Nullable Integer maxBufferSize) {
prettyPrint = prettyPrint == null ? false : prettyPrint;
Objects.requireNonNull(host);
var _headers = KeyValues.of(headers == null ? Map.of() : headers);
int _timeFractionalDigits = timeFractionalDigits == null ? DEFAULT_TIME_FRACTIONAL_DIGITS
: timeFractionalDigits;
int _maxBufferSize = maxBufferSize == null ? -1 : maxBufferSize;
return new GelfEncoder(host, _headers, prettyPrint, _timeFractionalDigits, _maxBufferSize);
}
@Override
protected JsonBuffer doBuffer(BufferHints hints) {
return new JsonBuffer(this.prettyPrint, ExtendedFieldPrefix.UNDERSCORE, maxBufferSize);
}
@Override
protected void doEncode(LogEvent event, JsonBuffer buffer) {
buffer.clear();
var formattedMessage = buffer.getFormattedMessageBuilder();
final String host = this.host;
event.formattedMessage(formattedMessage);
final String shortMessage = formattedMessage.toString();
Instant now = event.timestamp();
final double timeStamp = ((double) now.toEpochMilli()) / 1000;
@Nullable String fullMessage = null;
var t = event.throwableOrNull();
if (t != null) {
formattedMessage.append("\n");
ThrowableFormatter.appendThrowable(formattedMessage, t);
fullMessage = formattedMessage.toString();
}
int level = levelToSyslogLevel(event.level());
buffer.write(JSONToken.OBJECT_START);
int index = 0;
index = buffer.write("host", host, index);
index = buffer.write("short_message", shortMessage, index);
index = buffer.write("full_message", fullMessage, index);
index = buffer.writeDouble("timestamp", timeStamp, index, 0);
index = buffer.writeInt("level", level, index, 0);
StringBuilder timeBuilder = new StringBuilder(32);
timeFormatter.formatTimestamp(timeBuilder, now);
index = buffer.write("_time", timeBuilder.toString(), index);
index = buffer.write("_level", LevelFormatter.toString(event.level()), index);
index = buffer.write("_logger", event.loggerName(), index);
index = buffer.write("_thread_name", event.threadName(), index);
index = buffer.write("_thread_id", String.valueOf(event.threadId()), index);
if (t != null) {
String tn = t.getClass().getName();
index = buffer.write("_throwable", tn, index);
}
var kvs = event.keyValues();
/*
* output headers
*/
index = headers.forEach((src, k, v, idx, buf) -> {
if (kvs.getValueOrNull(k) != null) {
return idx;
}
return buf.write(k, v, idx, EXTENDED_F);
}, index, buffer);
/*
* output MDC
*/
index = KEY_VALUES.write(kvs, buffer, index);
index = buffer.write("version", "1.1", index);
if (index > 0 && prettyPrint) {
buffer.writeLineFeed();
}
buffer.write(JSONToken.OBJECT_END);
buffer.writeLineFeed();
}
private int levelToSyslogLevel(Level level) {
/*
* FROM LOGBACK The syslog severity of a logging event is converted from the level
* of the logging event. The DEBUG level is converted to 7, INFO is converted to
* 6, WARN is converted to 4 and ERROR is converted to 3.
*/
int r = switch (level) {
case ERROR -> 3;
case DEBUG -> 7;
case INFO -> 6;
case TRACE -> 7;
case WARNING -> 4;
case ALL -> 7;
case OFF -> 0;
};
return r;
}
}