From 8aee3e1e9288b0d0c44c9641aa4dd521a1c91291 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 9 Oct 2024 19:12:58 -0700 Subject: [PATCH] Allow structure logging JSON to be customized Introduce a new `StructureLoggingJsonMembersCustomizer` interface as well as additional properties that can be used to customize the JSON produced with structured logging. Closes gh-42486 --- .../reference/pages/features/logging.adoc | 46 ++++++- .../MyCustomFormat.java | 2 +- ...ticCommonSchemaStructuredLogFormatter.java | 6 +- ...tendedLogFormatStructuredLogFormatter.java | 6 +- .../LogstashStructuredLogFormatter.java | 5 +- .../logging/log4j2/StructuredLogLayout.java | 33 +++-- ...ticCommonSchemaStructuredLogFormatter.java | 7 +- ...tendedLogFormatStructuredLogFormatter.java | 5 +- .../LogstashStructuredLogFormatter.java | 6 +- .../logging/logback/StructuredLogEncoder.java | 24 ++-- .../JsonWriterStructuredLogFormatter.java | 18 ++- ...StructureLoggingJsonMembersCustomizer.java | 43 +++++++ .../structured/StructuredLogFormatter.java | 1 + .../StructuredLogFormatterFactory.java | 55 +++++++- .../StructuredLoggingJsonProperties.java | 45 +++++++ ...ngJsonPropertiesJsonMembersCustomizer.java | 78 ++++++++++++ ...itional-spring-configuration-metadata.json | 24 +++- .../AbstractStructuredLoggingTests.java | 9 ++ ...mmonSchemaStructuredLogFormatterTests.java | 9 +- ...dLogFormatStructuredLogFormatterTests.java | 9 +- .../LogstashStructuredLogFormatterTests.java | 9 +- .../AbstractStructuredLoggingTests.java | 9 ++ ...mmonSchemaStructuredLogFormatterTests.java | 10 +- ...dLogFormatStructuredLogFormatterTests.java | 10 +- .../LogstashStructuredLogFormatterTests.java | 9 +- .../StructuredLogFormatterFactoryTests.java | 29 +++++ ...nPropertiesJsonMembersCustomizerTests.java | 120 ++++++++++++++++++ .../StructuredLoggingJsonPropertiesTests.java | 54 ++++++++ .../SampleJsonMembersCustomizer.java | 31 +++++ .../src/main/resources/application.properties | 5 + ...mpleStructuredLoggingApplicationTests.java | 7 +- 31 files changed, 673 insertions(+), 51 deletions(-) rename spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/{customformat => otherformats}/MyCustomFormat.java (98%) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructureLoggingJsonMembersCustomizer.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonProperties.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizer.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizerTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesTests.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleJsonMembersCustomizer.java 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 1e246edab6..dd6904006a 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 @@ -572,8 +572,50 @@ If you add https://www.slf4j.org/api/org/slf4j/Marker.html[markers], these will -[[features.logging.structured.custom-format]] -=== Custom Structured Logging formats +[[features.logging.structured.customizing-json]] +=== Customizing Structured Logging JSON + +Spring Boot attempts to pick sensible defaults for the JSON names and values output for structured logging. +Sometimes, however, you may want to make small adjustments to the JSON for your own needs. +For example, it's possible that you might want to change some of the names to match the expectations of your log ingestion system. +You might also want to filter out certain members since you don't find them useful. + +The following properties allow you to change the way that structured logging JSON is written: + +|=== +| Property | Description + +| configprop:logging.structured.json.include[] & configprop:logging.structured.json.exclude[] +| Filters specific paths from the JSON + +| configprop:logging.structured.json.rename[] +| Renames a specific member in the JSON + +| configprop:logging.structured.json.add[] +| Adds additional members to the JSON +|=== + +For example, the following will exclude `log.level`, rename `process.id` to `procid` and add a fixed `corpname` field: + +[configprops,yaml] +---- +logging: + structured: + json: + exclude: log.level + rename: + process.id: procid + add: + corpname: mycorp +---- + +TIP: For more advanced customizations, you can write your own class that implements the javadoc:org.springframework.boot.logging.structured.StructuredLogFormatter[] interface and declare it using the configprop:logging.structured.json.customizer[] property. +You can also declare implementations by listing them in a `META-INF/spring.factories` file. + + + +[[features.logging.structured.other-formats]] +=== Supporting Other 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). 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/otherformats/MyCustomFormat.java similarity index 98% rename from spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/customformat/MyCustomFormat.java rename to spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logging/structured/otherformats/MyCustomFormat.java index e80a189a6b..6419f20f11 100644 --- 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/otherformats/MyCustomFormat.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.docs.features.logging.structured.customformat; +package org.springframework.boot.docs.features.logging.structured.otherformats; import ch.qos.logback.classic.spi.ILoggingEvent; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java index 1c95af72aa..1d3f119159 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatter.java @@ -28,6 +28,7 @@ import org.springframework.boot.json.JsonWriter; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.core.env.Environment; import org.springframework.util.ObjectUtils; @@ -41,8 +42,9 @@ import org.springframework.util.ObjectUtils; */ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogFormatter { - ElasticCommonSchemaStructuredLogFormatter(Environment environment) { - super((members) -> jsonMembers(environment, members)); + ElasticCommonSchemaStructuredLogFormatter(Environment environment, + StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> jsonMembers(environment, members), customizer); } private static void jsonMembers(Environment environment, JsonWriter.Members members) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java index fffd027219..1da4e8115c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -37,6 +37,7 @@ import org.springframework.boot.json.WritableJson; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.GraylogExtendedLogFormatProperties; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.core.env.Environment; import org.springframework.core.log.LogMessage; @@ -68,8 +69,9 @@ class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructure */ private static final Set ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("id", "_id"); - GraylogExtendedLogFormatStructuredLogFormatter(Environment environment) { - super((members) -> jsonMembers(environment, members)); + GraylogExtendedLogFormatStructuredLogFormatter(Environment environment, + StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> jsonMembers(environment, members), customizer); } private static void jsonMembers(Environment environment, JsonWriter.Members members) { 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 index 65531a6995..2e7a34b930 100644 --- 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 @@ -32,6 +32,7 @@ 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.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.util.CollectionUtils; @@ -43,8 +44,8 @@ import org.springframework.util.CollectionUtils; */ class LogstashStructuredLogFormatter extends JsonWriterStructuredLogFormatter { - LogstashStructuredLogFormatter() { - super(LogstashStructuredLogFormatter::jsonMembers); + LogstashStructuredLogFormatter(StructureLoggingJsonMembersCustomizer customizer) { + super(LogstashStructuredLogFormatter::jsonMembers, customizer); } private static void jsonMembers(JsonWriter.Members members) { 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 index 5e4b49fbdf..25c8558aa7 100644 --- 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 @@ -30,9 +30,11 @@ import org.apache.logging.log4j.core.config.plugins.PluginLoggerContext; import org.apache.logging.log4j.core.layout.AbstractStringLayout; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; 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; import org.springframework.core.env.Environment; import org.springframework.util.Assert; @@ -102,14 +104,29 @@ final class StructuredLogLayout extends AbstractStringLayout { } private void addCommonFormatters(CommonFormatters commonFormatters) { - commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, - (instantiator) -> new ElasticCommonSchemaStructuredLogFormatter( - instantiator.getArg(Environment.class))); - commonFormatters.add(CommonStructuredLogFormat.GRAYLOG_EXTENDED_LOG_FORMAT, - (instantiator) -> new GraylogExtendedLogFormatStructuredLogFormatter( - instantiator.getArg(Environment.class))); - commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, - (instantiator) -> new LogstashStructuredLogFormatter()); + commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, this::createEcsFormatter); + commonFormatters.add(CommonStructuredLogFormat.GRAYLOG_EXTENDED_LOG_FORMAT, this::createGraylogFormatter); + commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, this::createLogstashFormatter); + } + + private ElasticCommonSchemaStructuredLogFormatter createEcsFormatter(Instantiator instantiator) { + Environment environment = instantiator.getArg(Environment.class); + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new ElasticCommonSchemaStructuredLogFormatter(environment, jsonMembersCustomizer); + } + + private GraylogExtendedLogFormatStructuredLogFormatter createGraylogFormatter(Instantiator instantiator) { + Environment environment = instantiator.getArg(Environment.class); + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new GraylogExtendedLogFormatStructuredLogFormatter(environment, jsonMembersCustomizer); + } + + private LogstashStructuredLogFormatter createLogstashFormatter(Instantiator instantiator) { + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new LogstashStructuredLogFormatter(jsonMembersCustomizer); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java index 527c2b66fa..58b7e5758e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatter.java @@ -28,6 +28,7 @@ import org.springframework.boot.json.JsonWriter.PairExtractor; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.ElasticCommonSchemaProperties; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.core.env.Environment; @@ -43,9 +44,9 @@ class ElasticCommonSchemaStructuredLogFormatter extends JsonWriterStructuredLogF private static final PairExtractor keyValuePairExtractor = PairExtractor.of((pair) -> pair.key, (pair) -> pair.value); - ElasticCommonSchemaStructuredLogFormatter(Environment environment, - ThrowableProxyConverter throwableProxyConverter) { - super((members) -> jsonMembers(environment, throwableProxyConverter, members)); + ElasticCommonSchemaStructuredLogFormatter(Environment environment, ThrowableProxyConverter throwableProxyConverter, + StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> jsonMembers(environment, throwableProxyConverter, members), customizer); } private static void jsonMembers(Environment environment, ThrowableProxyConverter throwableProxyConverter, diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java index 264f28fdeb..3273fc75a5 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -37,6 +37,7 @@ import org.springframework.boot.json.WritableJson; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; import org.springframework.boot.logging.structured.GraylogExtendedLogFormatProperties; import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.core.env.Environment; import org.springframework.core.log.LogMessage; @@ -69,8 +70,8 @@ class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructure private static final Set ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("id", "_id"); GraylogExtendedLogFormatStructuredLogFormatter(Environment environment, - ThrowableProxyConverter throwableProxyConverter) { - super((members) -> jsonMembers(environment, throwableProxyConverter, members)); + ThrowableProxyConverter throwableProxyConverter, StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> jsonMembers(environment, throwableProxyConverter, members), customizer); } private static void jsonMembers(Environment environment, ThrowableProxyConverter throwableProxyConverter, 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 index c99ae40a3a..fe2a2cb39d 100644 --- 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 @@ -35,6 +35,7 @@ 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.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; /** @@ -48,8 +49,9 @@ class LogstashStructuredLogFormatter extends JsonWriterStructuredLogFormatter keyValuePairExtractor = PairExtractor.of((pair) -> pair.key, (pair) -> pair.value); - LogstashStructuredLogFormatter(ThrowableProxyConverter throwableProxyConverter) { - super((members) -> jsonMembers(throwableProxyConverter, members)); + LogstashStructuredLogFormatter(ThrowableProxyConverter throwableProxyConverter, + StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> jsonMembers(throwableProxyConverter, members), customizer); } private static void jsonMembers(ThrowableProxyConverter throwableProxyConverter, 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 index 746251e1e0..f61d78a5bf 100644 --- 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 @@ -25,6 +25,7 @@ import ch.qos.logback.core.encoder.Encoder; import ch.qos.logback.core.encoder.EncoderBase; import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import org.springframework.boot.logging.structured.StructuredLogFormatter; import org.springframework.boot.logging.structured.StructuredLogFormatterFactory; import org.springframework.boot.logging.structured.StructuredLogFormatterFactory.CommonFormatters; @@ -85,24 +86,29 @@ public class StructuredLogEncoder extends EncoderBase { commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, this::createLogstashFormatter); } - private StructuredLogFormatter createEcsFormatter( - Instantiator> instantiator) { + private StructuredLogFormatter createEcsFormatter(Instantiator instantiator) { Environment environment = instantiator.getArg(Environment.class); ThrowableProxyConverter throwableProxyConverter = instantiator.getArg(ThrowableProxyConverter.class); - return new ElasticCommonSchemaStructuredLogFormatter(environment, throwableProxyConverter); + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new ElasticCommonSchemaStructuredLogFormatter(environment, throwableProxyConverter, + jsonMembersCustomizer); } - private StructuredLogFormatter createGraylogFormatter( - Instantiator> instantiator) { + private StructuredLogFormatter createGraylogFormatter(Instantiator instantiator) { Environment environment = instantiator.getArg(Environment.class); ThrowableProxyConverter throwableProxyConverter = instantiator.getArg(ThrowableProxyConverter.class); - return new GraylogExtendedLogFormatStructuredLogFormatter(environment, throwableProxyConverter); + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new GraylogExtendedLogFormatStructuredLogFormatter(environment, throwableProxyConverter, + jsonMembersCustomizer); } - private StructuredLogFormatter createLogstashFormatter( - Instantiator> instantiator) { + private StructuredLogFormatter createLogstashFormatter(Instantiator instantiator) { ThrowableProxyConverter throwableProxyConverter = instantiator.getArg(ThrowableProxyConverter.class); - return new LogstashStructuredLogFormatter(throwableProxyConverter); + StructureLoggingJsonMembersCustomizer jsonMembersCustomizer = instantiator + .getArg(StructureLoggingJsonMembersCustomizer.class); + return new LogstashStructuredLogFormatter(throwableProxyConverter, jsonMembersCustomizer); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/JsonWriterStructuredLogFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/JsonWriterStructuredLogFormatter.java index 64251161ff..1eb9e7a986 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/JsonWriterStructuredLogFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/JsonWriterStructuredLogFormatter.java @@ -21,6 +21,7 @@ import java.util.function.Consumer; import org.springframework.boot.json.JsonWriter; import org.springframework.boot.json.JsonWriter.Members; +import org.springframework.boot.util.LambdaSafe; /** * Base class for {@link StructuredLogFormatter} implementations that generates JSON using @@ -38,9 +39,22 @@ public abstract class JsonWriterStructuredLogFormatter implements StructuredL * Create a new {@link JsonWriterStructuredLogFormatter} instance with the given * members. * @param members a consumer, which should configure the members + * @param customizer an optional customizer to apply */ - protected JsonWriterStructuredLogFormatter(Consumer> members) { - this(JsonWriter.of(members).withNewLineAtEnd()); + protected JsonWriterStructuredLogFormatter(Consumer> members, + StructureLoggingJsonMembersCustomizer customizer) { + this(JsonWriter.of(customized(members, customizer)).withNewLineAtEnd()); + } + + private static Consumer> customized(Consumer> members, + StructureLoggingJsonMembersCustomizer customizer) { + return (customizer != null) ? members.andThen(customizeWith(customizer)) : members; + } + + @SuppressWarnings("unchecked") + private static Consumer> customizeWith(StructureLoggingJsonMembersCustomizer customizer) { + return (members) -> LambdaSafe.callback(StructureLoggingJsonMembersCustomizer.class, customizer, members) + .invoke((instance) -> instance.customize(members)); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructureLoggingJsonMembersCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructureLoggingJsonMembersCustomizer.java new file mode 100644 index 0000000000..bc1744580e --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructureLoggingJsonMembersCustomizer.java @@ -0,0 +1,43 @@ +/* + * 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.springframework.boot.json.JsonWriter; +import org.springframework.boot.json.JsonWriter.Members; + +/** + * Customer that can be injected into a {@link StructuredLogFormatter} implementations to + * customize {@link JsonWriter} {@link Members}. + *

+ * An implementation may be provided using the {@code logging.structured.json.customizer} + * property. + * + * @param the type being written + * @author Phillip Webb + * @since 3.4.0 + * @see JsonWriterStructuredLogFormatter + */ +@FunctionalInterface +public interface StructureLoggingJsonMembersCustomizer { + + /** + * Customize the given {@link Members} instance. + * @param members the members instance to customize + */ + void customize(JsonWriter.Members members); + +} 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 index c5bbdd5d0f..0bcd26e35f 100644 --- 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 @@ -28,6 +28,7 @@ import org.springframework.core.env.Environment; * Implementing classes can declare the following parameter types in the constructor: *

    *
  • {@link Environment}
  • + *
  • {@link StructureLoggingJsonMembersCustomizer}
  • *
* When using Logback, implementing classes can also use the following parameter types in * the constructor: 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 index 4958f6b988..d58c881e93 100644 --- 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 @@ -16,16 +16,22 @@ package org.springframework.boot.logging.structured; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.function.Consumer; +import org.springframework.boot.json.JsonWriter.Members; import org.springframework.boot.util.Instantiator; import org.springframework.boot.util.Instantiator.AvailableParameters; import org.springframework.boot.util.Instantiator.FailureHandler; +import org.springframework.boot.util.LambdaSafe; import org.springframework.core.GenericTypeResolver; import org.springframework.core.env.Environment; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver; import org.springframework.util.Assert; /** @@ -48,9 +54,11 @@ public class StructuredLogFormatterFactory { } }; + private final SpringFactoriesLoader factoriesLoader; + private final Class logEventType; - private final Instantiator> instantiator; + private final Instantiator instantiator; private final CommonFormatters commonFormatters; @@ -64,9 +72,18 @@ public class StructuredLogFormatterFactory { */ public StructuredLogFormatterFactory(Class logEventType, Environment environment, Consumer availableParameters, Consumer> commonFormatters) { + this(SpringFactoriesLoader.forDefaultResourceLocation(), logEventType, environment, availableParameters, + commonFormatters); + } + + StructuredLogFormatterFactory(SpringFactoriesLoader factoriesLoader, Class logEventType, Environment environment, + Consumer availableParameters, Consumer> commonFormatters) { + this.factoriesLoader = factoriesLoader; this.logEventType = logEventType; - this.instantiator = new Instantiator<>(StructuredLogFormatter.class, (allAvailableParameters) -> { + this.instantiator = new Instantiator<>(Object.class, (allAvailableParameters) -> { allAvailableParameters.add(Environment.class, environment); + allAvailableParameters.add(StructureLoggingJsonMembersCustomizer.class, + (type) -> getStructureLoggingJsonMembersCustomizer(environment)); if (availableParameters != null) { availableParameters.accept(allAvailableParameters); } @@ -75,6 +92,29 @@ public class StructuredLogFormatterFactory { commonFormatters.accept(this.commonFormatters); } + StructureLoggingJsonMembersCustomizer getStructureLoggingJsonMembersCustomizer(Environment environment) { + List> customizers = new ArrayList<>(); + StructuredLoggingJsonProperties properties = StructuredLoggingJsonProperties.get(environment); + if (properties != null) { + customizers.add(new StructuredLoggingJsonPropertiesJsonMembersCustomizer(this.instantiator, properties)); + } + customizers.addAll(loadStructureLoggingJsonMembersCustomizers()); + return (members) -> invokeCustomizers(customizers, members); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private List> loadStructureLoggingJsonMembersCustomizers() { + return (List) this.factoriesLoader.load(StructureLoggingJsonMembersCustomizer.class, + ArgumentResolver.from(this.instantiator::getArg)); + } + + @SuppressWarnings("unchecked") + private void invokeCustomizers(List> customizers, + Members members) { + LambdaSafe.callbacks(StructureLoggingJsonMembersCustomizer.class, customizers, members) + .invoke((customizer) -> customizer.customize(members)); + } + /** * Get a new {@link StructuredLogFormatter} instance for the specified format. * @param format the format requested (either a {@link CommonStructuredLogFormat} ID @@ -93,12 +133,15 @@ public class StructuredLogFormatterFactory { .formatted(format, this.commonFormatters.getCommonNames())); } + @SuppressWarnings("unchecked") private StructuredLogFormatter getUsingClassName(String className) { - StructuredLogFormatter formatter = this.instantiator.instantiate(className); + Object formatter = this.instantiator.instantiate(className); if (formatter != null) { + Assert.state(formatter instanceof StructuredLogFormatter, + () -> "'%s' is not a StructuredLogFormatter".formatted(className)); checkTypeArgument(formatter); } - return formatter; + return (StructuredLogFormatter) formatter; } private void checkTypeArgument(Object formatter) { @@ -134,7 +177,7 @@ public class StructuredLogFormatterFactory { return this.factories.keySet().stream().map(CommonStructuredLogFormat::getId).toList(); } - StructuredLogFormatter get(Instantiator> instantiator, String format) { + 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; @@ -156,7 +199,7 @@ public class StructuredLogFormatterFactory { * @param instantiator instantiator that can be used to obtain arguments * @return a new {@link StructuredLogFormatter} instance */ - StructuredLogFormatter createFormatter(Instantiator> instantiator); + StructuredLogFormatter createFormatter(Instantiator instantiator); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonProperties.java new file mode 100644 index 0000000000..89bbbb264c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonProperties.java @@ -0,0 +1,45 @@ +/* + * 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.Map; +import java.util.Set; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.Environment; + +/** + * Properties that can be used to customize structured logging JSON. + * + * @param include the paths that should be included. An empty set includes all names + * @param exclude the paths that should be excluded. An empty set excludes nothing + * @param rename a map of path to replacement names + * @param add a map of additional elements {@link StructureLoggingJsonMembersCustomizer} + * @param customizer the fully qualified name of a + * {@link StructureLoggingJsonMembersCustomizer} + * @author Phillip Webb + */ +record StructuredLoggingJsonProperties(Set include, Set exclude, Map rename, + Map add, String customizer) { + + static StructuredLoggingJsonProperties get(Environment environment) { + return Binder.get(environment) + .bind("logging.structured.json", StructuredLoggingJsonProperties.class) + .orElse(null); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizer.java new file mode 100644 index 0000000000..1cca440dc2 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizer.java @@ -0,0 +1,78 @@ +/* + * 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.Map; + +import org.springframework.boot.json.JsonWriter.MemberPath; +import org.springframework.boot.json.JsonWriter.Members; +import org.springframework.boot.util.Instantiator; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * {@link StructureLoggingJsonMembersCustomizer} to apply + * {@link StructuredLoggingJsonProperties}. + * + * @author Phillip Webb + */ +class StructuredLoggingJsonPropertiesJsonMembersCustomizer implements StructureLoggingJsonMembersCustomizer { + + private final Instantiator instantiator; + + private final StructuredLoggingJsonProperties properties; + + StructuredLoggingJsonPropertiesJsonMembersCustomizer(Instantiator instantiator, + StructuredLoggingJsonProperties properties) { + this.instantiator = instantiator; + this.properties = properties; + } + + @Override + public void customize(Members members) { + members.applyingPathFilter(this::filterPath); + members.applyingNameProcessor(this::renameJsonMembers); + Map add = this.properties.add(); + if (!CollectionUtils.isEmpty(add)) { + add.forEach(members::add); + } + String customizer = this.properties.customizer(); + if (StringUtils.hasLength(customizer)) { + createAndApplyCustomizer(members, customizer); + } + } + + String renameJsonMembers(MemberPath path, String existingName) { + Map rename = this.properties.rename(); + String key = path.toUnescapedString(); + return !CollectionUtils.isEmpty(rename) ? rename.getOrDefault(key, existingName) : existingName; + } + + boolean filterPath(MemberPath path) { + boolean included = CollectionUtils.isEmpty(this.properties.include()) + || this.properties.include().contains(path.toUnescapedString()); + boolean excluded = !CollectionUtils.isEmpty(this.properties.exclude()) + && this.properties.exclude().contains(path.toUnescapedString()); + return (!included || excluded); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void createAndApplyCustomizer(Members members, String customizerClassName) { + ((StructureLoggingJsonMembersCustomizer) this.instantiator.instantiate(customizerClassName)).customize(members); + } + +} 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 8c16e67645..ae72f7a7c5 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 @@ -265,9 +265,29 @@ "description": "Structured GELF service version (defaults to 'spring.application.version')." }, { - "name": "logging.structured.gelf.service.version", + "name": "logging.structured.json.add", + "type": "java.util.Map", + "description": "Additional members that should be added to structured logging JSON" + }, + { + "name": "logging.structured.json.customizer", "type": "java.lang.String", - "description": "Structured GELF service version (defaults to 'spring.application.version')." + "description": "The fully qualified class name of a StructureLoggingJsonMembersCustomizer" + }, + { + "name": "logging.structured.json.exclude", + "type": "java.util.Set", + "description": "Member paths that should be excluded from structured logging JSON" + }, + { + "name": "logging.structured.json.include", + "type": "java.util.Set", + "description": "Member paths that should be included in structured logging JSON" + }, + { + "name": "logging.structured.json.rename", + "type": "java.util.Map", + "description": "Mapping between member paths and an alternative name that should be used in structured logging JSON" }, { "name": "logging.threshold.console", 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 index 9ad5ad794e..354afe6f0a 100644 --- 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 @@ -27,6 +27,11 @@ 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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; import static org.assertj.core.api.Assertions.assertThat; @@ -35,12 +40,16 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Moritz Halbritter */ +@ExtendWith(MockitoExtension.class) abstract class AbstractStructuredLoggingTests { static final Instant EVENT_TIME = Instant.ofEpochMilli(1719910193000L); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Mock + StructureLoggingJsonMembersCustomizer customizer; + protected Map map(Object... values) { assertThat(values.length).isEven(); Map result = new HashMap<>(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java index f7e6a24c32..0ffb0a88e1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/ElasticCommonSchemaStructuredLogFormatterTests.java @@ -27,6 +27,8 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link ElasticCommonSchemaStructuredLogFormatter}. @@ -45,7 +47,12 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL environment.setProperty("logging.structured.ecs.service.environment", "test"); environment.setProperty("logging.structured.ecs.service.node-name", "node-1"); environment.setProperty("spring.application.pid", "1"); - this.formatter = new ElasticCommonSchemaStructuredLogFormatter(environment); + this.formatter = new ElasticCommonSchemaStructuredLogFormatter(environment, this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java index 3e2d4a5b0f..0cb2abf06f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -29,6 +29,8 @@ import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. @@ -47,7 +49,12 @@ class GraylogExtendedLogFormatStructuredLogFormatterTests extends AbstractStruct environment.setProperty("logging.structured.gelf.host", "name"); environment.setProperty("logging.structured.gelf.service.version", "1.0.0"); environment.setProperty("spring.application.pid", "1"); - this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment); + this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment, this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java index 595933ef92..61041aefe0 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/LogstashStructuredLogFormatterTests.java @@ -30,6 +30,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link LogstashStructuredLogFormatter}. @@ -42,7 +44,12 @@ class LogstashStructuredLogFormatterTests extends AbstractStructuredLoggingTests @BeforeEach void setUp() { - this.formatter = new LogstashStructuredLogFormatter(); + this.formatter = new LogstashStructuredLogFormatter(this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test 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 index 010ed62243..84d29ab1b3 100644 --- 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 @@ -31,10 +31,15 @@ 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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.Marker; import org.slf4j.event.KeyValuePair; import org.slf4j.helpers.BasicMarkerFactory; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -42,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Moritz Halbritter */ +@ExtendWith(MockitoExtension.class) abstract class AbstractStructuredLoggingTests { static final Instant EVENT_TIME = Instant.ofEpochSecond(1719910193L); @@ -52,6 +58,9 @@ abstract class AbstractStructuredLoggingTests { private BasicMarkerFactory markerFactory; + @Mock + StructureLoggingJsonMembersCustomizer customizer; + @BeforeEach void setUp() { this.markerFactory = new BasicMarkerFactory(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java index d59bed968f..6c2c355ccc 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/ElasticCommonSchemaStructuredLogFormatterTests.java @@ -27,6 +27,8 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link ElasticCommonSchemaStructuredLogFormatter}. @@ -47,7 +49,13 @@ class ElasticCommonSchemaStructuredLogFormatterTests extends AbstractStructuredL environment.setProperty("logging.structured.ecs.service.environment", "test"); environment.setProperty("logging.structured.ecs.service.node-name", "node-1"); environment.setProperty("spring.application.pid", "1"); - this.formatter = new ElasticCommonSchemaStructuredLogFormatter(environment, getThrowableProxyConverter()); + this.formatter = new ElasticCommonSchemaStructuredLogFormatter(environment, getThrowableProxyConverter(), + this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java index f68ea51125..e98986a30b 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -30,6 +30,8 @@ import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. @@ -50,7 +52,13 @@ class GraylogExtendedLogFormatStructuredLogFormatterTests extends AbstractStruct environment.setProperty("logging.structured.gelf.host", "name"); environment.setProperty("logging.structured.gelf.service.version", "1.0.0"); environment.setProperty("spring.application.pid", "1"); - this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment, getThrowableProxyConverter()); + this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment, getThrowableProxyConverter(), + this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatterTests.java index 0c9267be0a..8cdcf6948c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogstashStructuredLogFormatterTests.java @@ -30,6 +30,8 @@ import org.junit.jupiter.api.Test; import org.slf4j.Marker; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; /** * Tests for {@link LogstashStructuredLogFormatter}. @@ -44,7 +46,12 @@ class LogstashStructuredLogFormatterTests extends AbstractStructuredLoggingTests @BeforeEach void setUp() { super.setUp(); - this.formatter = new LogstashStructuredLogFormatter(getThrowableProxyConverter()); + this.formatter = new LogstashStructuredLogFormatter(getThrowableProxyConverter(), this.customizer); + } + + @Test + void callsCustomizer() { + then(this.customizer).should().customize(any()); } @Test 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 index edd923018f..5493f02b7e 100644 --- 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 @@ -16,15 +16,23 @@ package org.springframework.boot.logging.structured; +import java.util.List; + import org.junit.jupiter.api.Test; +import org.springframework.boot.json.JsonWriter.ValueProcessor; import org.springframework.boot.logging.structured.StructuredLogFormatterFactory.CommonFormatters; import org.springframework.boot.util.Instantiator.AvailableParameters; import org.springframework.core.env.Environment; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; /** * Tests for {@link StructuredLogFormatterFactory}. @@ -95,6 +103,19 @@ class StructuredLogFormatterFactoryTests { assertThat(formatter.getCustom()).hasToString("Hello"); } + @Test + void getInjectCustomizers() { + this.environment.setProperty("logging.structured.json.rename.spring", "test"); + SpringFactoriesLoader factoriesLoader = mock(SpringFactoriesLoader.class); + StructureLoggingJsonMembersCustomizer customizer = (members) -> members + .applyingValueProcessor(ValueProcessor.of(String.class, String::toUpperCase)); + given(factoriesLoader.load(any(), any(ArgumentResolver.class))).willReturn(List.of(customizer)); + StructuredLogFormatterFactory factory = new StructuredLogFormatterFactory<>(factoriesLoader, + LogEvent.class, this.environment, this::addAvailableParameters, this::addCommonFormatters); + CutomizedFormatter formatter = (CutomizedFormatter) factory.get(CutomizedFormatter.class.getName()); + assertThat(formatter.format(new LogEvent())).contains("\"test\":\"BOOT\""); + } + static class LogEvent { } @@ -146,4 +167,12 @@ class StructuredLogFormatterFactoryTests { } + static class CutomizedFormatter extends JsonWriterStructuredLogFormatter { + + CutomizedFormatter(StructureLoggingJsonMembersCustomizer customizer) { + super((members) -> members.add("spring", "boot"), customizer); + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizerTests.java new file mode 100644 index 0000000000..51993e537c --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesJsonMembersCustomizerTests.java @@ -0,0 +1,120 @@ +/* + * 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.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.json.JsonWriter.NameProcessor; +import org.springframework.boot.util.Instantiator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link StructuredLoggingJsonPropertiesJsonMembersCustomizer}. + * + * @author Phillip Webb + */ +@ExtendWith(MockitoExtension.class) +class StructuredLoggingJsonPropertiesJsonMembersCustomizerTests { + + @Mock + private Instantiator instantiator; + + @Test + void customizeWhenHasExcludeFiltersMember() { + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Collections.emptySet(), + Set.of("a"), Collections.emptyMap(), Collections.emptyMap(), null); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).doesNotContain("a").contains("b"); + } + + @Test + void customizeWhenHasIncludeFiltersOtherMembers() { + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Set.of("a"), + Collections.emptySet(), Collections.emptyMap(), Collections.emptyMap(), null); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).contains("a") + .doesNotContain("b") + .doesNotContain("c") + .doesNotContain("d"); + } + + @Test + void customizeWhenHasIncludeAndExcludeFiltersMembers() { + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Set.of("a", "b"), Set.of("b"), + Collections.emptyMap(), Collections.emptyMap(), null); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).contains("a") + .doesNotContain("b") + .doesNotContain("c") + .doesNotContain("d"); + } + + @Test + void customizeWhenHasRenameRenamesMember() { + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Collections.emptySet(), + Collections.emptySet(), Map.of("a", "z"), Collections.emptyMap(), null); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).contains("\"z\":\"a\""); + } + + @Test + void customizeWhenHasAddAddsMemeber() { + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Collections.emptySet(), + Collections.emptySet(), Collections.emptyMap(), Map.of("z", "z"), null); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).contains("\"z\":\"z\""); + } + + @Test + @SuppressWarnings("rawtypes") + void customizeWhenHasCustomizerCustomizesMember() { + StructureLoggingJsonMembersCustomizer uppercaseCustomizer = (members) -> members + .applyingNameProcessor(NameProcessor.of(String::toUpperCase)); + given(((Instantiator) this.instantiator).instantiate("test")).willReturn(uppercaseCustomizer); + StructuredLoggingJsonProperties properties = new StructuredLoggingJsonProperties(Collections.emptySet(), + Collections.emptySet(), Collections.emptyMap(), Collections.emptyMap(), "test"); + StructuredLoggingJsonPropertiesJsonMembersCustomizer customizer = new StructuredLoggingJsonPropertiesJsonMembersCustomizer( + this.instantiator, properties); + assertThat(writeSampleJson(customizer)).contains("\"A\":\"a\""); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private String writeSampleJson(StructureLoggingJsonMembersCustomizer customizer) { + return JsonWriter.of((members) -> { + members.add("a", "a"); + members.add("b", "b"); + members.add("c", "c"); + customizer.customize(members); + }).writeToString(new Object()); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesTests.java new file mode 100644 index 0000000000..94714fa125 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/StructuredLoggingJsonPropertiesTests.java @@ -0,0 +1,54 @@ +/* + * 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.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StructuredLoggingJsonProperties}. + * + * @author Phillip Webb + */ +class StructuredLoggingJsonPropertiesTests { + + @Test + void getBindsFromEnvironment() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("logging.structured.json.include", "a,b"); + environment.setProperty("logging.structured.json.exclude", "c,d"); + environment.setProperty("logging.structured.json.rename.e", "f"); + environment.setProperty("logging.structured.json.add.g", "h"); + environment.setProperty("logging.structured.json.customizer", "i"); + StructuredLoggingJsonProperties properties = StructuredLoggingJsonProperties.get(environment); + assertThat(properties).isEqualTo(new StructuredLoggingJsonProperties(Set.of("a", "b"), Set.of("c", "d"), + Map.of("e", "f"), Map.of("g", "h"), "i")); + } + + @Test + void getWhenNoBoundPropertiesReturnsNull() { + MockEnvironment environment = new MockEnvironment(); + StructuredLoggingJsonProperties.get(environment); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleJsonMembersCustomizer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleJsonMembersCustomizer.java new file mode 100644 index 0000000000..25f42b1bc8 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-structure-logging/src/main/java/smoketest/structuredlogging/SampleJsonMembersCustomizer.java @@ -0,0 +1,31 @@ +/* + * 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.json.JsonWriter.Members; +import org.springframework.boot.json.JsonWriter.ValueProcessor; +import org.springframework.boot.logging.structured.StructureLoggingJsonMembersCustomizer; + +public class SampleJsonMembersCustomizer implements StructureLoggingJsonMembersCustomizer { + + @Override + public void customize(Members members) { + members.applyingValueProcessor( + ValueProcessor.of(String.class, "!!%s!!"::formatted).whenHasUnescapedPath("process.thread.name")); + } + +} 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 index e591101031..4d8f17b7f9 100644 --- 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 @@ -1,4 +1,9 @@ +bar=hello logging.structured.format.console=ecs +logging.structured.json.exclude=@timestamp +logging.structured.json.rename[process.pid]=process.procid +logging.structured.json.add.foo=${bar} +logging.structured.json.customizer=smoketest.structuredlogging.SampleJsonMembersCustomizer #--- 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 index 4db380c25b..2eb7e2b2da 100644 --- 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 @@ -52,8 +52,11 @@ class SampleStructuredLoggingApplicationTests { @Test void json(CapturedOutput output) { SampleStructuredLoggingApplication.main(new String[0]); - assertThat(output).contains("{\"@timestamp\"") - .contains("\"message\":\"Starting SampleStructuredLoggingApplication"); + assertThat(output).doesNotContain("{\"@timestamp\"") + .contains("\"process.thread.name\":\"!!") + .contains("\"process.procid\"") + .contains("\"message\":\"Starting SampleStructuredLoggingApplication") + .contains("\"foo\":\"hello"); } @Test