Skip to content

Commit 5b165b3

Browse files
committedApr 14, 2025·
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
1 parent baa8d1a commit 5b165b3

File tree

12 files changed

+279
-91
lines changed

12 files changed

+279
-91
lines changed
 

‎spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc

+1-1
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ A log line looks like this:
504504

505505
[source,json]
506506
----
507-
{"@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"}
507+
{"@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"}}
508508
----
509509

510510
This format also adds every key value pair contained in the MDC to the JSON object.

‎spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java

+20-17
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.logging.log4j2;
1818

19+
import java.util.Map;
1920
import java.util.Objects;
2021
import java.util.Set;
2122
import java.util.TreeSet;
@@ -28,9 +29,9 @@
2829
import org.apache.logging.log4j.util.ReadOnlyStringMap;
2930

3031
import org.springframework.boot.json.JsonWriter;
31-
import org.springframework.boot.json.JsonWriter.Members;
3232
import org.springframework.boot.logging.StackTracePrinter;
3333
import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
34+
import org.springframework.boot.logging.structured.ElasticCommonSchemaPairs;
3435
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties;
3536
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
3637
import org.springframework.boot.logging.structured.StructuredLogFormatter;
@@ -56,36 +57,38 @@ private static void jsonMembers(Environment environment, StackTracePrinter stack
5657
JsonWriter.Members<LogEvent> members) {
5758
Extractor extractor = new Extractor(stackTracePrinter);
5859
members.add("@timestamp", LogEvent::getInstant).as(ElasticCommonSchemaStructuredLogFormatter::asTimestamp);
59-
members.add("log.level", LogEvent::getLevel).as(Level::name);
60-
members.add("process.pid", environment.getProperty("spring.application.pid", Long.class))
61-
.when(Objects::nonNull);
62-
members.add("process.thread.name", LogEvent::getThreadName);
60+
members.add("log").usingMembers((log) -> {
61+
log.add("level", LogEvent::getLevel).as(Level::name);
62+
log.add("logger", LogEvent::getLoggerName);
63+
});
64+
members.add("process").usingMembers((process) -> {
65+
process.add("pid", environment.getProperty("spring.application.pid", Long.class)).when(Objects::nonNull);
66+
process.add("thread").usingMembers((thread) -> thread.add("name", LogEvent::getThreadName));
67+
});
6368
ElasticCommonSchemaProperties.get(environment).jsonMembers(members);
64-
members.add("log.logger", LogEvent::getLoggerName);
6569
members.add("message", LogEvent::getMessage).as(StructuredMessage::get);
6670
members.from(LogEvent::getContextData)
6771
.whenNot(ReadOnlyStringMap::isEmpty)
68-
.usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept));
69-
members.from(LogEvent::getThrownProxy)
70-
.whenNotNull()
71-
.usingMembers((thrownProxyMembers) -> throwableMembers(thrownProxyMembers, extractor));
72+
.as((contextData) -> ElasticCommonSchemaPairs.nested((nested) -> contextData.forEach(nested::accept)))
73+
.usingPairs(Map::forEach);
74+
members.from(LogEvent::getThrownProxy).whenNotNull().usingMembers((thrownProxyMembers) -> {
75+
thrownProxyMembers.add("error").usingMembers((error) -> {
76+
error.add("type", ThrowableProxy::getThrowable).whenNotNull().as(ObjectUtils::nullSafeClassName);
77+
error.add("message", ThrowableProxy::getMessage);
78+
error.add("stack_trace", extractor::stackTrace);
79+
});
80+
});
7281
members.add("tags", LogEvent::getMarker)
7382
.whenNotNull()
7483
.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
7584
.whenNotEmpty();
76-
members.add("ecs.version", "8.11");
85+
members.add("ecs").usingMembers((ecs) -> ecs.add("version", "8.11"));
7786
}
7887

7988
private static java.time.Instant asTimestamp(Instant instant) {
8089
return java.time.Instant.ofEpochMilli(instant.getEpochMillisecond()).plusNanos(instant.getNanoOfMillisecond());
8190
}
8291

83-
private static void throwableMembers(Members<ThrowableProxy> members, Extractor extractor) {
84-
members.add("error.type", ThrowableProxy::getThrowable).whenNotNull().as(ObjectUtils::nullSafeClassName);
85-
members.add("error.message", ThrowableProxy::getMessage);
86-
members.add("error.stack_trace", extractor::stackTrace);
87-
}
88-
8992
private static Set<String> getMarkers(Marker marker) {
9093
Set<String> result = new TreeSet<>();
9194
addMarkers(result, marker);

‎spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java

+27-15
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.Iterator;
2020
import java.util.List;
21+
import java.util.Map;
2122
import java.util.Objects;
2223
import java.util.Set;
2324
import java.util.TreeSet;
@@ -29,9 +30,9 @@
2930
import org.slf4j.event.KeyValuePair;
3031

3132
import org.springframework.boot.json.JsonWriter;
32-
import org.springframework.boot.json.JsonWriter.PairExtractor;
3333
import org.springframework.boot.logging.StackTracePrinter;
3434
import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
35+
import org.springframework.boot.logging.structured.ElasticCommonSchemaPairs;
3536
import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties;
3637
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
3738
import org.springframework.boot.logging.structured.StructuredLogFormatter;
@@ -47,9 +48,6 @@
4748
*/
4849
class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogFormatter<ILoggingEvent> {
4950

50-
private static final PairExtractor<KeyValuePair> keyValuePairExtractor = PairExtractor.of((pair) -> pair.key,
51-
(pair) -> pair.value);
52-
5351
ElasticCommonSchemaStructuredLogFormatter(Environment environment, StackTracePrinter stackTracePrinter,
5452
ThrowableProxyConverter throwableProxyConverter, StructuredLoggingJsonMembersCustomizer<?> customizer) {
5553
super((members) -> jsonMembers(environment, stackTracePrinter, throwableProxyConverter, members), customizer);
@@ -59,27 +57,41 @@ private static void jsonMembers(Environment environment, StackTracePrinter stack
5957
ThrowableProxyConverter throwableProxyConverter, JsonWriter.Members<ILoggingEvent> members) {
6058
Extractor extractor = new Extractor(stackTracePrinter, throwableProxyConverter);
6159
members.add("@timestamp", ILoggingEvent::getInstant);
62-
members.add("log.level", ILoggingEvent::getLevel);
63-
members.add("process.pid", environment.getProperty("spring.application.pid", Long.class))
64-
.when(Objects::nonNull);
65-
members.add("process.thread.name", ILoggingEvent::getThreadName);
60+
members.add("log").usingMembers((log) -> {
61+
log.add("level", ILoggingEvent::getLevel);
62+
log.add("logger", ILoggingEvent::getLoggerName);
63+
});
64+
members.add("process").usingMembers((process) -> {
65+
process.add("pid", environment.getProperty("spring.application.pid", Long.class)).when(Objects::nonNull);
66+
process.add("thread").usingMembers((thread) -> thread.add("name", ILoggingEvent::getThreadName));
67+
});
6668
ElasticCommonSchemaProperties.get(environment).jsonMembers(members);
67-
members.add("log.logger", ILoggingEvent::getLoggerName);
6869
members.add("message", ILoggingEvent::getFormattedMessage);
69-
members.addMapEntries(ILoggingEvent::getMDCPropertyMap);
70+
members.from(ILoggingEvent::getMDCPropertyMap)
71+
.whenNotEmpty()
72+
.as(ElasticCommonSchemaPairs::nested)
73+
.usingPairs(Map::forEach);
7074
members.from(ILoggingEvent::getKeyValuePairs)
7175
.whenNotEmpty()
72-
.usingExtractedPairs(Iterable::forEach, keyValuePairExtractor);
76+
.as(ElasticCommonSchemaStructuredLogFormatter::nested)
77+
.usingPairs(Map::forEach);
7378
members.add().whenNotNull(ILoggingEvent::getThrowableProxy).usingMembers((throwableMembers) -> {
74-
throwableMembers.add("error.type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName);
75-
throwableMembers.add("error.message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage);
76-
throwableMembers.add("error.stack_trace", extractor::stackTrace);
79+
throwableMembers.add("error").usingMembers((error) -> {
80+
error.add("type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName);
81+
error.add("message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage);
82+
error.add("stack_trace", extractor::stackTrace);
83+
});
7784
});
78-
members.add("ecs.version", "8.11");
7985
members.add("tags", ILoggingEvent::getMarkerList)
8086
.whenNotNull()
8187
.as(ElasticCommonSchemaStructuredLogFormatter::getMarkers)
8288
.whenNotEmpty();
89+
members.add("ecs").usingMembers((ecs) -> ecs.add("version", "8.11"));
90+
}
91+
92+
private static Map<String, Object> nested(List<KeyValuePair> keyValuePairs) {
93+
return ElasticCommonSchemaPairs.nested((nested) -> keyValuePairs
94+
.forEach((keyValuePair) -> nested.accept(keyValuePair.key, keyValuePair.value)));
8395
}
8496

8597
private static Set<String> getMarkers(List<Marker> markers) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.logging.structured;
18+
19+
import java.util.LinkedHashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.function.BiConsumer;
23+
import java.util.function.Consumer;
24+
import java.util.stream.Collectors;
25+
26+
import org.springframework.util.Assert;
27+
28+
/**
29+
* Utility to help with writing ElasticCommonSchema pairs in their nested form.
30+
*
31+
* @author Phillip Webb
32+
* @since 3.5.0
33+
*/
34+
public final class ElasticCommonSchemaPairs {
35+
36+
private ElasticCommonSchemaPairs() {
37+
}
38+
39+
public static Map<String, Object> nested(Map<String, String> map) {
40+
return nested(map::forEach);
41+
}
42+
43+
@SuppressWarnings("unchecked")
44+
public static <K, V> Map<String, Object> nested(Consumer<BiConsumer<K, V>> nested) {
45+
Map<String, Object> result = new LinkedHashMap<>();
46+
nested.accept((name, value) -> {
47+
List<String> nameParts = List.of(name.toString().split("\\."));
48+
Map<String, Object> destination = result;
49+
for (int i = 0; i < nameParts.size() - 1; i++) {
50+
Object existing = destination.computeIfAbsent(nameParts.get(i), (key) -> new LinkedHashMap<>());
51+
if (!(existing instanceof Map)) {
52+
String common = nameParts.subList(0, i + 1).stream().collect(Collectors.joining("."));
53+
throw new IllegalStateException("Duplicate ECS pairs added under '%s'".formatted(common));
54+
}
55+
destination = (Map<String, Object>) existing;
56+
}
57+
Object previous = destination.put(nameParts.get(nameParts.size() - 1), value);
58+
Assert.state(previous == null, () -> "Duplicate ECS pairs added under '%s'".formatted(name));
59+
});
60+
return result;
61+
}
62+
63+
}

‎spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaProperties.java

+6-4
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@ public record Service(String name, String version, String environment, String no
8080
static final Service NONE = new Service(null, null, null, null);
8181

8282
void jsonMembers(Members<?> members) {
83-
members.add("service.name", this::name).whenHasLength();
84-
members.add("service.version", this::version).whenHasLength();
85-
members.add("service.environment", this::environment).whenHasLength();
86-
members.add("service.node.name", this::nodeName).whenHasLength();
83+
members.add("service").usingMembers((service) -> {
84+
service.add("name", this::name).whenHasLength();
85+
service.add("version", this::version).whenHasLength();
86+
service.add("environment", this::environment).whenHasLength();
87+
service.add("node").usingMembers((node) -> node.add("name", this::nodeName).whenHasLength());
88+
});
8789
}
8890

8991
Service withDefaults(Environment environment) {

‎spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java

+32-24
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.logging.log4j2;
1818

19+
import java.util.HashMap;
1920
import java.util.List;
2021
import java.util.Map;
2122

@@ -68,38 +69,51 @@ void shouldFormat() {
6869
String json = this.formatter.format(event);
6970
assertThat(json).endsWith("\n");
7071
Map<String, Object> deserialized = deserialize(json);
71-
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z",
72-
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name",
73-
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger",
74-
"org.example.Test", "message", "message", "mdc-1", "mdc-v-1", "ecs.version", "8.11"));
72+
Map<String, Object> expected = new HashMap<>();
73+
expected.put("@timestamp", "2024-07-02T08:49:53Z");
74+
expected.put("message", "message");
75+
expected.put("mdc-1", "mdc-v-1");
76+
expected.put("ecs", Map.of("version", "8.11"));
77+
expected.put("process", map("pid", 1, "thread", map("name", "main")));
78+
expected.put("log", map("level", "INFO", "logger", "org.example.Test"));
79+
expected.put("service",
80+
map("name", "name", "version", "1.0.0", "environment", "test", "node", map("name", "node-1")));
81+
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(expected);
7582
}
7683

7784
@Test
85+
@SuppressWarnings("unchecked")
7886
void shouldFormatException() {
7987
MutableLogEvent event = createEvent();
8088
event.setThrown(new RuntimeException("Boom"));
8189
String json = this.formatter.format(event);
8290
Map<String, Object> deserialized = deserialize(json);
83-
assertThat(deserialized)
84-
.containsAllEntriesOf(map("error.type", "java.lang.RuntimeException", "error.message", "Boom"));
85-
String stackTrace = (String) deserialized.get("error.stack_trace");
86-
assertThat(stackTrace).startsWith(
87-
"""
88-
java.lang.RuntimeException: Boom
89-
\tat org.springframework.boot.logging.log4j2.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException""");
90-
assertThat(json).contains(
91-
"""
92-
java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException""");
91+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
92+
Map<String, Object> expectedError = new HashMap<>();
93+
expectedError.put("type", "java.lang.RuntimeException");
94+
expectedError.put("message", "Boom");
95+
assertThat(error).containsAllEntriesOf(expectedError);
96+
String stackTrace = (String) error.get("stack_trace");
97+
assertThat(stackTrace)
98+
.startsWith(String.format("java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.log4j2."
99+
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"));
100+
assertThat(json).contains(String
101+
.format("java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.log4j2."
102+
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException")
103+
.replace("\n", "\\n")
104+
.replace("\r", "\\r"));
93105
}
94106

95107
@Test
108+
@SuppressWarnings("unchecked")
96109
void shouldFormatExceptionUsingStackTracePrinter() {
97110
this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(),
98111
this.customizer);
99112
MutableLogEvent event = createEvent();
100113
event.setThrown(new RuntimeException("Boom"));
101114
Map<String, Object> deserialized = deserialize(this.formatter.format(event));
102-
String stackTrace = (String) deserialized.get("error.stack_trace");
115+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
116+
String stackTrace = (String) error.get("stack_trace");
103117
assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException");
104118
}
105119

@@ -111,10 +125,7 @@ void shouldFormatStructuredMessage() {
111125
assertThat(json).endsWith("\n");
112126
Map<String, Object> deserialized = deserialize(json);
113127
Map<String, Object> expectedMessage = Map.of("foo", true, "bar", 1.0);
114-
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z",
115-
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name",
116-
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger",
117-
"org.example.Test", "message", expectedMessage, "ecs.version", "8.11"));
128+
assertThat(deserialized.get("message")).isEqualTo(expectedMessage);
118129
}
119130

120131
@Test
@@ -131,11 +142,8 @@ void shouldFormatMarkersAsTags() {
131142
String json = this.formatter.format(event);
132143
assertThat(json).endsWith("\n");
133144
Map<String, Object> deserialized = deserialize(json);
134-
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z",
135-
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name",
136-
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger",
137-
"org.example.Test", "message", "message", "ecs.version", "8.11", "tags",
138-
List.of("grandchild", "grandparent", "grandparent1", "parent", "parent1")));
145+
assertThat(deserialized.get("tags"))
146+
.isEqualTo(List.of("grandchild", "grandparent", "grandparent1", "parent", "parent1"));
139147
}
140148

141149
}

‎spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLogLayoutTests.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ void cleanup() {
6262
}
6363

6464
@Test
65+
@SuppressWarnings("unchecked")
6566
void shouldSupportEcsCommonFormat() {
6667
StructuredLogLayout layout = newBuilder().setFormat("ecs").build();
6768
String json = layout.toSerializable(createEvent(new RuntimeException("Boom!")));
6869
Map<String, Object> deserialized = deserialize(json);
69-
assertThat(deserialized).containsKey("ecs.version");
70-
assertThat(deserialized.get("error.stack_trace")).isEqualTo("stacktrace:RuntimeException");
70+
assertThat(deserialized).containsEntry("ecs", Map.of("version", "8.11"));
71+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
72+
assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException");
7173
}
7274

7375
@Test

‎spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java

+42-22
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.boot.logging.logback;
1818

1919
import java.util.Collections;
20+
import java.util.HashMap;
2021
import java.util.List;
2122
import java.util.Map;
2223

@@ -72,41 +73,54 @@ void shouldFormat() {
7273
String json = this.formatter.format(event);
7374
assertThat(json).endsWith("\n");
7475
Map<String, Object> deserialized = deserialize(json);
75-
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z",
76-
"log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name",
77-
"service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger",
78-
"org.example.Test", "message", "message", "mdc-1", "mdc-v-1", "kv-1", "kv-v-1", "ecs.version", "8.11"));
76+
Map<String, Object> expected = new HashMap<>();
77+
expected.put("@timestamp", "2024-07-02T08:49:53Z");
78+
expected.put("message", "message");
79+
expected.put("mdc-1", "mdc-v-1");
80+
expected.put("kv-1", "kv-v-1");
81+
expected.put("ecs", Map.of("version", "8.11"));
82+
expected.put("process", map("pid", 1, "thread", map("name", "main")));
83+
expected.put("log", map("level", "INFO", "logger", "org.example.Test"));
84+
expected.put("service",
85+
map("name", "name", "version", "1.0.0", "environment", "test", "node", map("name", "node-1")));
86+
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(expected);
7987
}
8088

8189
@Test
90+
@SuppressWarnings("unchecked")
8291
void shouldFormatException() {
8392
LoggingEvent event = createEvent();
8493
event.setMDCPropertyMap(Collections.emptyMap());
8594
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
8695
String json = this.formatter.format(event);
8796
Map<String, Object> deserialized = deserialize(json);
88-
assertThat(deserialized)
89-
.containsAllEntriesOf(map("error.type", "java.lang.RuntimeException", "error.message", "Boom"));
90-
String stackTrace = (String) deserialized.get("error.stack_trace");
91-
assertThat(stackTrace).startsWith(
92-
"java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"
93-
.formatted());
94-
assertThat(json).contains(
95-
"java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback.ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"
96-
.formatted()
97-
.replace("\n", "\\n")
98-
.replace("\r", "\\r"));
97+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
98+
Map<String, Object> expectedError = new HashMap<>();
99+
expectedError.put("type", "java.lang.RuntimeException");
100+
expectedError.put("message", "Boom");
101+
assertThat(error).containsAllEntriesOf(expectedError);
102+
String stackTrace = (String) error.get("stack_trace");
103+
assertThat(stackTrace)
104+
.startsWith(String.format("java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback."
105+
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException"));
106+
assertThat(json).contains(String
107+
.format("java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback."
108+
+ "ElasticCommonSchemaStructuredLogFormatterTests.shouldFormatException")
109+
.replace("\n", "\\n")
110+
.replace("\r", "\\r"));
99111
}
100112

101113
@Test
114+
@SuppressWarnings("unchecked")
102115
void shouldFormatExceptionUsingStackTracePrinter() {
103116
this.formatter = new ElasticCommonSchemaStructuredLogFormatter(this.environment, new SimpleStackTracePrinter(),
104117
getThrowableProxyConverter(), this.customizer);
105118
LoggingEvent event = createEvent();
106119
event.setMDCPropertyMap(Collections.emptyMap());
107120
event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom")));
108121
Map<String, Object> deserialized = deserialize(this.formatter.format(event));
109-
String stackTrace = (String) deserialized.get("error.stack_trace");
122+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
123+
String stackTrace = (String) error.get("stack_trace");
110124
assertThat(stackTrace).isEqualTo("stacktrace:RuntimeException");
111125
}
112126

@@ -123,13 +137,19 @@ void shouldFormatMarkersAsTags() {
123137
grandparent.add(parent1);
124138
event.addMarker(grandparent);
125139
String json = this.formatter.format(event);
126-
assertThat(json).endsWith("\n");
127140
Map<String, Object> deserialized = deserialize(json);
128-
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(
129-
map("@timestamp", "2024-07-02T08:49:53Z", "log.level", "INFO", "process.pid", 1, "process.thread.name",
130-
"main", "service.name", "name", "service.version", "1.0.0", "service.environment", "test",
131-
"service.node.name", "node-1", "log.logger", "org.example.Test", "message", "message",
132-
"ecs.version", "8.11", "tags", List.of("child", "child1", "grandparent", "parent", "parent1")));
141+
assertThat(deserialized.get("tags")).isEqualTo(List.of("child", "child1", "grandparent", "parent", "parent1"));
142+
}
143+
144+
@Test
145+
void shouldNestMdcAndKeyValuePairs() {
146+
LoggingEvent event = createEvent();
147+
event.setMDCPropertyMap(Map.of("a1.b1.c1", "A1B1C1", "a1.b1.c2", "A1B1C2"));
148+
event.setKeyValuePairs(keyValuePairs("a2.b1.c1", "A2B1C1", "a2.b1.c2", "A2B1C2"));
149+
String json = this.formatter.format(event);
150+
Map<String, Object> deserialized = deserialize(json);
151+
assertThat(deserialized.get("a1")).isEqualTo(Map.of("b1", Map.of("c1", "A1B1C1", "c2", "A1B1C2")));
152+
assertThat(deserialized.get("a2")).isEqualTo(Map.of("b1", Map.of("c1", "A2B1C1", "c2", "A2B1C2")));
133153
}
134154

135155
}

‎spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,17 @@ void tearDown() {
7272
}
7373

7474
@Test
75+
@SuppressWarnings("unchecked")
7576
void shouldSupportEcsCommonFormat() {
7677
this.encoder.setFormat("ecs");
7778
this.encoder.start();
7879
LoggingEvent event = createEvent(new RuntimeException("Boom!"));
7980
event.setMDCPropertyMap(Collections.emptyMap());
8081
String json = encode(event);
8182
Map<String, Object> deserialized = deserialize(json);
82-
assertThat(deserialized).containsKey("ecs.version");
83-
assertThat(deserialized.get("error.stack_trace")).isEqualTo("stacktrace:RuntimeException");
83+
assertThat(deserialized).containsEntry("ecs", Map.of("version", "8.11"));
84+
Map<String, Object> error = (Map<String, Object>) deserialized.get("error");
85+
assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException");
8486
}
8587

8688
@Test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.logging.structured;
18+
19+
import java.util.LinkedHashMap;
20+
import java.util.Map;
21+
22+
import org.junit.jupiter.api.Test;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
26+
27+
/**
28+
* Tests for {@link ElasticCommonSchemaPairs}.
29+
*
30+
* @author Phillip Webb
31+
*/
32+
class ElasticCommonSchemaPairsTests {
33+
34+
@Test
35+
void nestedExpandsNames() {
36+
Map<String, String> map = Map.of("a1.b1.c1", "A1B1C1", "a1.b2.c1", "A1B2C1", "a1.b1.c2", "A1B1C2");
37+
Map<String, Object> expected = new LinkedHashMap<>();
38+
Map<String, Object> a1 = new LinkedHashMap<>();
39+
Map<String, Object> b1 = new LinkedHashMap<>();
40+
Map<String, Object> b2 = new LinkedHashMap<>();
41+
expected.put("a1", a1);
42+
a1.put("b1", b1);
43+
a1.put("b2", b2);
44+
b1.put("c1", "A1B1C1");
45+
b1.put("c2", "A1B1C2");
46+
b2.put("c1", "A1B2C1");
47+
assertThat(ElasticCommonSchemaPairs.nested(map)).isEqualTo(expected);
48+
}
49+
50+
@Test
51+
void nestedWhenDuplicateInParentThrowsException() {
52+
Map<String, String> map = new LinkedHashMap<>();
53+
map.put("a1.b1.c1", "A1B1C1");
54+
map.put("a1.b1", "A1B1");
55+
assertThatIllegalStateException().isThrownBy(() -> ElasticCommonSchemaPairs.nested(map))
56+
.withMessage("Duplicate ECS pairs added under 'a1.b1'");
57+
}
58+
59+
@Test
60+
void nestedWhenDuplicateInLeafThrowsException() {
61+
Map<String, String> map = new LinkedHashMap<>();
62+
map.put("a1.b1", "A1B1");
63+
map.put("a1.b1.c1", "A1B1C1");
64+
assertThatIllegalStateException().isThrownBy(() -> ElasticCommonSchemaPairs.nested(map))
65+
.withMessage("Duplicate ECS pairs added under 'a1.b1'");
66+
}
67+
68+
}

‎spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPropertiesTests.java

+11-3
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,17 @@ void addToJsonMembersCreatesValidJson() {
7777
ElasticCommonSchemaProperties properties = new ElasticCommonSchemaProperties(
7878
new Service("spring", "1.2.3", "prod", "boot"));
7979
JsonWriter<ElasticCommonSchemaProperties> writer = JsonWriter.of(properties::jsonMembers);
80-
assertThat(writer.writeToString(properties))
81-
.isEqualTo("{\"service.name\":\"spring\",\"service.version\":\"1.2.3\","
82-
+ "\"service.environment\":\"prod\",\"service.node.name\":\"boot\"}");
80+
assertThat(writer.writeToString(properties)).isEqualToNormalizingNewlines("""
81+
{
82+
"service": {
83+
"name": "spring",
84+
"version": "1.2.3",
85+
"environment": "prod",
86+
"node": {
87+
"name": "boot"
88+
}
89+
}
90+
}""".replaceAll("\\s+", ""));
8391
}
8492

8593
@Test

‎spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structured-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ void shouldNotLogBanner(CapturedOutput output) {
5454
void json(CapturedOutput output) {
5555
SampleStructuredLoggingApplication.main(new String[0]);
5656
assertThat(output).doesNotContain("{\"@timestamp\"")
57-
.contains("\"process.thread.name\":\"!!")
57+
.contains("\"thread\":{\"name\":\"!!")
5858
.contains("\"process.procid\"")
5959
.contains("\"message\":\"Starting SampleStructuredLoggingApplication")
6060
.contains("\"foo\":\"hello");

0 commit comments

Comments
 (0)
Please sign in to comment.