diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java index b883105f1a..82e6c2c253 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java @@ -22,7 +22,6 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.apache.logging.log4j.core.time.Instant; -import org.apache.logging.log4j.message.Message; import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.springframework.boot.json.JsonWriter; @@ -54,7 +53,7 @@ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogF members.add("process.thread.name", LogEvent::getThreadName); ElasticCommonSchemaService.get(environment).jsonMembers(members); members.add("log.logger", LogEvent::getLoggerName); - members.add("message", LogEvent::getMessage).as(Message::getFormattedMessage); + members.add("message", LogEvent::getMessage).as(StructuredMessage::get); members.from(LogEvent::getContextData) .whenNot(ReadOnlyStringMap::isEmpty) .usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept)); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java index a085823b0d..65531a6995 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java @@ -27,7 +27,6 @@ import org.apache.logging.log4j.Marker; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.apache.logging.log4j.core.time.Instant; -import org.apache.logging.log4j.message.Message; import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.springframework.boot.json.JsonWriter; @@ -51,7 +50,7 @@ class LogstashStructuredLogFormatter extends JsonWriterStructuredLogFormatter members) { members.add("@timestamp", LogEvent::getInstant).as(LogstashStructuredLogFormatter::asTimestamp); members.add("@version", "1"); - members.add("message", LogEvent::getMessage).as(Message::getFormattedMessage); + members.add("message", LogEvent::getMessage).as(StructuredMessage::get); members.add("logger_name", LogEvent::getLoggerName); members.add("thread_name", LogEvent::getThreadName); members.add("level", LogEvent::getLevel).as(Level::name); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredMessage.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredMessage.java new file mode 100644 index 0000000000..873ee2814c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredMessage.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.io.IOException; + +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.util.MultiFormatStringBuilderFormattable; + +import org.springframework.boot.json.JsonWriter.WritableJson; + +/** + * Helper used to adapt {@link Message} for structured writing. + * + * @author Phillip Webb + */ +final class StructuredMessage { + + private static final String JSON2 = "JSON"; + + private static final String[] JSON = { JSON2 }; + + private StructuredMessage() { + } + + static Object get(Message message) { + if (message instanceof MultiFormatStringBuilderFormattable multiFormatMessage + && hasJsonFormat(multiFormatMessage)) { + return WritableJson.of((out) -> formatTo(multiFormatMessage, out)); + } + return message.getFormattedMessage(); + } + + private static boolean hasJsonFormat(MultiFormatStringBuilderFormattable message) { + for (String format : message.getFormats()) { + if (JSON2.equalsIgnoreCase(format)) { + return true; + } + } + return false; + } + + private static void formatTo(MultiFormatStringBuilderFormattable message, Appendable out) throws IOException { + if (out instanceof StringBuilder stringBuilder) { + message.formatTo(JSON, stringBuilder); + } + else { + out.append(message.getFormattedMessage(JSON)); + } + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java index 5171a11bb8..f7e6a24c32 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java @@ -20,6 +20,7 @@ import java.util.Map; import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.apache.logging.log4j.message.MapMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -78,4 +79,18 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"""); } + @Test + void shouldFormatStructuredMessage() { + MutableLogEvent event = createEvent(); + event.setMessage(new MapMessage<>().with("foo", true).with("bar", 1.0)); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + Map expectedMessage = Map.of("foo", true, "bar", 1.0); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", + "log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name", + "service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger", + "org.example.Test", "message", expectedMessage, "ecs.version", "8.11")); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java index 6e43ee38c4..595933ef92 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java @@ -25,6 +25,7 @@ import java.util.Map; import org.apache.logging.log4j.MarkerManager.Log4jMarker; import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.apache.logging.log4j.message.MapMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -77,4 +78,19 @@ class LogstashStructuredLogFormatterTests extends AbstractStructuredLoggingTests java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.LogstashStructuredLogFormatterTests.shouldFormatException"""); } + @Test + void shouldFormatStructuredMessage() { + MutableLogEvent event = createEvent(); + event.setMessage(new MapMessage<>().with("foo", true).with("bar", 1.0)); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + Map expectedMessage = Map.of("foo", true, "bar", 1.0); + String timestamp = DateTimeFormatter.ISO_OFFSET_DATE_TIME + .format(OffsetDateTime.ofInstant(EVENT_TIME, ZoneId.systemDefault())); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf( + map("@timestamp", timestamp, "@version", "1", "message", expectedMessage, "logger_name", + "org.example.Test", "thread_name", "main", "level", "INFO", "level_value", 400)); + } + }