Used nested format for ECS structure logging

Update `ElasticCommonSchemaStructuredLogFormatter` implementations so
that nested JSON is used for entries that previous has a '.' in the
name. This format follows the ECS specification and should be compatible
with more backends.

Fixes gh-45063
This commit is contained in:
Phillip Webb 2025-04-14 12:32:27 -07:00
parent baa8d1a333
commit 5b165b35e3
12 changed files with 279 additions and 91 deletions

View File

@ -504,7 +504,7 @@ A log line looks like this:
[source,json] [source,json]
---- ----
{"@timestamp":"2024-01-01T10:15:00.067462556Z","log.level":"INFO","process.pid":39599,"process.thread.name":"main","service.name":"simple","log.logger":"org.example.Application","message":"No active profile set, falling back to 1 default profile: \"default\"","ecs.version":"8.11"} {"@timestamp":"2024-01-01T10:15:00.067462556Z","log":{"level":"INFO","logger":"org.example.Application"},"process":{"pid":39599,"thread":{"name":"main"}},"service":{"name":"simple"},"message":"No active profile set, falling back to 1 default profile: \"default\"","ecs":{"version":"8.11"}}
---- ----
This format also adds every key value pair contained in the MDC to the JSON object. This format also adds every key value pair contained in the MDC to the JSON object.

View File

@ -16,6 +16,7 @@
package org.springframework.boot.logging.log4j2; package org.springframework.boot.logging.log4j2;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
@ -28,9 +29,9 @@ import org.apache.logging.log4j.core.time.Instant;
import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.apache.logging.log4j.util.ReadOnlyStringMap;
import org.springframework.boot.json.JsonWriter; import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.json.JsonWriter.Members;
import org.springframework.boot.logging.StackTracePrinter; import org.springframework.boot.logging.StackTracePrinter;
import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
import org.springframework.boot.logging.structured.ElasticCommonSchemaPairs;
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties; import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties;
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.boot.logging.structured.StructuredLogFormatter;
@ -56,36 +57,38 @@ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogF
JsonWriter.Members<LogEvent> members) { JsonWriter.Members<LogEvent> members) {
Extractor extractor = new Extractor(stackTracePrinter); Extractor extractor = new Extractor(stackTracePrinter);
members.add("@timestamp", LogEvent::getInstant).as(ElasticCommonSchemaStructuredLogFormatter::asTimestamp); members.add("@timestamp", LogEvent::getInstant).as(ElasticCommonSchemaStructuredLogFormatter::asTimestamp);
members.add("log.level", LogEvent::getLevel).as(Level::name); members.add("log").usingMembers((log) -> {
members.add("process.pid", environment.getProperty("spring.application.pid", Long.class)) log.add("level", LogEvent::getLevel).as(Level::name);
.when(Objects::nonNull); log.add("logger", LogEvent::getLoggerName);
members.add("process.thread.name", LogEvent::getThreadName); });
members.add("process").usingMembers((process) -> {
process.add("pid", environment.getProperty("spring.application.pid", Long.class)).when(Objects::nonNull);
process.add("thread").usingMembers((thread) -> thread.add("name", LogEvent::getThreadName));
});
ElasticCommonSchemaProperties.get(environment).jsonMembers(members); ElasticCommonSchemaProperties.get(environment).jsonMembers(members);
members.add("log.logger", LogEvent::getLoggerName);
members.add("message", LogEvent::getMessage).as(StructuredMessage::get); members.add("message", LogEvent::getMessage).as(StructuredMessage::get);
members.from(LogEvent::getContextData) members.from(LogEvent::getContextData)
.whenNot(ReadOnlyStringMap::isEmpty) .whenNot(ReadOnlyStringMap::isEmpty)
.usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept)); .as((contextData) -> ElasticCommonSchemaPairs.nested((nested) -> contextData.forEach(nested::accept)))
members.from(LogEvent::getThrownProxy) .usingPairs(Map::forEach);
.whenNotNull() members.from(LogEvent::getThrownProxy).whenNotNull().usingMembers((thrownProxyMembers) -> {
.usingMembers((thrownProxyMembers) -> throwableMembers(thrownProxyMembers, extractor)); thrownProxyMembers.add("error").usingMembers((error) -> {
error.add("type", ThrowableProxy::getThrowable).whenNotNull().as(ObjectUtils::nullSafeClassName);
error.add("message", ThrowableProxy::getMessage);
error.add("stack_trace", extractor::stackTrace);
});
});
members.add("tags", LogEvent::getMarker) members.add("tags", LogEvent::getMarker)
.whenNotNull() .whenNotNull()
.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers) .as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
.whenNotEmpty(); .whenNotEmpty();
members.add("ecs.version", "8.11"); members.add("ecs").usingMembers((ecs) -> ecs.add("version", "8.11"));
} }
private static java.time.Instant asTimestamp(Instant instant) { private static java.time.Instant asTimestamp(Instant instant) {
return java.time.Instant.ofEpochMilli(instant.getEpochMillisecond()).plusNanos(instant.getNanoOfMillisecond()); return java.time.Instant.ofEpochMilli(instant.getEpochMillisecond()).plusNanos(instant.getNanoOfMillisecond());
} }
private static void throwableMembers(Members<ThrowableProxy> members, Extractor extractor) {
members.add("error.type", ThrowableProxy::getThrowable).whenNotNull().as(ObjectUtils::nullSafeClassName);
members.add("error.message", ThrowableProxy::getMessage);
members.add("error.stack_trace", extractor::stackTrace);
}
private static Set<String> getMarkers(Marker marker) { private static Set<String> getMarkers(Marker marker) {
Set<String> result = new TreeSet<>(); Set<String> result = new TreeSet<>();
addMarkers(result, marker); addMarkers(result, marker);

View File

@ -18,6 +18,7 @@ package org.springframework.boot.logging.logback;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
@ -29,9 +30,9 @@ import org.slf4j.Marker;
import org.slf4j.event.KeyValuePair; import org.slf4j.event.KeyValuePair;
import org.springframework.boot.json.JsonWriter; import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.json.JsonWriter.PairExtractor;
import org.springframework.boot.logging.StackTracePrinter; import org.springframework.boot.logging.StackTracePrinter;
import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
import org.springframework.boot.logging.structured.ElasticCommonSchemaPairs;
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties; import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties;
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.boot.logging.structured.StructuredLogFormatter;
@ -47,9 +48,6 @@ import org.springframework.core.env.Environment;
*/ */
class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogFormatter<ILoggingEvent> { class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogFormatter<ILoggingEvent> {
private static final PairExtractor<KeyValuePair> keyValuePairExtractor = PairExtractor.of((pair) -> pair.key,
(pair) -> pair.value);
ElasticCommonSchemaStructuredLogFormatter(Environment environment, StackTracePrinter stackTracePrinter, ElasticCommonSchemaStructuredLogFormatter(Environment environment, StackTracePrinter stackTracePrinter,
ThrowableProxyConverter throwableProxyConverter, StructuredLoggingJsonMembersCustomizer<?> customizer) { ThrowableProxyConverter throwableProxyConverter, StructuredLoggingJsonMembersCustomizer<?> customizer) {
super((members) -> jsonMembers(environment, stackTracePrinter, throwableProxyConverter, members), customizer); super((members) -> jsonMembers(environment, stackTracePrinter, throwableProxyConverter, members), customizer);
@ -59,27 +57,41 @@ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogF
ThrowableProxyConverter throwableProxyConverter, JsonWriter.Members<ILoggingEvent> members) { ThrowableProxyConverter throwableProxyConverter, JsonWriter.Members<ILoggingEvent> members) {
Extractor extractor = new Extractor(stackTracePrinter, throwableProxyConverter); Extractor extractor = new Extractor(stackTracePrinter, throwableProxyConverter);
members.add("@timestamp", ILoggingEvent::getInstant); members.add("@timestamp", ILoggingEvent::getInstant);
members.add("log.level", ILoggingEvent::getLevel); members.add("log").usingMembers((log) -> {
members.add("process.pid", environment.getProperty("spring.application.pid", Long.class)) log.add("level", ILoggingEvent::getLevel);
.when(Objects::nonNull); log.add("logger", ILoggingEvent::getLoggerName);
members.add("process.thread.name", ILoggingEvent::getThreadName); });
members.add("process").usingMembers((process) -> {
process.add("pid", environment.getProperty("spring.application.pid", Long.class)).when(Objects::nonNull);
process.add("thread").usingMembers((thread) -> thread.add("name", ILoggingEvent::getThreadName));
});
ElasticCommonSchemaProperties.get(environment).jsonMembers(members); ElasticCommonSchemaProperties.get(environment).jsonMembers(members);
members.add("log.logger", ILoggingEvent::getLoggerName);
members.add("message", ILoggingEvent::getFormattedMessage); members.add("message", ILoggingEvent::getFormattedMessage);
members.addMapEntries(ILoggingEvent::getMDCPropertyMap); members.from(ILoggingEvent::getMDCPropertyMap)
.whenNotEmpty()
.as(ElasticCommonSchemaPairs::nested)
.usingPairs(Map::forEach);
members.from(ILoggingEvent::getKeyValuePairs) members.from(ILoggingEvent::getKeyValuePairs)
.whenNotEmpty() .whenNotEmpty()
.usingExtractedPairs(Iterable::forEach, keyValuePairExtractor); .as(ElasticCommonSchemaStructuredLogFormatter::nested)
.usingPairs(Map::forEach);
members.add().whenNotNull(ILoggingEvent::getThrowableProxy).usingMembers((throwableMembers) -> { members.add().whenNotNull(ILoggingEvent::getThrowableProxy).usingMembers((throwableMembers) -> {
throwableMembers.add("error.type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName); throwableMembers.add("error").usingMembers((error) -> {
throwableMembers.add("error.message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage); error.add("type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName);
throwableMembers.add("error.stack_trace", extractor::stackTrace); error.add("message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage);
error.add("stack_trace", extractor::stackTrace);
});
}); });
members.add("ecs.version", "8.11");
members.add("tags", ILoggingEvent::getMarkerList) members.add("tags", ILoggingEvent::getMarkerList)
.whenNotNull() .whenNotNull()
.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers) .as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
.whenNotEmpty(); .whenNotEmpty();
members.add("ecs").usingMembers((ecs) -> ecs.add("version", "8.11"));
}
private static Map<String, Object> nested(List<KeyValuePair> keyValuePairs) {
return ElasticCommonSchemaPairs.nested((nested) -> keyValuePairs
.forEach((keyValuePair) -> nested.accept(keyValuePair.key, keyValuePair.value)));
} }
private static Set<String> getMarkers(List<Marker> markers) { private static Set<String> getMarkers(List<Marker> markers) {

View File

@ -0,0 +1,63 @@
/*
* Copyright 2012-2025 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.structured;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.springframework.util.Assert;
/**
* Utility to help with writing ElasticCommonSchema pairs in their nested form.
*
* @author Phillip Webb
* @since 3.5.0
*/
public final class ElasticCommonSchemaPairs {
private ElasticCommonSchemaPairs() {
}
public static Map<String, Object> nested(Map<String, String> map) {
return nested(map::forEach);
}
@SuppressWarnings("unchecked")
public static <K, V> Map<String, Object> nested(Consumer<BiConsumer<K, V>> nested) {
Map<String, Object> result = new LinkedHashMap<>();
nested.accept((name, value) -> {
List<String> nameParts = List.of(name.toString().split("\\."));
Map<String, Object> destination = result;
for (int i = 0; i < nameParts.size() - 1; i++) {
Object existing = destination.computeIfAbsent(nameParts.get(i), (key) -> new LinkedHashMap<>());
if (!(existing instanceof Map)) {
String common = nameParts.subList(0, i + 1).stream().collect(Collectors.joining("."));
throw new IllegalStateException("Duplicate ECS pairs added under '%s'".formatted(common));
}
destination = (Map<String, Object>) existing;
}
Object previous = destination.put(nameParts.get(nameParts.size() - 1), value);
Assert.state(previous == null, () -> "Duplicate ECS pairs added under '%s'".formatted(name));
});
return result;
}
}

View File

@ -80,10 +80,12 @@ public record ElasticCommonSchemaProperties(Service service) {
static final Service NONE = new Service(null, null, null, null); static final Service NONE = new Service(null, null, null, null);
void jsonMembers(Members<?> members) { void jsonMembers(Members<?> members) {
members.add("service.name", this::name).whenHasLength(); members.add("service").usingMembers((service) -> {
members.add("service.version", this::version).whenHasLength(); service.add("name", this::name).whenHasLength();
members.add("service.environment", this::environment).whenHasLength(); service.add("version", this::version).whenHasLength();
members.add("service.node.name", this::nodeName).whenHasLength(); service.add("environment", this::environment).whenHasLength();
service.add("node").usingMembers((node) -> node.add("name", this::nodeName).whenHasLength());
});
} }
Service withDefaults(Environment environment) { Service withDefaults(Environment environment) {

View File

@ -16,6 +16,7 @@
package org.springframework.boot.logging.log4j2; package org.springframework.boot.logging.log4j2;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -68,38 +69,51 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
String json = this.formatter.format(event); String json = this.formatter.format(event);
assertThat(json).endsWith("\n"); assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", Map<String, Object> expected = new HashMap<>();
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name", expected.put("@timestamp", "2024-07-02T08:49:53Z");
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger", expected.put("message", "message");
"org.example.Test", "message", "message", "mdc-1", "mdc-v-1", "ecs.version", "8.11")); expected.put("mdc-1", "mdc-v-1");
expected.put("ecs", Map.of("version", "8.11"));
expected.put("process", map("pid", 1, "thread", map("name", "main")));
expected.put("log", map("level", "INFO", "logger", "org.example.Test"));
expected.put("service",
map("name", "name", "version", "1.0.0", "environment", "test", "node", map("name", "node-1")));
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(expected);
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldFormatException() { void shouldFormatException() {
MutableLogEvent event = createEvent(); MutableLogEvent event = createEvent();
event.setThrown(new RuntimeException("Boom")); event.setThrown(new RuntimeException("Boom"));
String json = this.formatter.format(event); String json = this.formatter.format(event);
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized) Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
.containsAllEntriesOf(map("error.type", "java.lang.RuntimeException", "error.message", "Boom")); Map<String, Object> expectedError = new HashMap<>();
String stackTrace = (String) deserialized.get("error.stack_trace"); expectedError.put("type", "java.lang.RuntimeException");
assertThat(stackTrace).startsWith( expectedError.put("message", "Boom");
""" assertThat(error).containsAllEntriesOf(expectedError);
java.lang.RuntimeException: Boom String stackTrace = (String) error.get("stack_trace");
\tat org.springframework.boot.logging.log4j2.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"""); assertThat(stackTrace)
assertThat(json).contains( .startsWith(String.format("java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.log4j2."
""" + "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"));
java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"""); assertThat(json).contains(String
.format("java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.log4j2."
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException")
.replace("\n", "\\n")
.replace("\r", "\\r"));
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldFormatExceptionUsingStackTracePrinter() { void shouldFormatExceptionUsingStackTracePrinter() {
this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(), this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(),
this.customizer); this.customizer);
MutableLogEvent event = createEvent(); MutableLogEvent event = createEvent();
event.setThrown(new RuntimeException("Boom")); event.setThrown(new RuntimeException("Boom"));
Map<String, Object> deserialized = deserialize(this.formatter.format(event)); Map<String, Object> deserialized = deserialize(this.formatter.format(event));
String stackTrace = (String) deserialized.get("error.stack_trace"); Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
String stackTrace = (String) error.get("stack_trace");
assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException"); assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException");
} }
@ -111,10 +125,7 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
assertThat(json).endsWith("\n"); assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
Map<String, Object> expectedMessage = Map.of("foo", true, "bar", 1.0); Map<String, Object> expectedMessage = Map.of("foo", true, "bar", 1.0);
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", assertThat(deserialized.get("message")).isEqualTo(expectedMessage);
"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"));
} }
@Test @Test
@ -131,11 +142,8 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
String json = this.formatter.format(event); String json = this.formatter.format(event);
assertThat(json).endsWith("\n"); assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", assertThat(deserialized.get("tags"))
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name", .isEqualTo(List.of("grandchild", "grandparent", "grandparent1", "parent", "parent1"));
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger",
"org.example.Test", "message", "message", "ecs.version", "8.11", "tags",
List.of("grandchild", "grandparent", "grandparent1", "parent", "parent1")));
} }
} }

View File

@ -62,12 +62,14 @@ class StructuredLogLayoutTests extends AbstractStructuredLoggingTests {
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldSupportEcsCommonFormat() { void shouldSupportEcsCommonFormat() {
StructuredLogLayout layout = newBuilder().setFormat("ecs").build(); StructuredLogLayout layout = newBuilder().setFormat("ecs").build();
String json = layout.toSerializable(createEvent(new RuntimeException("Boom!"))); String json = layout.toSerializable(createEvent(new RuntimeException("Boom!")));
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsKey("ecs.version"); assertThat(deserialized).containsEntry("ecs", Map.of("version", "8.11"));
assertThat(deserialized.get("error.stack_trace")).isEqualTo("stacktrace:RuntimeException"); Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException");
} }
@Test @Test

View File

@ -17,6 +17,7 @@
package org.springframework.boot.logging.logback; package org.springframework.boot.logging.logback;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -72,33 +73,45 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
String json = this.formatter.format(event); String json = this.formatter.format(event);
assertThat(json).endsWith("\n"); assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", Map<String, Object> expected = new HashMap<>();
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name", expected.put("@timestamp", "2024-07-02T08:49:53Z");
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger", expected.put("message", "message");
"org.example.Test", "message", "message", "mdc-1", "mdc-v-1", "kv-1", "kv-v-1", "ecs.version", "8.11")); expected.put("mdc-1", "mdc-v-1");
expected.put("kv-1", "kv-v-1");
expected.put("ecs", Map.of("version", "8.11"));
expected.put("process", map("pid", 1, "thread", map("name", "main")));
expected.put("log", map("level", "INFO", "logger", "org.example.Test"));
expected.put("service",
map("name", "name", "version", "1.0.0", "environment", "test", "node", map("name", "node-1")));
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(expected);
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldFormatException() { void shouldFormatException() {
LoggingEvent event = createEvent(); LoggingEvent event = createEvent();
event.setMDCPropertyMap(Collections.emptyMap()); event.setMDCPropertyMap(Collections.emptyMap());
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom"))); event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
String json = this.formatter.format(event); String json = this.formatter.format(event);
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized) Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
.containsAllEntriesOf(map("error.type", "java.lang.RuntimeException", "error.message", "Boom")); Map<String, Object> expectedError = new HashMap<>();
String stackTrace = (String) deserialized.get("error.stack_trace"); expectedError.put("type", "java.lang.RuntimeException");
assertThat(stackTrace).startsWith( expectedError.put("message", "Boom");
"java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException" assertThat(error).containsAllEntriesOf(expectedError);
.formatted()); String stackTrace = (String) error.get("stack_trace");
assertThat(json).contains( assertThat(stackTrace)
"java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException" .startsWith(String.format("java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback."
.formatted() + "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"));
assertThat(json).contains(String
.format("java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback."
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException")
.replace("\n", "\\n") .replace("\n", "\\n")
.replace("\r", "\\r")); .replace("\r", "\\r"));
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldFormatExceptionUsingStackTracePrinter() { void shouldFormatExceptionUsingStackTracePrinter() {
this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(), this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(),
getThrowableProxyConverter(), this.customizer); getThrowableProxyConverter(), this.customizer);
@ -106,7 +119,8 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
event.setMDCPropertyMap(Collections.emptyMap()); event.setMDCPropertyMap(Collections.emptyMap());
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom"))); event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
Map<String, Object> deserialized = deserialize(this.formatter.format(event)); Map<String, Object> deserialized = deserialize(this.formatter.format(event));
String stackTrace = (String) deserialized.get("error.stack_trace"); Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
String stackTrace = (String) error.get("stack_trace");
assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException"); assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException");
} }
@ -123,13 +137,19 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
grandparent.add(parent1); grandparent.add(parent1);
event.addMarker(grandparent); event.addMarker(grandparent);
String json = this.formatter.format(event); String json = this.formatter.format(event);
assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf( assertThat(deserialized.get("tags")).isEqualTo(List.of("child", "child1", "grandparent", "parent", "parent1"));
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", "message", @Test
"ecs.version", "8.11", "tags", List.of("child", "child1", "grandparent", "parent", "parent1"))); void shouldNestMdcAndKeyValuePairs() {
LoggingEvent event = createEvent();
event.setMDCPropertyMap(Map.of("a1.b1.c1", "A1B1C1", "a1.b1.c2", "A1B1C2"));
event.setKeyValuePairs(keyValuePairs("a2.b1.c1", "A2B1C1", "a2.b1.c2", "A2B1C2"));
String json = this.formatter.format(event);
Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized.get("a1")).isEqualTo(Map.of("b1", Map.of("c1", "A1B1C1", "c2", "A1B1C2")));
assertThat(deserialized.get("a2")).isEqualTo(Map.of("b1", Map.of("c1", "A2B1C1", "c2", "A2B1C2")));
} }
} }

View File

@ -72,6 +72,7 @@ class StructuredLogEncoderTests extends AbstractStructuredLoggingTests {
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldSupportEcsCommonFormat() { void shouldSupportEcsCommonFormat() {
this.encoder.setFormat("ecs"); this.encoder.setFormat("ecs");
this.encoder.start(); this.encoder.start();
@ -79,8 +80,9 @@ class StructuredLogEncoderTests extends AbstractStructuredLoggingTests {
event.setMDCPropertyMap(Collections.emptyMap()); event.setMDCPropertyMap(Collections.emptyMap());
String json = encode(event); String json = encode(event);
Map<String, Object> deserialized = deserialize(json); Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsKey("ecs.version"); assertThat(deserialized).containsEntry("ecs", Map.of("version", "8.11"));
assertThat(deserialized.get("error.stack_trace")).isEqualTo("stacktrace:RuntimeException"); Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException");
} }
@Test @Test

View File

@ -0,0 +1,68 @@
/*
* Copyright 2012-2025 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.structured;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Tests for {@link ElasticCommonSchemaPairs}.
*
* @author Phillip Webb
*/
class ElasticCommonSchemaPairsTests {
@Test
void nestedExpandsNames() {
Map<String, String> map = Map.of("a1.b1.c1", "A1B1C1", "a1.b2.c1", "A1B2C1", "a1.b1.c2", "A1B1C2");
Map<String, Object> expected = new LinkedHashMap<>();
Map<String, Object> a1 = new LinkedHashMap<>();
Map<String, Object> b1 = new LinkedHashMap<>();
Map<String, Object> b2 = new LinkedHashMap<>();
expected.put("a1", a1);
a1.put("b1", b1);
a1.put("b2", b2);
b1.put("c1", "A1B1C1");
b1.put("c2", "A1B1C2");
b2.put("c1", "A1B2C1");
assertThat(ElasticCommonSchemaPairs.nested(map)).isEqualTo(expected);
}
@Test
void nestedWhenDuplicateInParentThrowsException() {
Map<String, String> map = new LinkedHashMap<>();
map.put("a1.b1.c1", "A1B1C1");
map.put("a1.b1", "A1B1");
assertThatIllegalStateException().isThrownBy(() -> ElasticCommonSchemaPairs.nested(map))
.withMessage("Duplicate ECS pairs added under 'a1.b1'");
}
@Test
void nestedWhenDuplicateInLeafThrowsException() {
Map<String, String> map = new LinkedHashMap<>();
map.put("a1.b1", "A1B1");
map.put("a1.b1.c1", "A1B1C1");
assertThatIllegalStateException().isThrownBy(() -> ElasticCommonSchemaPairs.nested(map))
.withMessage("Duplicate ECS pairs added under 'a1.b1'");
}
}

View File

@ -77,9 +77,17 @@ class ElasticCommonSchemaPropertiesTests {
ElasticCommonSchemaProperties properties = new ElasticCommonSchemaProperties( ElasticCommonSchemaProperties properties = new ElasticCommonSchemaProperties(
new Service("spring", "1.2.3", "prod", "boot")); new Service("spring", "1.2.3", "prod", "boot"));
JsonWriter<ElasticCommonSchemaProperties> writer = JsonWriter.of(properties::jsonMembers); JsonWriter<ElasticCommonSchemaProperties> writer = JsonWriter.of(properties::jsonMembers);
assertThat(writer.writeToString(properties)) assertThat(writer.writeToString(properties)).isEqualToNormalizingNewlines("""
.isEqualTo("{\"service.name\":\"spring\",\"service.version\":\"1.2.3\"," {
+ "\"service.environment\":\"prod\",\"service.node.name\":\"boot\"}"); "service": {
"name": "spring",
"version": "1.2.3",
"environment": "prod",
"node": {
"name": "boot"
}
}
}""".replaceAll("\\s+", ""));
} }
@Test @Test

View File

@ -54,7 +54,7 @@ class SampleStructuredLoggingApplicationTests {
void json(CapturedOutput output) { void json(CapturedOutput output) {
SampleStructuredLoggingApplication.main(new String[0]); SampleStructuredLoggingApplication.main(new String[0]);
assertThat(output).doesNotContain("{\"@timestamp\"") assertThat(output).doesNotContain("{\"@timestamp\"")
.contains("\"process.thread.name\":\"!!") .contains("\"thread\":{\"name\":\"!!")
.contains("\"process.procid\"") .contains("\"process.procid\"")
.contains("\"message\":\"Starting SampleStructuredLoggingApplication") .contains("\"message\":\"Starting SampleStructuredLoggingApplication")
.contains("\"foo\":\"hello"); .contains("\"foo\":\"hello");