diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc index 787e75928ea..5b0c0765c66 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc @@ -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. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java index fc79b0c8f66..b95cff09e36 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java @@ -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 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 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 getMarkers(Marker marker) { Set result = new TreeSet<>(); addMarkers(result, marker); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java index a20ef3f1379..c2dc78d6602 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java @@ -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 { - private static final PairExtractor 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 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 nested(List keyValuePairs) { + return ElasticCommonSchemaPairs.nested((nested) -> keyValuePairs + .forEach((keyValuePair) -> nested.accept(keyValuePair.key, keyValuePair.value))); } private static Set getMarkers(List markers) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairs.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairs.java new file mode 100644 index 00000000000..4f03466b5bf --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairs.java @@ -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 nested(Map map) { + return nested(map::forEach); + } + + @SuppressWarnings("unchecked") + public static Map nested(Consumer> nested) { + Map result = new LinkedHashMap<>(); + nested.accept((name, value) -> { + List nameParts = List.of(name.toString().split("\\.")); + Map 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) 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; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaProperties.java index c665c23f8f3..a643e502754 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaProperties.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ElasticCommonSchemaProperties.java @@ -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) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java index 5d7533aabcc..14505d815a4 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java @@ -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 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 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 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 error = (Map) deserialized.get("error"); + Map 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 deserialized = deserialize(this.formatter.format(event)); - String stackTrace = (String) deserialized.get("error.stack_trace"); + Map error = (Map) 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 deserialized = deserialize(json); Map expectedMessage = Map.of("foo", true, "bar", 1.0); - assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", "2024-07-02T08:49:53Z", - "log.level", "INFO", "process.pid", 1, "process.thread.name", "main", "service.name", "name", - "service.version", "1.0.0", "service.environment", "test", "service.node.name", "node-1", "log.logger", - "org.example.Test", "message", expectedMessage, "ecs.version", "8.11")); + 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 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")); } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLogLayoutTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLogLayoutTests.java index f7600f2ce7a..95682c99103 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLogLayoutTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLogLayoutTests.java @@ -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 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 error = (Map) deserialized.get("error"); + assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException"); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java index d2e6662ff9a..0908d433e2f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java @@ -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 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 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 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 error = (Map) deserialized.get("error"); + Map 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 deserialized = deserialize(this.formatter.format(event)); - String stackTrace = (String) deserialized.get("error.stack_trace"); + Map error = (Map) 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 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 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"))); } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java index f162c5acb0c..e222e1e1e21 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java @@ -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 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 error = (Map) deserialized.get("error"); + assertThat(error.get("stack_trace")).isEqualTo("stacktrace:RuntimeException"); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairsTests.java new file mode 100644 index 00000000000..8d9fff340a3 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPairsTests.java @@ -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 map = Map.of("a1.b1.c1", "A1B1C1", "a1.b2.c1", "A1B2C1", "a1.b1.c2", "A1B1C2"); + Map expected = new LinkedHashMap<>(); + Map a1 = new LinkedHashMap<>(); + Map b1 = new LinkedHashMap<>(); + Map 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 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 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'"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPropertiesTests.java index 7ecba4443c1..a804fde4f3a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/ElasticCommonSchemaPropertiesTests.java @@ -77,9 +77,17 @@ class ElasticCommonSchemaPropertiesTests { ElasticCommonSchemaProperties properties = new ElasticCommonSchemaProperties( new Service("spring", "1.2.3", "prod", "boot")); JsonWriter 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 diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structured-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structured-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java index 2e0159d2dab..03f9c645c0a 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structured-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structured-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java @@ -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");