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]
----
{"@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.

View File

@ -16,6 +16,7 @@
package org.springframework.boot.logging.log4j2;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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.springframework.boot.json.JsonWriter;
import org.springframework.boot.json.JsonWriter.Members;
import org.springframework.boot.logging.StackTracePrinter;
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.JsonWriterStructuredLogFormatter;
import org.springframework.boot.logging.structured.StructuredLogFormatter;
@ -56,36 +57,38 @@ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogF
JsonWriter.Members<LogEvent> members) {
Extractor extractor = new Extractor(stackTracePrinter);
members.add("@timestamp", LogEvent::getInstant).as(ElasticCommonSchemaStructuredLogFormatter::asTimestamp);
members.add("log.level", LogEvent::getLevel).as(Level::name);
members.add("process.pid", environment.getProperty("spring.application.pid", Long.class))
.when(Objects::nonNull);
members.add("process.thread.name", LogEvent::getThreadName);
members.add("log").usingMembers((log) -> {
log.add("level", LogEvent::getLevel).as(Level::name);
log.add("logger", LogEvent::getLoggerName);
});
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);
members.add("log.logger", LogEvent::getLoggerName);
members.add("message", LogEvent::getMessage).as(StructuredMessage::get);
members.from(LogEvent::getContextData)
.whenNot(ReadOnlyStringMap::isEmpty)
.usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept));
members.from(LogEvent::getThrownProxy)
.whenNotNull()
.usingMembers((thrownProxyMembers) -> throwableMembers(thrownProxyMembers, extractor));
.as((contextData) -> ElasticCommonSchemaPairs.nested((nested) -> contextData.forEach(nested::accept)))
.usingPairs(Map::forEach);
members.from(LogEvent::getThrownProxy).whenNotNull().usingMembers((thrownProxyMembers) -> {
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)
.whenNotNull()
.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
.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) {
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) {
Set<String> result = new TreeSet<>();
addMarkers(result, marker);

View File

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

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);
void jsonMembers(Members<?> members) {
members.add("service.name", this::name).whenHasLength();
members.add("service.version", this::version).whenHasLength();
members.add("service.environment", this::environment).whenHasLength();
members.add("service.node.name", this::nodeName).whenHasLength();
members.add("service").usingMembers((service) -> {
service.add("name", this::name).whenHasLength();
service.add("version", this::version).whenHasLength();
service.add("environment", this::environment).whenHasLength();
service.add("node").usingMembers((node) -> node.add("name", this::nodeName).whenHasLength());
});
}
Service withDefaults(Environment environment) {

View File

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

View File

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

View File

@ -17,6 +17,7 @@
package org.springframework.boot.logging.logback;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -72,33 +73,45 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
String json = this.formatter.format(event);
assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json);
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", "message", "mdc-1", "mdc-v-1", "kv-1", "kv-v-1", "ecs.version", "8.11"));
Map<String, Object> expected = new HashMap<>();
expected.put("@timestamp", "2024-07-02T08:49:53Z");
expected.put("message", "message");
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
@SuppressWarnings("unchecked")
void shouldFormatException() {
LoggingEvent event = createEvent();
event.setMDCPropertyMap(Collections.emptyMap());
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
String json = this.formatter.format(event);
Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized)
.containsAllEntriesOf(map("error.type", "java.lang.RuntimeException", "error.message", "Boom"));
String stackTrace = (String) deserialized.get("error.stack_trace");
assertThat(stackTrace).startsWith(
"java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"
.formatted());
assertThat(json).contains(
"java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"
.formatted()
.replace("\n", "\\n")
.replace("\r", "\\r"));
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
Map<String, Object> expectedError = new HashMap<>();
expectedError.put("type", "java.lang.RuntimeException");
expectedError.put("message", "Boom");
assertThat(error).containsAllEntriesOf(expectedError);
String stackTrace = (String) error.get("stack_trace");
assertThat(stackTrace)
.startsWith(String.format("java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback."
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"));
assertThat(json).contains(String
.format("java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback."
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException")
.replace("\n", "\\n")
.replace("\r", "\\r"));
}
@Test
@SuppressWarnings("unchecked")
void shouldFormatExceptionUsingStackTracePrinter() {
this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(),
getThrowableProxyConverter(), this.customizer);
@ -106,7 +119,8 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
event.setMDCPropertyMap(Collections.emptyMap());
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
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");
}
@ -123,13 +137,19 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL
grandparent.add(parent1);
event.addMarker(grandparent);
String json = this.formatter.format(event);
assertThat(json).endsWith("\n");
Map<String, Object> deserialized = deserialize(json);
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", "message",
"ecs.version", "8.11", "tags", List.of("child", "child1", "grandparent", "parent", "parent1")));
assertThat(deserialized.get("tags")).isEqualTo(List.of("child", "child1", "grandparent", "parent", "parent1"));
}
@Test
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
@SuppressWarnings("unchecked")
void shouldSupportEcsCommonFormat() {
this.encoder.setFormat("ecs");
this.encoder.start();
@ -79,8 +80,9 @@ class StructuredLogEncoderTests extends AbstractStructuredLoggingTests {
event.setMDCPropertyMap(Collections.emptyMap());
String json = encode(event);
Map<String, Object> deserialized = deserialize(json);
assertThat(deserialized).containsKey("ecs.version");
assertThat(deserialized.get("error.stack_trace")).isEqualTo("stacktrace:RuntimeException");
assertThat(deserialized).containsEntry("ecs", Map.of("version", "8.11"));
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException");
}
@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(
new Service("spring", "1.2.3", "prod", "boot"));
JsonWriter<ElasticCommonSchemaProperties> writer = JsonWriter.of(properties::jsonMembers);
assertThat(writer.writeToString(properties))
.isEqualTo("{\"service.name\":\"spring\",\"service.version\":\"1.2.3\","
+ "\"service.environment\":\"prod\",\"service.node.name\":\"boot\"}");
assertThat(writer.writeToString(properties)).isEqualToNormalizingNewlines("""
{
"service": {
"name": "spring",
"version": "1.2.3",
"environment": "prod",
"node": {
"name": "boot"
}
}
}""".replaceAll("\\s+", ""));
}
@Test

View File

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