From bf2950c045e7464f87b5c819ffe646ca75eb1f14 Mon Sep 17 00:00:00 2001 From: Moritz Halbritter Date: Mon, 15 Jul 2024 11:24:51 +0100 Subject: [PATCH] Add support for structured logging Update Logback and Log4j2 integrations to support structured logging. Support for the ECS and Logstash JSON formats is provided out-of-the-box and the `StructuredLogFormatter` interface may be used to if further custom formats need to be supported. Closes gh-5479 Co-authored-by: Phillip Webb --- .../antora/modules/how-to/pages/logging.adoc | 2 + .../reference/pages/features/logging.adoc | 96 +++++++++ .../customformat/MyCustomFormat.java | 30 +++ .../boot/logging/LoggingSystemProperties.java | 2 + .../boot/logging/LoggingSystemProperty.java | 12 ++ ...ticCommonSchemaStructuredLogFormatter.java | 80 ++++++++ .../LogstashStructuredLogFormatter.java | 96 +++++++++ .../logging/log4j2/StructuredLogLayout.java | 144 ++++++++++++++ .../logback/DefaultLogbackConfiguration.java | 69 +++++-- ...ticCommonSchemaStructuredLogFormatter.java | 80 ++++++++ .../LogstashStructuredLogFormatter.java | 102 ++++++++++ .../logging/logback/StructuredLogEncoder.java | 141 ++++++++++++++ .../structured/ApplicationMetadata.java | 32 +++ .../structured/CommonStructuredLogFormat.java | 69 +++++++ .../structured/StructuredLogFormatter.java | 47 +++++ .../StructuredLogFormatterFactory.java | 162 ++++++++++++++++ .../boot/logging/structured/package-info.java | 20 ++ .../boot/util/Instantiator.java | 43 ++++- ...itional-spring-configuration-metadata.json | 10 + .../boot/logging/log4j2/log4j2-file.xml | 18 +- .../boot/logging/log4j2/log4j2.xml | 9 +- .../boot/logging/logback/defaults.xml | 2 + .../logback/structured-console-appender.xml | 20 ++ .../logback/structured-file-appender.xml | 28 +++ .../logging/LoggingSystemPropertiesTests.java | 16 ++ .../AbstractStructuredLoggingTests.java | 74 +++++++ ...4j2EcsStructuredLoggingFormatterTests.java | 76 ++++++++ ...gstashStructuredLoggingFormatterTests.java | 80 ++++++++ .../log4j2/StructuredLoggingLayoutTests.java | 141 ++++++++++++++ .../AbstractStructuredLoggingTests.java | 114 +++++++++++ ...ackEcsStructuredLoggingFormatterTests.java | 81 ++++++++ .../logback/LogbackLoggingSystemTests.java | 2 + ...gstashStructuredLoggingFormatterTests.java | 106 ++++++++++ .../StructuredLoggingEncoderTests.java | 182 ++++++++++++++++++ .../CommonStructuredLogFormatTests.java | 47 +++++ .../StructuredLogFormatterFactoryTests.java | 147 ++++++++++++++ .../boot/util/InstantiatorTests.java | 28 ++- .../build.gradle | 17 ++ .../log4j2/CustomStructuredLogFormatter.java | 49 +++++ ...pleLog4j2StructuredLoggingApplication.java | 29 +++ .../src/main/resources/application.properties | 5 + ...g4j2StructuredLoggingApplicationTests.java | 59 ++++++ .../build.gradle | 12 ++ .../CustomStructuredLogFormatter.java | 53 +++++ .../SampleStructuredLoggingApplication.java | 29 +++ .../src/main/resources/application.properties | 5 + ...mpleStructuredLoggingApplicationTests.java | 59 ++++++ 47 files changed, 2699 insertions(+), 26 deletions(-) create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/customformat/MyCustomFormat.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredLogLayout.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatter.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/StructuredLogEncoder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ApplicationMetadata.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatter.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactory.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/package-info.java create mode 100644 spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-console-appender.xml create mode 100644 spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-file-appender.xml create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/AbstractStructuredLoggingTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2EcsStructuredLoggingFormatterTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2LogstashStructuredLoggingFormatterTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/AbstractStructuredLoggingTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackEcsStructuredLoggingFormatterTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLogstashStructuredLoggingFormatterTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLoggingEncoderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/CommonStructuredLogFormatTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactoryTests.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/build.gradle create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/CustomStructuredLogFormatter.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplication.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/resources/application.properties create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/test/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplicationTests.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/build.gradle create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/CustomStructuredLogFormatter.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleStructuredLoggingApplication.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/resources/application.properties create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/how-to/pages/logging.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/how-to/pages/logging.adoc index 38370ada44..d9bd04076f 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/how-to/pages/logging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/how-to/pages/logging.adoc @@ -51,7 +51,9 @@ The following files are provided under `org/springframework/boot/logging/logback * `defaults.xml` - Provides conversion rules, pattern properties and common logger configurations. * `console-appender.xml` - Adds a `ConsoleAppender` using the `CONSOLE_LOG_PATTERN`. +* `structured-console-appender.xml` - Adds a `ConsoleAppender` using structured logging in the `CONSOLE_LOG_STRUCTURED_FORMAT`. * `file-appender.xml` - Adds a `RollingFileAppender` using the `FILE_LOG_PATTERN` and `ROLLING_FILE_NAME_PATTERN` with appropriate settings. +* `structured-file-appender.xml` - Adds a `RollingFileAppender` using the `ROLLING_FILE_NAME_PATTERN` with structured logging in the `FILE_LOG_STRUCTURED_FORMAT`. In addition, a legacy `base.xml` file is provided for compatibility with earlier versions of Spring Boot. 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 7856a7a159..4bda514314 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 @@ -366,6 +366,14 @@ The properties that are transferred are described in the following table: | `LOG_LEVEL_PATTERN` | The format to use when rendering the log level (default `%5p`). +| configprop:logging.structured.format.console[] +| `CONSOLE_LOG_STRUCTURED_FORMAT` +| The structured logging format to use for console logging. + +| configprop:logging.structured.format.file[] +| `FILE_LOG_STRUCTURED_FORMAT` +| The structured logging format to use for file logging. + | `PID` | `PID` | The current process ID (discovered if possible and when not already defined as an OS environment variable). @@ -425,6 +433,94 @@ Handling authenticated request +[[features.logging.structured]] +== Structured Logging + +Structured logging is a technique where the log output is written in a well-defined, often machine-readable format. +Spring Boot supports structured logging and has support for the following formats out of the box: + +* xref:#features.logging.structured.ecs[Elastic Common Schema (ECS)] +* xref:#features.logging.structured.logstash[Logstash] + +To enable structured logging, set the property configprop:logging.structured.format.console[] (for console output) or configprop:logging.structured.format.file[] (for file output) to the id of the format you want to use. + + + +[[features.logging.structured.ecs]] +=== Elastic Common Schema +https://www.elastic.co/guide/en/ecs/8.11/ecs-reference.html[Elastic Common Schema] is a JSON based logging format. + +To enable the Elastic Common Schema log format, set the appropriate `format` property to `ecs`: + +[configprops,yaml] +---- +logging: + structured: + format: + console: ecs + file: ecs +---- + +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"} +---- + +This format also adds every key value pair contained in the MDC to the JSON object. +You can also use the https://www.slf4j.org/manual.html#fluent[SLF4J fluent logging API] to add key value pairs to the logged JSON object with the https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html#addKeyValue(java.lang.String,java.lang.Object)[addKeyValue] method. + + + +[[features.logging.structured.logstash]] +=== Logstash JSON format + +The https://github.com/logfellow/logstash-logback-encoder?tab=readme-ov-file#standard-fields[Logstash JSON format] is a JSON based logging format. + +To enable the Logstash JSON log format, set the appropriate `format` property to `logstash`: + +[configprops,yaml] +---- +logging: + structured: + format: + console: logstash + file: logstash +---- + +A log line looks like this: + +[source,json] +---- +{"@timestamp":"2024-01-01T10:15:00.111037681+02:00","@version":"1","message":"No active profile set, falling back to 1 default profile: \"default\"","logger_name":"org.example.Application","thread_name":"main","level":"INFO","level_value":20000} +---- + +This format also adds every key value pair contained in the MDC to the JSON object. +You can also use the https://www.slf4j.org/manual.html#fluent[SLF4J fluent logging API] to add key value pairs to the logged JSON object with the https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html#addKeyValue(java.lang.String,java.lang.Object)[addKeyValue] method. + +If you add https://www.slf4j.org/api/org/slf4j/Marker.html[markers], these will show up in a `tags` string array in the JSON. + + + +[[features.logging.structured.custom-format]] +=== Custom Structured Logging formats + +The structured logging support in Spring Boot is extensible, allowing you to define your own custom format. +To do this, implement the `StructuredLoggingFormatter` interface. The generic type argument has to be `ILoggingEvent` when using Logback and `LogEvent` when using Log4j2 (that means your implementation is tied to a specific logging system). +Your implementation is then called with the log event and returns the `String` to be logged, as seen in this example: + +include-code::MyCustomFormat[] + +As you can see in the example, you can return any format, it doesn't have to be JSON. + +To enable your custom format, set the property configprop:logging.structured.format.console[] or configprop:logging.structured.format.file[] to the fully qualified class name of your implementation. + +Your implementation can use some constructor parameters, which are injected automatically. +Please see the JavaDoc of xref:api:java/org/springframework/boot/logging/structured/StructuredLogFormatter.html[`StructuredLogFormatter`] for more details. + + + [[features.logging.logback-extensions]] == Logback Extensions diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/customformat/MyCustomFormat.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/customformat/MyCustomFormat.java new file mode 100644 index 0000000000..e80a189a6b --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/customformat/MyCustomFormat.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.features.logging.structured.customformat; + +import ch.qos.logback.classic.spi.ILoggingEvent; + +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +class MyCustomFormat implements StructuredLogFormatter { + + @Override + public String format(ILoggingEvent event) { + return "time=" + event.getInstant() + " level=" + event.getLevel() + " message=" + event.getMessage() + "\n"; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java index bdc274756b..bd4a30349e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java @@ -127,6 +127,8 @@ public class LoggingSystemProperties { setSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD, resolver); setSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN, resolver); setSystemProperty(LoggingSystemProperty.FILE_PATTERN, resolver); + setSystemProperty(LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT, resolver); + setSystemProperty(LoggingSystemProperty.FILE_STRUCTURED_FORMAT, resolver); setSystemProperty(LoggingSystemProperty.LEVEL_PATTERN, resolver); setSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN, resolver); setSystemProperty(LoggingSystemProperty.CORRELATION_PATTERN, resolver); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java index 1e917edd94..d57d32b403 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java @@ -85,6 +85,18 @@ public enum LoggingSystemProperty { */ FILE_PATTERN("FILE_LOG_PATTERN", "logging.pattern.file"), + /** + * Logging system property for the console structured logging format. + * @since 3.4.0 + */ + CONSOLE_STRUCTURED_FORMAT("CONSOLE_LOG_STRUCTURED_FORMAT", "logging.structured.format.console"), + + /** + * Logging system property for the file structured logging format. + * @since 3.4.0 + */ + FILE_STRUCTURED_FORMAT("FILE_LOG_STRUCTURED_FORMAT", "logging.structured.format.file"), + /** * Logging system property for the log level pattern. */ 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 new file mode 100644 index 0000000000..574581a9cc --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.ThrowableProxy; +import org.apache.logging.log4j.core.time.Instant; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.util.ReadOnlyStringMap; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.util.ObjectUtils; + +/** + * Log4j2 {@link StructuredLogFormatter} for + * {@link CommonStructuredLogFormat#ELASTIC_COMMON_SCHEMA}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class ElasticCommonSchemaStructuredLogFormatter implements StructuredLogFormatter { + + private final JsonWriter writer; + + ElasticCommonSchemaStructuredLogFormatter(ApplicationMetadata metadata) { + this.writer = JsonWriter.of((members) -> logEventJson(metadata, members)).withNewLineAtEnd(); + } + + private void logEventJson(ApplicationMetadata metadata, JsonWriter.Members members) { + members.add("@timestamp", LogEvent::getInstant).as(this::asTimestamp); + members.add("log.level", LogEvent::getLevel).as(Level::name); + members.add("process.pid", metadata::pid).whenNotNull(); + members.add("process.thread.name", LogEvent::getThreadName); + members.add("service.name", metadata::name).whenHasLength(); + members.add("service.version", metadata::version).whenHasLength(); + members.add("service.environment", metadata::environment).whenHasLength(); + members.add("service.node.name", metadata::nodeName).whenHasLength(); + members.add("log.logger", LogEvent::getLoggerName); + members.add("message", LogEvent::getMessage).as(Message::getFormattedMessage); + members.add(LogEvent::getContextData) + .whenNot(ReadOnlyStringMap::isEmpty) + .usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept)); + members.add(LogEvent::getThrownProxy).whenNotNull().usingMembers((thrownProxyMembers) -> { + thrownProxyMembers.add("error.type", ThrowableProxy::getThrowable) + .whenNotNull() + .as(ObjectUtils::nullSafeClassName); + thrownProxyMembers.add("error.message", ThrowableProxy::getMessage); + thrownProxyMembers.add("error.stack_trace", ThrowableProxy::getExtendedStackTraceAsString); + }); + members.add("ecs.version", "8.11"); + } + + private java.time.Instant asTimestamp(Instant instant) { + return java.time.Instant.ofEpochMilli(instant.getEpochMillisecond()).plusNanos(instant.getNanoOfMillisecond()); + } + + @Override + public String format(LogEvent event) { + return this.writer.writeToString(event); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java new file mode 100644 index 0000000000..b63ef7bd36 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatter.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.ThrowableProxy; +import org.apache.logging.log4j.core.time.Instant; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.util.ReadOnlyStringMap; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.util.CollectionUtils; + +/** + * Log4j2 {@link StructuredLogFormatter} for {@link CommonStructuredLogFormat#LOGSTASH}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class LogstashStructuredLogFormatter implements StructuredLogFormatter { + + private JsonWriter writer; + + LogstashStructuredLogFormatter() { + this.writer = JsonWriter.of(this::logEventJson).withNewLineAtEnd(); + } + + private void logEventJson(JsonWriter.Members members) { + members.add("@timestamp", LogEvent::getInstant).as(this::asTimestamp); + members.add("@version", "1"); + members.add("message", LogEvent::getMessage).as(Message::getFormattedMessage); + members.add("logger_name", LogEvent::getLoggerName); + members.add("thread_name", LogEvent::getThreadName); + members.add("level", LogEvent::getLevel).as(Level::name); + members.add("level_value", LogEvent::getLevel).as(Level::intLevel); + members.add(LogEvent::getContextData) + .whenNot(ReadOnlyStringMap::isEmpty) + .usingPairs((contextData, pairs) -> contextData.forEach(pairs::accept)); + members.add("tags", LogEvent::getMarker).whenNotNull().as(this::getMarkers).whenNot(CollectionUtils::isEmpty); + members.add("stack_trace", LogEvent::getThrownProxy) + .whenNotNull() + .as(ThrowableProxy::getExtendedStackTraceAsString); + } + + private String asTimestamp(Instant instant) { + java.time.Instant javaInstant = java.time.Instant.ofEpochMilli(instant.getEpochMillisecond()) + .plusNanos(instant.getNanoOfMillisecond()); + OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(javaInstant, ZoneId.systemDefault()); + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(offsetDateTime); + } + + private Set getMarkers(Marker marker) { + Set result = new TreeSet<>(); + addMarkers(result, marker); + return result; + } + + private void addMarkers(Set result, Marker marker) { + result.add(marker.getName()); + if (marker.hasParents()) { + for (Marker parent : marker.getParents()) { + addMarkers(result, parent); + } + } + } + + @Override + public String format(LogEvent event) { + return this.writer.writeToString(event); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredLogLayout.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredLogLayout.java new file mode 100644 index 0000000000..a604278aa6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/StructuredLogLayout.java @@ -0,0 +1,144 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.apache.logging.log4j.core.Layout; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.Node; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory; +import org.apache.logging.log4j.core.layout.AbstractStringLayout; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.boot.logging.structured.StructuredLogFormatterFactory; +import org.springframework.boot.logging.structured.StructuredLogFormatterFactory.CommonFormatters; +import org.springframework.util.Assert; + +/** + * {@link Layout Log4j2 Layout} for structured logging. + * + * @author Moritz Halbritter + * @author Phillip Webb + * @see StructuredLogFormatter + */ +@Plugin(name = "StructuredLogLayout", category = Node.CATEGORY, elementType = Layout.ELEMENT_TYPE) +final class StructuredLogLayout extends AbstractStringLayout { + + private final StructuredLogFormatter formatter; + + private StructuredLogLayout(Charset charset, StructuredLogFormatter formatter) { + super(charset); + Assert.notNull(formatter, "Formatter must not be null"); + this.formatter = formatter; + } + + @Override + public String toSerializable(LogEvent event) { + return this.formatter.format(event); + } + + @PluginBuilderFactory + static StructuredLogLayout.Builder newBuilder() { + return new StructuredLogLayout.Builder(); + } + + static final class Builder implements org.apache.logging.log4j.core.util.Builder { + + @PluginBuilderAttribute + private String format; + + @PluginBuilderAttribute + private String charset = StandardCharsets.UTF_8.name(); + + @PluginBuilderAttribute + private Long pid; + + @PluginBuilderAttribute + private String serviceName; + + @PluginBuilderAttribute + private String serviceVersion; + + @PluginBuilderAttribute + private String serviceNodeName; + + @PluginBuilderAttribute + private String serviceEnvironment; + + Builder setFormat(String format) { + this.format = format; + return this; + } + + Builder setCharset(String charset) { + this.charset = charset; + return this; + } + + Builder setPid(Long pid) { + this.pid = pid; + return this; + } + + Builder setServiceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + Builder setServiceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } + + Builder setServiceNodeName(String serviceNodeName) { + this.serviceNodeName = serviceNodeName; + return this; + } + + Builder setServiceEnvironment(String serviceEnvironment) { + this.serviceEnvironment = serviceEnvironment; + return this; + } + + @Override + public StructuredLogLayout build() { + ApplicationMetadata applicationMetadata = new ApplicationMetadata(this.pid, this.serviceName, + this.serviceVersion, this.serviceEnvironment, this.serviceNodeName); + Charset charset = Charset.forName(this.charset); + StructuredLogFormatter formatter = new StructuredLogFormatterFactory<>(LogEvent.class, + applicationMetadata, null, this::addCommonFormatters) + .get(this.format); + return new StructuredLogLayout(charset, formatter); + } + + private void addCommonFormatters(CommonFormatters commonFormatters) { + commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, + (instantiator) -> new ElasticCommonSchemaStructuredLogFormatter( + instantiator.getArg(ApplicationMetadata.class))); + commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, + (instantiator) -> new LogstashStructuredLogFormatter()); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java index b7c2a43d20..1a96efa658 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java @@ -24,6 +24,8 @@ import ch.qos.logback.classic.filter.ThresholdFilter; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.Appender; import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.core.encoder.Encoder; import ch.qos.logback.core.rolling.RollingFileAppender; import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy; import ch.qos.logback.core.spi.ScanException; @@ -34,6 +36,7 @@ import org.springframework.boot.ansi.AnsiColor; import org.springframework.boot.ansi.AnsiElement; import org.springframework.boot.ansi.AnsiStyle; import org.springframework.boot.logging.LogFile; +import org.springframework.util.StringUtils; /** * Default logback configuration used by Spring Boot. Uses {@link LogbackConfigurator} to @@ -47,6 +50,7 @@ import org.springframework.boot.logging.LogFile; * @author Robert Thornton * @author Scott Frederick * @author Jonatan Ivanov + * @author Moritz Halbritter */ class DefaultLogbackConfiguration { @@ -99,9 +103,11 @@ class DefaultLogbackConfiguration { putProperty(config, "CONSOLE_LOG_PATTERN", CONSOLE_LOG_PATTERN); putProperty(config, "CONSOLE_LOG_CHARSET", "${CONSOLE_LOG_CHARSET:-" + DEFAULT_CHARSET + "}"); putProperty(config, "CONSOLE_LOG_THRESHOLD", "${CONSOLE_LOG_THRESHOLD:-TRACE}"); + putProperty(config, "CONSOLE_LOG_STRUCTURED_FORMAT", "${CONSOLE_LOG_STRUCTURED_FORMAT:-}"); putProperty(config, "FILE_LOG_PATTERN", FILE_LOG_PATTERN); putProperty(config, "FILE_LOG_CHARSET", "${FILE_LOG_CHARSET:-" + DEFAULT_CHARSET + "}"); putProperty(config, "FILE_LOG_THRESHOLD", "${FILE_LOG_THRESHOLD:-TRACE}"); + putProperty(config, "FILE_LOG_STRUCTURED_FORMAT", "${FILE_LOG_STRUCTURED_FORMAT:-}"); config.logger("org.apache.catalina.startup.DigesterFactory", Level.ERROR); config.logger("org.apache.catalina.util.LifecycleBase", Level.ERROR); config.logger("org.apache.coyote.http11.Http11NioProtocol", Level.WARN); @@ -123,36 +129,59 @@ class DefaultLogbackConfiguration { private Appender consoleAppender(LogbackConfigurator config) { ConsoleAppender appender = new ConsoleAppender<>(); - ThresholdFilter filter = new ThresholdFilter(); - filter.setLevel(resolve(config, "${CONSOLE_LOG_THRESHOLD}")); - filter.start(); - appender.addFilter(filter); - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setPattern(resolve(config, "${CONSOLE_LOG_PATTERN}")); - encoder.setCharset(resolveCharset(config, "${CONSOLE_LOG_CHARSET}")); - config.start(encoder); - appender.setEncoder(encoder); + createAppender(config, appender, "CONSOLE"); config.appender("CONSOLE", appender); return appender; } private Appender fileAppender(LogbackConfigurator config, String logFile) { RollingFileAppender appender = new RollingFileAppender<>(); - ThresholdFilter filter = new ThresholdFilter(); - filter.setLevel(resolve(config, "${FILE_LOG_THRESHOLD}")); - filter.start(); - appender.addFilter(filter); - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setPattern(resolve(config, "${FILE_LOG_PATTERN}")); - encoder.setCharset(resolveCharset(config, "${FILE_LOG_CHARSET}")); - appender.setEncoder(encoder); - config.start(encoder); + createAppender(config, appender, "FILE"); appender.setFile(logFile); setRollingPolicy(appender, config); config.appender("FILE", appender); return appender; } + private void createAppender(LogbackConfigurator config, OutputStreamAppender appender, String type) { + appender.addFilter(createThresholdFilter(config, type)); + Encoder encoder = createEncoder(config, type); + appender.setEncoder(encoder); + config.start(encoder); + } + + private ThresholdFilter createThresholdFilter(LogbackConfigurator config, String type) { + ThresholdFilter filter = new ThresholdFilter(); + filter.setLevel(resolve(config, "${" + type + "_LOG_THRESHOLD}")); + filter.start(); + return filter; + } + + private Encoder createEncoder(LogbackConfigurator config, String type) { + Charset charset = resolveCharset(config, "${" + type + "_LOG_CHARSET}"); + String structuredLogFormat = resolve(config, "${" + type + "_LOG_STRUCTURED_FORMAT}"); + if (StringUtils.hasLength(structuredLogFormat)) { + StructuredLogEncoder encoder = createStructuredLoggingEncoder(config, structuredLogFormat); + encoder.setCharset(charset); + return encoder; + } + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setCharset(charset); + encoder.setPattern(resolve(config, "${" + type + "_LOG_PATTERN}")); + return encoder; + } + + private StructuredLogEncoder createStructuredLoggingEncoder(LogbackConfigurator config, String format) { + StructuredLogEncoder encoder = new StructuredLogEncoder(); + encoder.setFormat(format); + encoder.setPid(resolveLong(config, "${PID:--1}")); + String applicationName = resolve(config, "${APPLICATION_NAME:-}"); + if (StringUtils.hasLength(applicationName)) { + encoder.setServiceName(applicationName); + } + return encoder; + } + private void setRollingPolicy(RollingFileAppender appender, LogbackConfigurator config) { SizeAndTimeBasedRollingPolicy rollingPolicy = new SizeAndTimeBasedRollingPolicy<>(); rollingPolicy.setContext(config.getContext()); @@ -176,6 +205,10 @@ class DefaultLogbackConfiguration { return Integer.parseInt(resolve(config, val)); } + private long resolveLong(LogbackConfigurator config, String val) { + return Long.parseLong(resolve(config, val)); + } + private FileSize resolveFileSize(LogbackConfigurator config, String val) { return FileSize.valueOf(resolve(config, val)); } 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 new file mode 100644 index 0000000000..3b769a2765 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import org.slf4j.event.KeyValuePair; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.json.JsonWriter.PairExtractor; +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +/** + * Logback {@link StructuredLogFormatter} for + * {@link CommonStructuredLogFormat#ELASTIC_COMMON_SCHEMA}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class ElasticCommonSchemaStructuredLogFormatter implements StructuredLogFormatter { + + private static final PairExtractor keyValuePairExtractor = PairExtractor.of((pair) -> pair.key, + (pair) -> pair.value); + + private JsonWriter writer; + + ElasticCommonSchemaStructuredLogFormatter(ApplicationMetadata metadata, + ThrowableProxyConverter throwableProxyConverter) { + this.writer = JsonWriter + .of((members) -> loggingEventJson(metadata, throwableProxyConverter, members)) + .withNewLineAtEnd(); + } + + private void loggingEventJson(ApplicationMetadata metadata, ThrowableProxyConverter throwableProxyConverter, + JsonWriter.Members members) { + members.add("@timestamp", ILoggingEvent::getInstant); + members.add("log.level", ILoggingEvent::getLevel); + members.add("process.pid", metadata::pid).whenNotNull(); + members.add("process.thread.name", ILoggingEvent::getThreadName); + members.add("service.name", metadata::name).whenHasLength(); + members.add("service.version", metadata::version).whenHasLength(); + members.add("service.environment", metadata::environment).whenHasLength(); + members.add("service.node.name", metadata::nodeName).whenHasLength(); + members.add("log.logger", ILoggingEvent::getLoggerName); + members.add("message", ILoggingEvent::getFormattedMessage); + members.addMapEntries(ILoggingEvent::getMDCPropertyMap); + members.add(ILoggingEvent::getKeyValuePairs) + .whenNotEmpty() + .usingExtractedPairs(Iterable::forEach, keyValuePairExtractor); + members.addSelf().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", (event) -> throwableProxyConverter.convert(event)); + }); + members.add("ecs.version", "8.11"); + } + + @Override + public String format(ILoggingEvent event) { + return this.writer.writeToString(event); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatter.java new file mode 100644 index 0000000000..4a50f2f291 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatter.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +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.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +/** + * Logback {@link StructuredLogFormatter} for {@link CommonStructuredLogFormat#LOGSTASH}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class LogstashStructuredLogFormatter implements StructuredLogFormatter { + + private static final PairExtractor keyValuePairExtractor = PairExtractor.of((pair) -> pair.key, + (pair) -> pair.value); + + private JsonWriter writer; + + LogstashStructuredLogFormatter(ThrowableProxyConverter throwableProxyConverter) { + this.writer = JsonWriter.of((members) -> loggingEventJson(throwableProxyConverter, members)) + .withNewLineAtEnd(); + } + + private void loggingEventJson(ThrowableProxyConverter throwableProxyConverter, + JsonWriter.Members members) { + members.add("@timestamp", ILoggingEvent::getInstant).as(this::asTimestamp); + members.add("@version", "1"); + members.add("message", ILoggingEvent::getFormattedMessage); + members.add("logger_name", ILoggingEvent::getLoggerName); + members.add("thread_name", ILoggingEvent::getThreadName); + members.add("level", ILoggingEvent::getLevel); + members.add("level_value", ILoggingEvent::getLevel).as(Level::toInt); + members.addMapEntries(ILoggingEvent::getMDCPropertyMap); + members.add(ILoggingEvent::getKeyValuePairs) + .whenNotEmpty() + .usingExtractedPairs(Iterable::forEach, keyValuePairExtractor); + members.add("tags", ILoggingEvent::getMarkerList).whenNotNull().as(this::getMarkers).whenNotEmpty(); + members.add("stack_trace", (event) -> event) + .whenNotNull(ILoggingEvent::getThrowableProxy) + .as(throwableProxyConverter::convert); + } + + private String asTimestamp(Instant instant) { + OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(instant, ZoneId.systemDefault()); + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(offsetDateTime); + } + + private Set getMarkers(List markers) { + Set result = new LinkedHashSet<>(); + addMarkers(result, markers.iterator()); + return result; + } + + private void addMarkers(Set result, Iterator iterator) { + while (iterator.hasNext()) { + Marker marker = iterator.next(); + result.add(marker.getName()); + if (marker.hasReferences()) { + addMarkers(result, marker.iterator()); + } + } + } + + @Override + public String format(ILoggingEvent event) { + return this.writer.writeToString(event); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/StructuredLogEncoder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/StructuredLogEncoder.java new file mode 100644 index 0000000000..04c3e17169 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/StructuredLogEncoder.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.encoder.EncoderBase; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.boot.logging.structured.StructuredLogFormatterFactory; +import org.springframework.boot.logging.structured.StructuredLogFormatterFactory.CommonFormatters; +import org.springframework.boot.util.Instantiator.AvailableParameters; +import org.springframework.util.Assert; + +/** + * {@link Encoder Logback encoder} for structured logging. + * + * @author Moritz Halbritter + * @author Phillip Webb + * @since 3.4.0 + * @see StructuredLogFormatter + */ +public class StructuredLogEncoder extends EncoderBase { + + private final ThrowableProxyConverter throwableProxyConverter = new ThrowableProxyConverter(); + + private String format; + + private StructuredLogFormatter formatter; + + private Long pid; + + private String serviceName; + + private String serviceVersion; + + private String serviceNodeName; + + private String serviceEnvironment; + + private Charset charset = StandardCharsets.UTF_8; + + public void setFormat(String format) { + this.format = format; + } + + public void setPid(Long pid) { + this.pid = pid; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + public void setServiceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + } + + public void setServiceNodeName(String serviceNodeName) { + this.serviceNodeName = serviceNodeName; + } + + public void setServiceEnvironment(String serviceEnvironment) { + this.serviceEnvironment = serviceEnvironment; + } + + public void setCharset(Charset charset) { + this.charset = charset; + } + + @Override + public void start() { + Assert.state(this.format != null, "Format has not been set"); + this.formatter = createFormatter(this.format); + super.start(); + this.throwableProxyConverter.start(); + } + + private StructuredLogFormatter createFormatter(String format) { + ApplicationMetadata applicationMetadata = new ApplicationMetadata(this.pid, this.serviceName, + this.serviceVersion, this.serviceEnvironment, this.serviceNodeName); + return new StructuredLogFormatterFactory<>(ILoggingEvent.class, applicationMetadata, + this::addAvailableParameters, this::addCommonFormatters) + .get(format); + } + + private void addAvailableParameters(AvailableParameters availableParameters) { + availableParameters.add(ThrowableProxyConverter.class, this.throwableProxyConverter); + } + + private void addCommonFormatters(CommonFormatters commonFormatters) { + commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, + (instantiator) -> new ElasticCommonSchemaStructuredLogFormatter( + instantiator.getArg(ApplicationMetadata.class), + instantiator.getArg(ThrowableProxyConverter.class))); + commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, (instantiator) -> new LogstashStructuredLogFormatter( + instantiator.getArg(ThrowableProxyConverter.class))); + } + + @Override + public void stop() { + this.throwableProxyConverter.stop(); + super.stop(); + } + + @Override + public byte[] headerBytes() { + return null; + } + + @Override + public byte[] encode(ILoggingEvent event) { + return this.formatter.format(event).getBytes(this.charset); + } + + @Override + public byte[] footerBytes() { + return null; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ApplicationMetadata.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ApplicationMetadata.java new file mode 100644 index 0000000000..61bdd9918b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/ApplicationMetadata.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +/** + * Metadata about the application. + * + * @param pid the process ID of the application + * @param name the application name + * @param version the version of the application + * @param environment the name of the environment the application is running in + * @param nodeName the name of the node the application is running on + * @author Moritz Halbritter + * @since 3.4.0 + */ +public record ApplicationMetadata(Long pid, String name, String version, String environment, String nodeName) { + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java new file mode 100644 index 0000000000..e4a92ebd32 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +/** + * Common structured log formats supported by Spring Boot. + * + * @author Moritz Halbritter + * @author Phillip Webb + * @since 3.4.0 + */ +public enum CommonStructuredLogFormat { + + /** + * Elasic Common + * Schema (ECS) log format. + */ + ELASTIC_COMMON_SCHEMA("ecs"), + + /** + * The Logstash + * log format. + */ + LOGSTASH("logstash"); + + private final String id; + + CommonStructuredLogFormat(String id) { + this.id = id; + } + + /** + * Return the ID for this format. + * @return the format identifier + */ + String getId() { + return this.id; + } + + /** + * Find the {@link CommonStructuredLogFormat} for the given ID. + * @param id the format identifier + * @return the associated {@link CommonStructuredLogFormat} or {@code null} + */ + static CommonStructuredLogFormat forId(String id) { + for (CommonStructuredLogFormat candidate : values()) { + if (candidate.getId().equalsIgnoreCase(id)) { + return candidate; + } + } + return null; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatter.java new file mode 100644 index 0000000000..69a249827b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatter.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; + +/** + * Formats a log event to a structured log message. + *

+ * Implementing classes can declare the following parameter types in the constructor: + *

    + *
  • {@link ApplicationMetadata}
  • + *
+ * When using Logback, implementing classes can also use the following parameter types in + * the constructor: + *
    + *
  • {@link ThrowableProxyConverter}
  • + *
+ * + * @param the log event type + * @author Moritz Halbritter + * @since 3.4.0 + */ +public interface StructuredLogFormatter { + + /** + * Formats the given log event. + * @param event the log event to write + * @return the formatted log event + */ + String format(E event); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactory.java new file mode 100644 index 0000000000..c2a51ec103 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactory.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +import java.util.Collection; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Consumer; + +import org.springframework.boot.util.Instantiator; +import org.springframework.boot.util.Instantiator.AvailableParameters; +import org.springframework.boot.util.Instantiator.FailureHandler; +import org.springframework.core.GenericTypeResolver; +import org.springframework.util.Assert; + +/** + * Factory that can be used to create a fully instantiated {@link StructuredLogFormatter} + * for either a {@link CommonStructuredLogFormat#getId() common format} or a + * fully-qualified class name. + * + * @param the log even type + * @author Moritz Halbritter + * @author Phillip Webb + * @since 3.4.0 + * @see StructuredLogFormatter + */ +public class StructuredLogFormatterFactory { + + private static FailureHandler failureHandler = (type, implementationName, failure) -> { + if (!(failure instanceof ClassNotFoundException)) { + throw new IllegalArgumentException( + "Unable to instantiate " + implementationName + " [" + type.getName() + "]", failure); + } + }; + + private final Class logEventType; + + private final Instantiator> instantiator; + + private final CommonFormatters commonFormatters; + + /** + * Create a new {@link StructuredLogFormatterFactory} instance. + * @param logEventType the log event type + * @param applicationMetadata an {@link ApplicationMetadata} instance for injection + * @param availableParameters callback used to configure available parameters for the + * specific logging system + * @param commonFormatters callback used to define supported common formatters + */ + public StructuredLogFormatterFactory(Class logEventType, ApplicationMetadata applicationMetadata, + Consumer availableParameters, Consumer> commonFormatters) { + this.logEventType = logEventType; + this.instantiator = new Instantiator<>(StructuredLogFormatter.class, (allAvailableParameters) -> { + allAvailableParameters.add(ApplicationMetadata.class, applicationMetadata); + if (availableParameters != null) { + availableParameters.accept(allAvailableParameters); + } + }, failureHandler); + this.commonFormatters = new CommonFormatters<>(); + commonFormatters.accept(this.commonFormatters); + } + + /** + * Get a new {@link StructuredLogFormatter} instance for the specified format. + * @param format the format requested (either a {@link CommonStructuredLogFormat} ID + * or a fully-qualified class name) + * @return a new {@link StructuredLogFormatter} instance + * @throws IllegalArgumentException if the format is unknown + */ + public StructuredLogFormatter get(String format) { + StructuredLogFormatter formatter = this.commonFormatters.get(this.instantiator, format); + formatter = (formatter != null) ? formatter : getUsingClassName(format); + if (formatter != null) { + return formatter; + } + throw new IllegalArgumentException( + "Unknown format '%s'. Values can be a valid fully-qualified class name or one of the common formats: %s" + .formatted(format, this.commonFormatters.getCommonNames())); + } + + private StructuredLogFormatter getUsingClassName(String className) { + StructuredLogFormatter formatter = this.instantiator.instantiate(className); + if (formatter != null) { + checkTypeArgument(formatter); + } + return formatter; + } + + private void checkTypeArgument(Object formatter) { + Class typeArgument = GenericTypeResolver.resolveTypeArgument(formatter.getClass(), + StructuredLogFormatter.class); + Assert.isTrue(this.logEventType.equals(typeArgument), + () -> "Type argument of %s must be %s but was %s".formatted(formatter.getClass().getName(), + this.logEventType.getName(), (typeArgument != null) ? typeArgument.getName() : "null")); + + } + + /** + * Callback used for configure the {@link CommonFormatterFactory} to use for a given + * {@link CommonStructuredLogFormat}. + * + * @param the log event type + */ + public static class CommonFormatters { + + private final Map> factories = new TreeMap<>(); + + /** + * Add the factory that should be used for the given + * {@link CommonStructuredLogFormat}. + * @param format the common structured log format + * @param factory the factory to use + */ + public void add(CommonStructuredLogFormat format, CommonFormatterFactory factory) { + this.factories.put(format, factory); + } + + Collection getCommonNames() { + return this.factories.keySet().stream().map(CommonStructuredLogFormat::getId).toList(); + } + + StructuredLogFormatter get(Instantiator> instantiator, String format) { + CommonStructuredLogFormat commonFormat = CommonStructuredLogFormat.forId(format); + CommonFormatterFactory factory = (commonFormat != null) ? this.factories.get(commonFormat) : null; + return (factory != null) ? factory.createFormatter(instantiator) : null; + } + + } + + /** + * Factory used to create a {@link StructuredLogFormatter} for a given + * {@link CommonStructuredLogFormat}. + * + * @param the log event type + */ + @FunctionalInterface + public interface CommonFormatterFactory { + + /** + * Create the {@link StructuredLogFormatter} instance. + * @param instantiator instantiator that can be used to obtain arguments + * @return a new {@link StructuredLogFormatter} instance + */ + StructuredLogFormatter createFormatter(Instantiator> instantiator); + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/package-info.java new file mode 100644 index 0000000000..44f0655b3a --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for structured logging. + */ +package org.springframework.boot.logging.structured; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java index 05eb183cc6..0e9530aa57 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -125,6 +125,29 @@ public class Instantiator { return instantiate(names.stream().map((name) -> TypeSupplier.forName(classLoader, name))); } + /** + * Instantiate the given set of class name, injecting constructor arguments as + * necessary. + * @param name the class name to instantiate + * @return an instantiated instance + * @since 3.4.0 + */ + public T instantiate(String name) { + return instantiate((ClassLoader) null, name); + } + + /** + * Instantiate the given set of class name, injecting constructor arguments as + * necessary. + * @param classLoader the source classloader + * @param name the class name to instantiate + * @return an instantiated instance + * @since 3.4.0 + */ + public T instantiate(ClassLoader classLoader, String name) { + return instantiate(TypeSupplier.forName(classLoader, name)); + } + /** * Instantiate the given set of classes, injecting constructor arguments as necessary. * @param types the types to instantiate @@ -136,6 +159,22 @@ public class Instantiator { return instantiate(types.stream().map(TypeSupplier::forType)); } + /** + * Get an injectable argument instance for the given type. This method can be used + * when manually instantiating an object without reflection. + * @param the argument type + * @param type the argument type + * @return the argument to inject or {@code null} + * @since 3.4.0 + */ + @SuppressWarnings("unchecked") + public A getArg(Class type) { + Assert.notNull(type, "'type' must not be null"); + Function, Object> parameter = getAvailableParameter(type); + Assert.isTrue(parameter != null, "Unknown argument type " + type.getName()); + return (A) parameter.apply(this.type); + } + private List instantiate(Stream typeSuppliers) { return typeSuppliers.map(this::instantiate).sorted(AnnotationAwareOrderComparator.INSTANCE).toList(); } @@ -242,7 +281,7 @@ public class Instantiator { } @Override - public Class get() throws ClassNotFoundException { + public Class get() { return type; } diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 424434744f..27eebf2c81 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -223,6 +223,16 @@ "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "defaultValue": true }, + { + "name": "logging.structured.format.console", + "type": "java.lang.String", + "description": "Structured logging format for output to the console. Must be either a format id or a fully qualified class name." + }, + { + "name": "logging.structured.format.file", + "type": "java.lang.String", + "description": "Structured logging format for output to a file. Must be either a format id or a fully qualified class name." + }, { "name": "logging.threshold.console", "type": "java.lang.String", diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml index 580eae3e6a..f6a0e81b33 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml @@ -9,13 +9,27 @@ - + - + diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml index 21cf1450b4..a994ab4389 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml @@ -9,7 +9,14 @@ - + diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml index edffb0289e..9eb15888c1 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml @@ -15,9 +15,11 @@ Default logback configuration provided for import + + diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-console-appender.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-console-appender.xml new file mode 100644 index 0000000000..0ae182c221 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-console-appender.xml @@ -0,0 +1,20 @@ + + + + + + + + ${CONSOLE_LOG_THRESHOLD} + + + ${CONSOLE_LOG_STRUCTURED_FORMAT} + ${CONSOLE_LOG_CHARSET} + ${PID:--1} + ${APPLICATION_NAME:-} + + + diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-file-appender.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-file-appender.xml new file mode 100644 index 0000000000..012523438c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/structured-file-appender.xml @@ -0,0 +1,28 @@ + + + + + + + + ${FILE_LOG_THRESHOLD} + + + ${FILE_LOG_STRUCTURED_FORMAT} + ${FILE_LOG_CHARSET} + ${PID:--1} + ${APPLICATION_NAME:-} + + ${LOG_FILE} + + ${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz} + ${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false} + ${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB} + ${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0} + ${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7} + + + diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java index 477caad399..7b3763799f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java @@ -197,6 +197,22 @@ class LoggingSystemPropertiesTests { .isEqualTo("OFF"); } + @Test + void shouldSetFileStructuredLogging() { + new LoggingSystemProperties(new MockEnvironment().withProperty("logging.structured.format.file", "ecs")) + .apply(null); + assertThat(System.getProperty(LoggingSystemProperty.FILE_STRUCTURED_FORMAT.getEnvironmentVariableName())) + .isEqualTo("ecs"); + } + + @Test + void shouldSetConsoleStructuredLogging() { + new LoggingSystemProperties(new MockEnvironment().withProperty("logging.structured.format.console", "ecs")) + .apply(null); + assertThat(System.getProperty(LoggingSystemProperty.CONSOLE_STRUCTURED_FORMAT.getEnvironmentVariableName())) + .isEqualTo("ecs"); + } + private Environment environment(String key, Object value) { StandardEnvironment environment = new StandardEnvironment(); environment.getPropertySources().addLast(new MapPropertySource("test", Collections.singletonMap(key, value))); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/AbstractStructuredLoggingTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/AbstractStructuredLoggingTests.java new file mode 100644 index 0000000000..9ad5ad794e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/AbstractStructuredLoggingTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.apache.logging.log4j.message.SimpleMessage; +import org.assertj.core.api.Assertions; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Abstract base class for structured formatting tests. + * + * @author Moritz Halbritter + */ +abstract class AbstractStructuredLoggingTests { + + static final Instant EVENT_TIME = Instant.ofEpochMilli(1719910193000L); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + protected Map map(Object... values) { + assertThat(values.length).isEven(); + Map result = new HashMap<>(); + for (int i = 0; i < values.length; i += 2) { + result.put(values[i].toString(), values[i + 1]); + } + return result; + } + + protected static MutableLogEvent createEvent() { + MutableLogEvent event = new MutableLogEvent(); + event.setTimeMillis(EVENT_TIME.toEpochMilli()); + event.setLevel(Level.INFO); + event.setThreadName("main"); + event.setLoggerName("org.example.Test"); + event.setMessage(new SimpleMessage("message")); + return event; + } + + protected Map deserialize(String json) { + try { + return OBJECT_MAPPER.readValue(json, new TypeReference<>() { + }); + } + catch (JsonProcessingException ex) { + Assertions.fail("Failed to deserialize JSON: " + json, ex); + return null; + } + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2EcsStructuredLoggingFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2EcsStructuredLoggingFormatterTests.java new file mode 100644 index 0000000000..577e602222 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2EcsStructuredLoggingFormatterTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.util.Map; + +import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; +import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.logging.structured.ApplicationMetadata; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ElasticCommonSchemaStructuredLogFormatter}. + * + * @author Moritz Halbritter + */ +class Log4j2EcsStructuredLoggingFormatterTests extends AbstractStructuredLoggingTests { + + private ElasticCommonSchemaStructuredLogFormatter formatter; + + @BeforeEach + void setUp() { + this.formatter = new ElasticCommonSchemaStructuredLogFormatter( + new ApplicationMetadata(1L, "name", "1.0.0", "test", "node-1")); + } + + @Test + void shouldFormat() { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("mdc-1", "mdc-v-1"), true)); + 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")); + } + + @Test + 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.Log4j2EcsStructuredLoggingFormatterTests.shouldFormatException"""); + assertThat(json).contains( + """ + java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.Log4j2EcsStructuredLoggingFormatterTests.shouldFormatException"""); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2LogstashStructuredLoggingFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2LogstashStructuredLoggingFormatterTests.java new file mode 100644 index 0000000000..f03e6d2a8f --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2LogstashStructuredLoggingFormatterTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.MarkerManager.Log4jMarker; +import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; +import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LogstashStructuredLogFormatter}. + * + * @author Moritz Halbritter + */ +class Log4j2LogstashStructuredLoggingFormatterTests extends AbstractStructuredLoggingTests { + + private LogstashStructuredLogFormatter formatter; + + @BeforeEach + void setUp() { + this.formatter = new LogstashStructuredLogFormatter(); + } + + @Test + void shouldFormat() { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("mdc-1", "mdc-v-1"), true)); + Log4jMarker marker1 = new Log4jMarker("marker-1"); + marker1.addParents(new Log4jMarker("marker-2")); + event.setMarker(marker1); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + String timestamp = DateTimeFormatter.ISO_OFFSET_DATE_TIME + .format(OffsetDateTime.ofInstant(EVENT_TIME, ZoneId.systemDefault())); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", timestamp, "@version", "1", + "message", "message", "logger_name", "org.example.Test", "thread_name", "main", "level", "INFO", + "level_value", 400, "mdc-1", "mdc-v-1", "tags", List.of("marker-1", "marker-2"))); + } + + @Test + void shouldFormatException() { + MutableLogEvent event = createEvent(); + event.setThrown(new RuntimeException("Boom")); + String json = this.formatter.format(event); + Map deserialized = deserialize(json); + String stackTrace = (String) deserialized.get("stack_trace"); + assertThat(stackTrace).startsWith( + """ + java.lang.RuntimeException: Boom + \tat org.springframework.boot.logging.log4j2.Log4j2LogstashStructuredLoggingFormatterTests.shouldFormatException"""); + assertThat(json).contains( + """ + java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.Log4j2LogstashStructuredLoggingFormatterTests.shouldFormatException"""); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java new file mode 100644 index 0000000000..d7d3b5bc12 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.util.Map; + +import org.apache.logging.log4j.core.LogEvent; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link StructuredLogLayout}. + * + * @author Moritz Halbritter + */ +class StructuredLoggingLayoutTests extends AbstractStructuredLoggingTests { + + @Test + void shouldSupportEcsCommonFormat() { + StructuredLogLayout layout = StructuredLogLayout.newBuilder().setFormat("ecs").build(); + String json = layout.toSerializable(createEvent()); + Map deserialized = deserialize(json); + assertThat(deserialized).containsKey("ecs.version"); + } + + @Test + void shouldSupportLogstashCommonFormat() { + StructuredLogLayout layout = StructuredLogLayout.newBuilder().setFormat("logstash").build(); + String json = layout.toSerializable(createEvent()); + Map deserialized = deserialize(json); + assertThat(deserialized).containsKey("@version"); + } + + @Test + void shouldSupportCustomFormat() { + StructuredLogLayout layout = StructuredLogLayout.newBuilder() + .setFormat(CustomLog4j2StructuredLoggingFormatter.class.getName()) + .build(); + String format = layout.toSerializable(createEvent()); + assertThat(format).isEqualTo("custom-format"); + } + + @Test + void shouldInjectCustomFormatConstructorParameters() { + StructuredLogLayout layout = StructuredLogLayout.newBuilder() + .setFormat(CustomLog4j2StructuredLoggingFormatterWithInjection.class.getName()) + .setPid(1L) + .build(); + String format = layout.toSerializable(createEvent()); + assertThat(format).isEqualTo("custom-format-with-injection pid=1"); + } + + @Test + void shouldCheckTypeArgument() { + assertThatIllegalArgumentException() + .isThrownBy(() -> StructuredLogLayout.newBuilder() + .setFormat(CustomLog4j2StructuredLoggingFormatterWrongType.class.getName()) + .build()) + .withMessageContaining("must be org.apache.logging.log4j.core.LogEvent but was java.lang.String"); + } + + @Test + void shouldCheckTypeArgumentWithRawType() { + assertThatIllegalArgumentException() + .isThrownBy(() -> StructuredLogLayout.newBuilder() + .setFormat(CustomLog4j2StructuredLoggingFormatterRawType.class.getName()) + .build()) + .withMessageContaining("must be org.apache.logging.log4j.core.LogEvent but was null"); + } + + @Test + void shouldFailIfNoCommonOrCustomFormatIsSet() { + assertThatIllegalArgumentException() + .isThrownBy(() -> StructuredLogLayout.newBuilder().setFormat("does-not-exist").build()) + .withMessageContaining("Unknown format 'does-not-exist'. " + + "Values can be a valid fully-qualified class name or one of the common formats: [ecs, logstash]"); + } + + static final class CustomLog4j2StructuredLoggingFormatter implements StructuredLogFormatter { + + @Override + public String format(LogEvent event) { + return "custom-format"; + } + + } + + static final class CustomLog4j2StructuredLoggingFormatterWithInjection implements StructuredLogFormatter { + + private final ApplicationMetadata metadata; + + CustomLog4j2StructuredLoggingFormatterWithInjection(ApplicationMetadata metadata) { + this.metadata = metadata; + } + + @Override + public String format(LogEvent event) { + return "custom-format-with-injection pid=" + this.metadata.pid(); + } + + } + + static final class CustomLog4j2StructuredLoggingFormatterWrongType implements StructuredLogFormatter { + + @Override + public String format(String event) { + return event; + } + + } + + @SuppressWarnings("rawtypes") + static final class CustomLog4j2StructuredLoggingFormatterRawType implements StructuredLogFormatter { + + @Override + public String format(Object event) { + return ""; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/AbstractStructuredLoggingTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/AbstractStructuredLoggingTests.java new file mode 100644 index 0000000000..010ed62243 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/AbstractStructuredLoggingTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.LoggingEvent; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; +import org.slf4j.helpers.BasicMarkerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Abstract base class for structured formatting tests. + * + * @author Moritz Halbritter + */ +abstract class AbstractStructuredLoggingTests { + + static final Instant EVENT_TIME = Instant.ofEpochSecond(1719910193L); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ThrowableProxyConverter throwableProxyConverter; + + private BasicMarkerFactory markerFactory; + + @BeforeEach + void setUp() { + this.markerFactory = new BasicMarkerFactory(); + this.throwableProxyConverter = new ThrowableProxyConverter(); + this.throwableProxyConverter.start(); + } + + @AfterEach + void tearDown() { + this.throwableProxyConverter.stop(); + } + + protected Marker getMarker(String name) { + return this.markerFactory.getDetachedMarker(name); + } + + protected ThrowableProxyConverter getThrowableProxyConverter() { + return this.throwableProxyConverter; + } + + protected Map map(Object... values) { + assertThat(values.length).isEven(); + Map result = new HashMap<>(); + for (int i = 0; i < values.length; i += 2) { + result.put(values[i].toString(), values[i + 1]); + } + return result; + } + + protected List keyValuePairs(Object... values) { + assertThat(values.length).isEven(); + List result = new ArrayList<>(); + for (int i = 0; i < values.length; i += 2) { + result.add(new KeyValuePair(values[i].toString(), values[i + 1])); + } + return result; + } + + protected static LoggingEvent createEvent() { + LoggingEvent event = new LoggingEvent(); + event.setInstant(EVENT_TIME); + event.setLevel(Level.INFO); + event.setThreadName("main"); + event.setLoggerName("org.example.Test"); + event.setMessage("message"); + return event; + } + + protected Map deserialize(String json) { + try { + return OBJECT_MAPPER.readValue(json, new TypeReference<>() { + }); + } + catch (JsonProcessingException ex) { + Assertions.fail("Failed to deserialize JSON: " + json, ex); + return null; + } + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackEcsStructuredLoggingFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackEcsStructuredLoggingFormatterTests.java new file mode 100644 index 0000000000..eb96faf766 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackEcsStructuredLoggingFormatterTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.util.Collections; +import java.util.Map; + +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.logging.structured.ApplicationMetadata; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ElasticCommonSchemaStructuredLogFormatter}. + * + * @author Moritz Halbritter + */ +class LogbackEcsStructuredLoggingFormatterTests extends AbstractStructuredLoggingTests { + + private ElasticCommonSchemaStructuredLogFormatter formatter; + + @Override + @BeforeEach + void setUp() { + super.setUp(); + this.formatter = new ElasticCommonSchemaStructuredLogFormatter( + new ApplicationMetadata(1L, "name", "1.0.0", "test", "node-1"), getThrowableProxyConverter()); + } + + @Test + void shouldFormat() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("mdc-1", "mdc-v-1")); + event.setKeyValuePairs(keyValuePairs("kv-1", "kv-v-1")); + 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")); + } + + @Test + 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 + \tat org.springframework.boot.logging.logback.LogbackEcsStructuredLoggingFormatterTests.shouldFormatException"""); + assertThat(json).contains( + """ + java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.logback.LogbackEcsStructuredLoggingFormatterTests.shouldFormatException"""); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java index d9ebfafd44..d614940a21 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java @@ -558,6 +558,8 @@ class LogbackLoggingSystemTests extends AbstractLoggingSystemTests { .removeAll(Arrays.asList("LOG_FILE", "LOG_PATH", "LOGGED_APPLICATION_NAME", "LOGGED_APPLICATION_GROUP")); expectedProperties.add("org.jboss.logging.provider"); expectedProperties.add("LOG_CORRELATION_PATTERN"); + expectedProperties.add("CONSOLE_LOG_STRUCTURED_FORMAT"); + expectedProperties.add("FILE_LOG_STRUCTURED_FORMAT"); assertThat(properties).containsOnlyKeys(expectedProperties); assertThat(properties).containsEntry("CONSOLE_LOG_CHARSET", Charset.defaultCharset().name()); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLogstashStructuredLoggingFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLogstashStructuredLoggingFormatterTests.java new file mode 100644 index 0000000000..df55d6744b --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLogstashStructuredLoggingFormatterTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Marker; + +import org.springframework.util.StopWatch; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LogstashStructuredLogFormatter}. + * + * @author Moritz Halbritter + */ +class LogbackLogstashStructuredLoggingFormatterTests extends AbstractStructuredLoggingTests { + + private LogstashStructuredLogFormatter formatter; + + @Override + @BeforeEach + void setUp() { + super.setUp(); + this.formatter = new LogstashStructuredLogFormatter(getThrowableProxyConverter()); + } + + @Test + void shouldFormat() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("mdc-1", "mdc-v-1")); + event.setKeyValuePairs(keyValuePairs("kv-1", "kv-v-1")); + Marker marker1 = getMarker("marker-1"); + marker1.add(getMarker("marker-2")); + event.addMarker(marker1); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + String timestamp = DateTimeFormatter.ISO_OFFSET_DATE_TIME + .format(OffsetDateTime.ofInstant(EVENT_TIME, ZoneId.systemDefault())); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("@timestamp", timestamp, "@version", "1", + "message", "message", "logger_name", "org.example.Test", "thread_name", "main", "level", "INFO", + "level_value", 20000, "mdc-1", "mdc-v-1", "kv-1", "kv-v-1", "tags", List.of("marker-1", "marker-2"))); + } + + @Test + void shouldFormatException() { + LoggingEvent event = createEvent(); + event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom"))); + event.setMDCPropertyMap(Collections.emptyMap()); + String json = this.formatter.format(event); + Map deserialized = deserialize(json); + String stackTrace = (String) deserialized.get("stack_trace"); + assertThat(stackTrace).startsWith( + """ + java.lang.RuntimeException: Boom + \tat org.springframework.boot.logging.logback.LogbackLogstashStructuredLoggingFormatterTests.shouldFormatException"""); + assertThat(json).contains( + """ + java.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.logback.LogbackLogstashStructuredLoggingFormatterTests.shouldFormatException"""); + } + + @Test + void benchmark() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("mdc-1", "mdc-v-1")); + event.setKeyValuePairs(keyValuePairs("kv-1", "kv-v-1")); + Marker marker1 = getMarker("marker-1"); + marker1.add(getMarker("marker-2")); + event.addMarker(marker1); + System.out.println(this.formatter.format(event)); + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + for (int i = 0; i < 1000000; i++) { + this.formatter.format(event); + } + stopWatch.stop(); + System.out.println(stopWatch); + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLoggingEncoderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLoggingEncoderTests.java new file mode 100644 index 0000000000..65c3df4b97 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLoggingEncoderTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggingEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link StructuredLogEncoder}. + * + * @author Moritz Halbritter + */ +class StructuredLoggingEncoderTests extends AbstractStructuredLoggingTests { + + private StructuredLogEncoder encoder; + + @Override + @BeforeEach + void setUp() { + super.setUp(); + this.encoder = new StructuredLogEncoder(); + } + + @Override + @AfterEach + void tearDown() { + super.tearDown(); + this.encoder.stop(); + } + + @Test + void shouldSupportEcsCommonFormat() { + this.encoder.setFormat("ecs"); + this.encoder.start(); + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Collections.emptyMap()); + String json = encode(event); + Map deserialized = deserialize(json); + assertThat(deserialized).containsKey("ecs.version"); + } + + @Test + void shouldSupportLogstashCommonFormat() { + this.encoder.setFormat("logstash"); + this.encoder.start(); + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Collections.emptyMap()); + String json = encode(event); + Map deserialized = deserialize(json); + assertThat(deserialized).containsKey("@version"); + } + + @Test + void shouldSupportCustomFormat() { + this.encoder.setFormat(CustomLogbackStructuredLoggingFormatter.class.getName()); + this.encoder.start(); + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Collections.emptyMap()); + String format = encode(event); + assertThat(format).isEqualTo("custom-format"); + } + + @Test + void shouldInjectCustomFormatConstructorParameters() { + this.encoder.setFormat(CustomLogbackStructuredLoggingFormatterWithInjection.class.getName()); + this.encoder.setPid(1L); + this.encoder.start(); + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Collections.emptyMap()); + String format = encode(event); + assertThat(format).isEqualTo("custom-format-with-injection pid=1 hasThrowableProxyConverter=true"); + } + + @Test + void shouldCheckTypeArgument() { + assertThatIllegalArgumentException().isThrownBy(() -> { + this.encoder.setFormat(CustomLogbackStructuredLoggingFormatterWrongType.class.getName()); + this.encoder.start(); + }).withMessageContaining("must be ch.qos.logback.classic.spi.ILoggingEvent but was java.lang.String"); + } + + @Test + void shouldCheckTypeArgumentWithRawType() { + assertThatIllegalArgumentException().isThrownBy(() -> { + this.encoder.setFormat(CustomLogbackStructuredLoggingFormatterRawType.class.getName()); + this.encoder.start(); + }).withMessageContaining("must be ch.qos.logback.classic.spi.ILoggingEvent but was null"); + } + + @Test + void shouldFailIfNoCommonOrCustomFormatIsSet() { + assertThatIllegalArgumentException().isThrownBy(() -> { + this.encoder.setFormat("does-not-exist"); + this.encoder.start(); + }) + .withMessageContaining( + "Unknown format 'does-not-exist'. Values can be a valid fully-qualified class name or one of the common formats: [ecs, logstash]"); + } + + private String encode(LoggingEvent event) { + return new String(this.encoder.encode(event), StandardCharsets.UTF_8); + } + + static final class CustomLogbackStructuredLoggingFormatter implements StructuredLogFormatter { + + @Override + public String format(ILoggingEvent event) { + return "custom-format"; + } + + } + + static final class CustomLogbackStructuredLoggingFormatterWithInjection + implements StructuredLogFormatter { + + private final ApplicationMetadata metadata; + + private final ThrowableProxyConverter throwableProxyConverter; + + CustomLogbackStructuredLoggingFormatterWithInjection(ApplicationMetadata metadata, + ThrowableProxyConverter throwableProxyConverter) { + this.metadata = metadata; + this.throwableProxyConverter = throwableProxyConverter; + } + + @Override + public String format(ILoggingEvent event) { + boolean hasThrowableProxyConverter = this.throwableProxyConverter != null; + return "custom-format-with-injection pid=" + this.metadata.pid() + " hasThrowableProxyConverter=" + + hasThrowableProxyConverter; + } + + } + + static final class CustomLogbackStructuredLoggingFormatterWrongType implements StructuredLogFormatter { + + @Override + public String format(String event) { + return event; + } + + } + + @SuppressWarnings("rawtypes") + static final class CustomLogbackStructuredLoggingFormatterRawType implements StructuredLogFormatter { + + @Override + public String format(Object event) { + return ""; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/CommonStructuredLogFormatTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/CommonStructuredLogFormatTests.java new file mode 100644 index 0000000000..c76d4d7dbb --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/CommonStructuredLogFormatTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CommonStructuredLogFormat}. + * + * @author Phillip Webb + */ +class CommonStructuredLogFormatTests { + + @Test + void forIdReturnsCommonStructuredLogFormat() { + assertThat(CommonStructuredLogFormat.forId("ecs")).isEqualTo(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA); + assertThat(CommonStructuredLogFormat.forId("logstash")).isEqualTo(CommonStructuredLogFormat.LOGSTASH); + } + + @Test + void forIdWhenIdIsInDifferentCaseReturnsCommonStructuredLogFormat() { + assertThat(CommonStructuredLogFormat.forId("ECS")).isEqualTo(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA); + assertThat(CommonStructuredLogFormat.forId("logSTAsh")).isEqualTo(CommonStructuredLogFormat.LOGSTASH); + } + + @Test + void forIdWhenNotKnownReturnsNull() { + assertThat(CommonStructuredLogFormat.forId("madeup")).isNull(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactoryTests.java new file mode 100644 index 0000000000..c830a744ca --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLogFormatterFactoryTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.logging.structured.StructuredLogFormatterFactory.CommonFormatters; +import org.springframework.boot.util.Instantiator.AvailableParameters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link StructuredLogFormatterFactory}. + * + * @author Phillip Webb + */ +class StructuredLogFormatterFactoryTests { + + private final ApplicationMetadata applicationMetadata; + + private final StructuredLogFormatterFactory factory; + + StructuredLogFormatterFactoryTests() { + this.applicationMetadata = new ApplicationMetadata(123L, "test", "1.2", null, null); + this.factory = new StructuredLogFormatterFactory<>(LogEvent.class, this.applicationMetadata, + this::addAvailableParameters, this::addCommonFormatters); + } + + private void addAvailableParameters(AvailableParameters availableParameters) { + availableParameters.add(StringBuilder.class, new StringBuilder("Hello")); + } + + private void addCommonFormatters(CommonFormatters commonFormatters) { + commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, + (instantiator) -> new TestEcsFormatter(instantiator.getArg(ApplicationMetadata.class), + instantiator.getArg(StringBuilder.class))); + } + + @Test + void getUsingCommonFormat() { + assertThat(this.factory.get("ecs")).isInstanceOf(TestEcsFormatter.class); + } + + @Test + void getUsingClassName() { + assertThat(this.factory.get(ExtendedTestEcsFormatter.class.getName())) + .isInstanceOf(ExtendedTestEcsFormatter.class); + } + + @Test + void getUsingClassNameWhenNoSuchClass() { + assertThatIllegalArgumentException() + .isThrownBy(() -> assertThat(this.factory.get("com.example.WeMadeItUp")).isNull()) + .withMessage("Unknown format 'com.example.WeMadeItUp'. " + + "Values can be a valid fully-qualified class name or one of the common formats: [ecs]"); + } + + @Test + void getUsingClassNameWhenHasGenericMismatch() { + assertThatIllegalArgumentException().isThrownBy(() -> this.factory.get(DifferentFormatter.class.getName())) + .withMessage("Type argument of org.springframework.boot.logging.structured." + + "StructuredLogFormatterFactoryTests$DifferentFormatter " + + "must be org.springframework.boot.logging.structured." + + "StructuredLogFormatterFactoryTests$LogEvent " + + "but was org.springframework.boot.logging.structured." + + "StructuredLogFormatterFactoryTests$DifferentLogEvent"); + } + + @Test + void getUsingClassNameInjectsApplicationMetadata() { + TestEcsFormatter formatter = (TestEcsFormatter) this.factory.get(TestEcsFormatter.class.getName()); + assertThat(formatter.getMetadata()).isSameAs(this.applicationMetadata); + } + + @Test + void getUsingClassNameInjectsCustomParameter() { + TestEcsFormatter formatter = (TestEcsFormatter) this.factory.get(TestEcsFormatter.class.getName()); + assertThat(formatter.getCustom()).hasToString("Hello"); + } + + static class LogEvent { + + } + + static class DifferentLogEvent { + + } + + static class TestEcsFormatter implements StructuredLogFormatter { + + private ApplicationMetadata metadata; + + private StringBuilder custom; + + TestEcsFormatter(ApplicationMetadata metadata, StringBuilder custom) { + this.metadata = metadata; + this.custom = custom; + } + + @Override + public String format(LogEvent event) { + return "formatted " + this.metadata.version(); + } + + ApplicationMetadata getMetadata() { + return this.metadata; + } + + StringBuilder getCustom() { + return this.custom; + } + + } + + static class ExtendedTestEcsFormatter extends TestEcsFormatter { + + ExtendedTestEcsFormatter(ApplicationMetadata metadata, StringBuilder custom) { + super(metadata, custom); + } + + } + + static class DifferentFormatter implements StructuredLogFormatter { + + @Override + public String format(DifferentLogEvent event) { + return ""; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/InstantiatorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/InstantiatorTests.java index bb208ff4c9..dba42d456c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/InstantiatorTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/InstantiatorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.boot.util; +import java.io.InputStream; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -61,7 +62,6 @@ class InstantiatorTests { void instantiateWhenAdditionalConstructorPicksMostSuitable() { WithAdditionalConstructor instance = createInstance(WithAdditionalConstructor.class); assertThat(instance).isInstanceOf(WithAdditionalConstructor.class); - } @Test @@ -122,8 +122,30 @@ class InstantiatorTests { .withMessageContaining("custom failure handler message"); } + @Test + void instantiateWithSingleNameCreatesInstance() { + WithDefaultConstructor instance = createInstantiator(WithDefaultConstructor.class) + .instantiate(WithDefaultConstructor.class.getName()); + assertThat(instance).isInstanceOf(WithDefaultConstructor.class); + } + + @Test + void getArgReturnsArg() { + Instantiator instantiator = createInstantiator(WithMultipleConstructors.class); + assertThat(instantiator.getArg(ParamA.class)).isSameAs(this.paramA); + assertThat(instantiator.getArg(ParamB.class)).isSameAs(this.paramB); + assertThat(instantiator.getArg(ParamC.class)).isInstanceOf(ParamC.class); + } + + @Test + void getArgWhenUnknownThrowsException() { + Instantiator instantiator = createInstantiator(WithMultipleConstructors.class); + assertThatIllegalArgumentException().isThrownBy(() -> instantiator.getArg(InputStream.class)) + .withMessageStartingWith("Unknown argument type"); + } + private T createInstance(Class type) { - return createInstantiator(type).instantiate(Collections.singleton(type.getName())).get(0); + return createInstantiator(type).instantiate(type.getName()); } private Instantiator createInstantiator(Class type) { diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/build.gradle new file mode 100644 index 0000000000..b7e1a90ba7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/build.gradle @@ -0,0 +1,17 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot structure logging Log4j2 smoke test" + +configurations.all { + exclude module: "spring-boot-starter-logging" +} + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-log4j2")) + + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/CustomStructuredLogFormatter.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/CustomStructuredLogFormatter.java new file mode 100644 index 0000000000..8fc790b9e7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/CustomStructuredLogFormatter.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging.log4j2; + +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.ThrowableProxy; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +public class CustomStructuredLogFormatter implements StructuredLogFormatter { + + private final ApplicationMetadata metadata; + + public CustomStructuredLogFormatter(ApplicationMetadata metadata) { + this.metadata = metadata; + } + + @Override + public String format(LogEvent event) { + StringBuilder result = new StringBuilder(); + result.append("epoch=").append(event.getInstant().getEpochMillisecond()); + if (this.metadata.pid() != null) { + result.append(" pid=").append(this.metadata.pid()); + } + result.append(" msg=\"").append(event.getMessage().getFormattedMessage()).append('"'); + ThrowableProxy throwable = event.getThrownProxy(); + if (throwable != null) { + result.append(" error=\"").append(throwable.getExtendedStackTraceAsString()).append('"'); + } + result.append('\n'); + return result.toString(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplication.java new file mode 100644 index 0000000000..544685ddd3 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging.log4j2; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleLog4j2StructuredLoggingApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleLog4j2StructuredLoggingApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/resources/application.properties new file mode 100644 index 0000000000..f0ec153c0c --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/main/resources/application.properties @@ -0,0 +1,5 @@ +spring.main.banner-mode=off +logging.structured.format.console=ecs +#--- +spring.config.activate.on-profile=custom +logging.structured.format.console=smoketest.structuredlogging.log4j2.CustomStructuredLogFormatter diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/test/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/test/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplicationTests.java new file mode 100644 index 0000000000..d8a99e4e9e --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging-log4j2/src/test/java/smoketest/structuredlogging/log4j2/SampleLog4j2StructuredLoggingApplicationTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging.log4j2; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.boot.logging.LoggingSystemProperty; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SampleLog4j2StructuredLoggingApplication}. + * + * @author Phillip Webb + */ +@ExtendWith(OutputCaptureExtension.class) +class SampleLog4j2StructuredLoggingApplicationTests { + + @AfterEach + void reset() { + LoggingSystem.get(getClass().getClassLoader()).cleanUp(); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } + } + + @Test + void json(CapturedOutput output) { + SampleLog4j2StructuredLoggingApplication.main(new String[0]); + assertThat(output).contains("{\"@timestamp\"") + .contains("\"message\":\"Starting SampleLog4j2StructuredLoggingApplication"); + } + + @Test + void custom(CapturedOutput output) { + SampleLog4j2StructuredLoggingApplication.main(new String[] { "--spring.profiles.active=custom" }); + assertThat(output).contains("epoch=").contains("msg=\"Starting SampleLog4j2StructuredLoggingApplication"); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/build.gradle new file mode 100644 index 0000000000..cbaf8f99c2 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/build.gradle @@ -0,0 +1,12 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot structure logging smoke test" + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/CustomStructuredLogFormatter.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/CustomStructuredLogFormatter.java new file mode 100644 index 0000000000..0d25407fdf --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/CustomStructuredLogFormatter.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; + +import org.springframework.boot.logging.structured.ApplicationMetadata; +import org.springframework.boot.logging.structured.StructuredLogFormatter; + +public class CustomStructuredLogFormatter implements StructuredLogFormatter { + + private final ThrowableProxyConverter throwableProxyConverter; + + private final ApplicationMetadata metadata; + + public CustomStructuredLogFormatter(ApplicationMetadata metadata, ThrowableProxyConverter throwableProxyConverter) { + this.metadata = metadata; + this.throwableProxyConverter = throwableProxyConverter; + } + + @Override + public String format(ILoggingEvent event) { + StringBuilder result = new StringBuilder(); + result.append("epoch=").append(event.getInstant().toEpochMilli()); + if (this.metadata.pid() != null) { + result.append(" pid=").append(this.metadata.pid()); + } + result.append(" msg=\"").append(event.getFormattedMessage()).append('"'); + IThrowableProxy throwable = event.getThrowableProxy(); + if (throwable != null) { + result.append(" error=\"").append(this.throwableProxyConverter.convert(event)).append('"'); + } + result.append('\n'); + return result.toString(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleStructuredLoggingApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleStructuredLoggingApplication.java new file mode 100644 index 0000000000..4fdaf1d3e3 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleStructuredLoggingApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleStructuredLoggingApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleStructuredLoggingApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/resources/application.properties new file mode 100644 index 0000000000..b04549b907 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/resources/application.properties @@ -0,0 +1,5 @@ +spring.main.banner-mode=off +logging.structured.format.console=ecs +#--- +spring.config.activate.on-profile=custom +logging.structured.format.console=smoketest.structuredlogging.CustomStructuredLogFormatter diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java new file mode 100644 index 0000000000..0d77b17625 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/test/java/smoketest/structuredlogging/SampleStructuredLoggingApplicationTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.structuredlogging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.boot.logging.LoggingSystemProperty; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SampleStructuredLoggingApplication}. + * + * @author Phillip Webb + */ +@ExtendWith(OutputCaptureExtension.class) +class SampleStructuredLoggingApplicationTests { + + @AfterEach + void reset() { + LoggingSystem.get(getClass().getClassLoader()).cleanUp(); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } + } + + @Test + void json(CapturedOutput output) { + SampleStructuredLoggingApplication.main(new String[0]); + assertThat(output).contains("{\"@timestamp\"") + .contains("\"message\":\"Starting SampleStructuredLoggingApplication"); + } + + @Test + void custom(CapturedOutput output) { + SampleStructuredLoggingApplication.main(new String[] { "--spring.profiles.active=custom" }); + assertThat(output).contains("epoch=").contains("msg=\"Starting SampleStructuredLoggingApplication"); + } + +}