EcsEncoder.java

package io.jstach.rainbowgum.json.encoder;

import java.time.format.DateTimeFormatter;
import java.util.Set;
import java.util.function.Consumer;

import org.jspecify.annotations.Nullable;

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.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://www.elastic.co/guide/en/ecs-logging/java/current/index.html">Elastic
 * Common Schema (ECS) logging format</a>.
 * <p>
 * Two shapes are supported, controlled by {@link #structured()}:
 * <ul>
 * <li><strong>Flattened (default)</strong>: field names are the flattened, dotted ECS
 * field names (e.g. <code>"log.level"</code>) rather than nested JSON objects, matching
 * how the reference <a href="https://github.com/elastic/ecs-logging-java">
 * ecs-logging-java</a> implementation serializes them.</li>
 * <li><strong>Structured</strong> ({@link EcsEncoderBuilder#structured(Boolean)
 * structured=true}): fields are nested JSON objects (e.g.
 * <code>"log":{"level":...}</code>), matching
 * <a href="https://docs.spring.io/spring-boot/reference/features/logging.html">Spring
 * Boot's ECS structured logging format</a>.</li>
 * </ul>
 * Key values are written as top-level fields using their own key name, except a key value
 * named like a field this encoder writes (e.g. <code>message</code>), which goes in a
 * nested <code>keyValues</code> object, or is prefixed with <code>keyValues.</code> in
 * the flattened shape, so no field is written twice.
 */
public final class EcsEncoder extends LogEncoder.AbstractEncoder<JsonBuffer> {

	/**
	 * ECS encoder URI scheme.
	 */
	public static final String ECS_SCHEME = "ecs";

	/**
	 * The ECS schema version this encoder targets.
	 */
	public static final String ECS_VERSION = "1.2.0";

	private final @Nullable String serviceName;

	private final @Nullable String serviceVersion;

	private final @Nullable String serviceEnvironment;

	private final @Nullable String serviceNodeName;

	private final @Nullable String eventDataset;

	private final boolean structured;

	private final KeyValuesWriter keyValues;

	private final boolean prettyPrint;

	private final int maxBufferSize;

	private static final DateTimeFormatter timeFormatter = DateTimeFormatter.ISO_INSTANT;

	EcsEncoder(@Nullable String serviceName, @Nullable String serviceVersion, @Nullable String serviceEnvironment,
			@Nullable String serviceNodeName, @Nullable String eventDataset, boolean structured, boolean prettyPrint,
			int maxBufferSize) {
		super();
		this.serviceName = serviceName;
		this.serviceVersion = serviceVersion;
		this.serviceEnvironment = serviceEnvironment;
		this.serviceNodeName = serviceNodeName;
		this.eventDataset = eventDataset;
		this.structured = structured;
		this.keyValues = structured ? STRUCTURED_KEY_VALUES : FLATTENED_KEY_VALUES;
		this.prettyPrint = prettyPrint;
		this.maxBufferSize = maxBufferSize;
	}

	/**
	 * Whether fields are nested JSON objects (Spring Boot's ECS shape) instead of the
	 * default flattened, dotted field names (the reference ecs-logging-java shape).
	 * @return true if fields are nested.
	 */
	public boolean structured() {
		return this.structured;
	}

	/**
	 * Creates an ECS 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 ECS encoder provider.
	 */
	public static LogProvider<EcsEncoder> of(Consumer<EcsEncoderBuilder> consumer) {
		return (s, c) -> {
			var b = new EcsEncoderBuilder(s);
			consumer.accept(b);
			return b.fromProperties(c.properties()).build();
		};
	}

	/**
	 * Creates ECS Encoder.
	 * @param name property name prefix.
	 * @param serviceName <code>service.name</code> field, or null to omit.
	 * @param serviceVersion <code>service.version</code> field, or null to omit.
	 * @param serviceEnvironment <code>service.environment</code> field, or null to omit.
	 * @param serviceNodeName <code>service.node.name</code> field, or null to omit.
	 * @param eventDataset <code>event.dataset</code> field, or null to omit.
	 * @param structured <code>true</code> nests fields as JSON objects (Spring Boot's ECS
	 * shape) instead of flattened dotted field names, default is false.
	 * @param prettyPrint <code>true</code> will pretty print the JSON, default is false.
	 * @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 + "ecs.")
	static EcsEncoder of(@LogConfigurable.KeyParameter String name, @Nullable String serviceName,
			@Nullable String serviceVersion, @Nullable String serviceEnvironment, @Nullable String serviceNodeName,
			@Nullable String eventDataset, @Nullable Boolean structured, @Nullable Boolean prettyPrint,
			@Nullable Integer maxBufferSize) {
		prettyPrint = prettyPrint == null ? false : prettyPrint;
		structured = structured == null ? false : structured;
		int _maxBufferSize = maxBufferSize == null ? -1 : maxBufferSize;
		return new EcsEncoder(serviceName, serviceVersion, serviceEnvironment, serviceNodeName, eventDataset,
				structured, prettyPrint, _maxBufferSize);
	}

	@Override
	protected JsonBuffer doBuffer(BufferHints hints) {
		return new JsonBuffer(this.prettyPrint, ExtendedFieldPrefix.AT, maxBufferSize);
	}

	@Override
	protected void doEncode(LogEvent event, JsonBuffer buffer) {
		buffer.clear();
		var formattedMessage = buffer.getFormattedMessageBuilder();
		event.formattedMessage(formattedMessage);

		int index = structured ? encodeStructured(event, buffer, formattedMessage)
				: encodeFlattened(event, buffer, formattedMessage);

		if (index > 0 && prettyPrint) {
			buffer.writeLineFeed();
		}
		buffer.write(JSONToken.OBJECT_END);
		buffer.writeLineFeed();
	}

	private int encodeFlattened(LogEvent event, JsonBuffer buffer, StringBuilder formattedMessage) {
		var now = event.timestamp();
		var t = event.throwableOrNull();

		buffer.write(JSONToken.OBJECT_START);
		int index = 0;
		index = buffer.write("@timestamp", timeFormatter.format(now), index);
		index = buffer.write("log.level", LevelFormatter.toString(event.level()), index);
		index = buffer.write("message", formattedMessage.toString(), index);
		index = buffer.write("ecs.version", ECS_VERSION, index);
		index = buffer.write("service.name", serviceName, index);
		index = buffer.write("service.version", serviceVersion, index);
		index = buffer.write("service.environment", serviceEnvironment, index);
		index = buffer.write("service.node.name", serviceNodeName, index);
		index = buffer.write("event.dataset", eventDataset, index);
		index = buffer.write("log.logger", event.loggerName(), index);
		index = buffer.write("process.thread.name", event.threadName(), index);

		if (t != null) {
			index = buffer.write("error.type", t.getClass().getName(), index);
			index = buffer.write("error.message", t.getMessage(), index);
			var stackTrace = new StringBuilder();
			ThrowableFormatter.appendThrowable(stackTrace, t);
			index = buffer.write("error.stack_trace", stackTrace.toString(), index);
		}

		index = keyValues.write(event.keyValues(), buffer, index);
		return index;
	}

	private int encodeStructured(LogEvent event, JsonBuffer buffer, StringBuilder formattedMessage) {
		var now = event.timestamp();
		var t = event.throwableOrNull();

		buffer.write(JSONToken.OBJECT_START);
		int index = 0;
		index = buffer.write("@timestamp", timeFormatter.format(now), index);

		int logIndex = buffer.writeObjectStart("log", index, 0);
		logIndex = buffer.write("level", LevelFormatter.toString(event.level()), logIndex, 0);
		buffer.write("logger", event.loggerName(), logIndex, 0);
		buffer.writeObjectEnd();
		index++;

		index = buffer.write("message", formattedMessage.toString(), index);

		int ecsIndex = buffer.writeObjectStart("ecs", index, 0);
		buffer.write("version", ECS_VERSION, ecsIndex, 0);
		buffer.writeObjectEnd();
		index++;

		boolean hasService = serviceName != null || serviceVersion != null || serviceEnvironment != null
				|| serviceNodeName != null;
		if (hasService) {
			int serviceIndex = buffer.writeObjectStart("service", index, 0);
			serviceIndex = buffer.write("name", serviceName, serviceIndex, 0);
			serviceIndex = buffer.write("version", serviceVersion, serviceIndex, 0);
			serviceIndex = buffer.write("environment", serviceEnvironment, serviceIndex, 0);
			if (serviceNodeName != null) {
				int nodeIndex = buffer.writeObjectStart("node", serviceIndex, 0);
				buffer.write("name", serviceNodeName, nodeIndex, 0);
				buffer.writeObjectEnd();
			}
			buffer.writeObjectEnd();
			index++;
		}

		if (eventDataset != null) {
			int eventIndex = buffer.writeObjectStart("event", index, 0);
			buffer.write("dataset", eventDataset, eventIndex, 0);
			buffer.writeObjectEnd();
			index++;
		}

		int processIndex = buffer.writeObjectStart("process", index, 0);
		int threadIndex = buffer.writeObjectStart("thread", processIndex, 0);
		buffer.write("name", event.threadName(), threadIndex, 0);
		buffer.writeObjectEnd();
		buffer.writeObjectEnd();
		index++;

		if (t != null) {
			int errorIndex = buffer.writeObjectStart("error", index, 0);
			errorIndex = buffer.write("type", t.getClass().getName(), errorIndex, 0);
			errorIndex = buffer.write("message", t.getMessage(), errorIndex, 0);
			var stackTrace = new StringBuilder();
			ThrowableFormatter.appendThrowable(stackTrace, t);
			buffer.write("stack_trace", stackTrace.toString(), errorIndex, 0);
			buffer.writeObjectEnd();
			index++;
		}

		index = keyValues.write(event.keyValues(), buffer, index);
		return index;
	}

	private static final KeyValuesWriter FLATTENED_KEY_VALUES = new KeyValuesWriter(KeyValuesWriter.Placement.DOTTED,
			Set.of("@timestamp", "log.level", "message", "ecs.version", "service.name", "service.version",
					"service.environment", "service.node.name", "event.dataset", "log.logger", "process.thread.name",
					"error.type", "error.message", "error.stack_trace"),
			0);

	private static final KeyValuesWriter STRUCTURED_KEY_VALUES = new KeyValuesWriter(KeyValuesWriter.Placement.NESTED,
			Set.of("@timestamp", "log", "message", "ecs", "service", "event", "process", "error"), 0);

}