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
This commit is contained in:
Phillip Webb
2024-10-09 19:12:58 -07:00
parent 27c59b8cb5
commit 8aee3e1e92
31 changed files with 673 additions and 51 deletions

View File

@@ -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).

View File

@@ -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;

View File

@@ -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<LogEvent> {
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<LogEvent> members) {

View File

@@ -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<String> 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<LogEvent> members) {

View File

@@ -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<LogEvent> {
LogstashStructuredLogFormatter() {
super(LogstashStructuredLogFormatter::jsonMembers);
LogstashStructuredLogFormatter(StructureLoggingJsonMembersCustomizer<?> customizer) {
super(LogstashStructuredLogFormatter::jsonMembers, customizer);
}
private static void jsonMembers(JsonWriter.Members<LogEvent> members) {

View File

@@ -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<LogEvent> 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);
}
}

View File

@@ -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<KeyValuePair> 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,

View File

@@ -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<String> 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,

View File

@@ -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<IL
private static final PairExtractor<KeyValuePair> 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,

View File

@@ -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<ILoggingEvent> {
commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, this::createLogstashFormatter);
}
private StructuredLogFormatter<ILoggingEvent> createEcsFormatter(
Instantiator<StructuredLogFormatter<ILoggingEvent>> instantiator) {
private StructuredLogFormatter<ILoggingEvent> 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<ILoggingEvent> createGraylogFormatter(
Instantiator<StructuredLogFormatter<ILoggingEvent>> instantiator) {
private StructuredLogFormatter<ILoggingEvent> 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<ILoggingEvent> createLogstashFormatter(
Instantiator<StructuredLogFormatter<ILoggingEvent>> instantiator) {
private StructuredLogFormatter<ILoggingEvent> 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

View File

@@ -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<E> 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<E>> members) {
this(JsonWriter.of(members).withNewLineAtEnd());
protected JsonWriterStructuredLogFormatter(Consumer<Members<E>> members,
StructureLoggingJsonMembersCustomizer<?> customizer) {
this(JsonWriter.of(customized(members, customizer)).withNewLineAtEnd());
}
private static <E> Consumer<Members<E>> customized(Consumer<Members<E>> members,
StructureLoggingJsonMembersCustomizer<?> customizer) {
return (customizer != null) ? members.andThen(customizeWith(customizer)) : members;
}
@SuppressWarnings("unchecked")
private static <E> Consumer<Members<E>> customizeWith(StructureLoggingJsonMembersCustomizer<?> customizer) {
return (members) -> LambdaSafe.callback(StructureLoggingJsonMembersCustomizer.class, customizer, members)
.invoke((instance) -> instance.customize(members));
}
/**

View File

@@ -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}.
* <p>
* An implementation may be provided using the {@code logging.structured.json.customizer}
* property.
*
* @param <T> the type being written
* @author Phillip Webb
* @since 3.4.0
* @see JsonWriterStructuredLogFormatter
*/
@FunctionalInterface
public interface StructureLoggingJsonMembersCustomizer<T> {
/**
* Customize the given {@link Members} instance.
* @param members the members instance to customize
*/
void customize(JsonWriter.Members<T> members);
}

View File

@@ -28,6 +28,7 @@ import org.springframework.core.env.Environment;
* Implementing classes can declare the following parameter types in the constructor:
* <ul>
* <li>{@link Environment}</li>
* <li>{@link StructureLoggingJsonMembersCustomizer}</li>
* </ul>
* When using Logback, implementing classes can also use the following parameter types in
* the constructor:

View File

@@ -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<E> {
}
};
private final SpringFactoriesLoader factoriesLoader;
private final Class<E> logEventType;
private final Instantiator<StructuredLogFormatter<E>> instantiator;
private final Instantiator<?> instantiator;
private final CommonFormatters<E> commonFormatters;
@@ -64,9 +72,18 @@ public class StructuredLogFormatterFactory<E> {
*/
public StructuredLogFormatterFactory(Class<E> logEventType, Environment environment,
Consumer<AvailableParameters> availableParameters, Consumer<CommonFormatters<E>> commonFormatters) {
this(SpringFactoriesLoader.forDefaultResourceLocation(), logEventType, environment, availableParameters,
commonFormatters);
}
StructuredLogFormatterFactory(SpringFactoriesLoader factoriesLoader, Class<E> logEventType, Environment environment,
Consumer<AvailableParameters> availableParameters, Consumer<CommonFormatters<E>> 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<E> {
commonFormatters.accept(this.commonFormatters);
}
StructureLoggingJsonMembersCustomizer<?> getStructureLoggingJsonMembersCustomizer(Environment environment) {
List<StructureLoggingJsonMembersCustomizer<?>> 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<StructureLoggingJsonMembersCustomizer<?>> loadStructureLoggingJsonMembersCustomizers() {
return (List) this.factoriesLoader.load(StructureLoggingJsonMembersCustomizer.class,
ArgumentResolver.from(this.instantiator::getArg));
}
@SuppressWarnings("unchecked")
private void invokeCustomizers(List<StructureLoggingJsonMembersCustomizer<?>> customizers,
Members<Object> 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<E> {
.formatted(format, this.commonFormatters.getCommonNames()));
}
@SuppressWarnings("unchecked")
private StructuredLogFormatter<E> getUsingClassName(String className) {
StructuredLogFormatter<E> 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<E>) formatter;
}
private void checkTypeArgument(Object formatter) {
@@ -134,7 +177,7 @@ public class StructuredLogFormatterFactory<E> {
return this.factories.keySet().stream().map(CommonStructuredLogFormat::getId).toList();
}
StructuredLogFormatter<E> get(Instantiator<StructuredLogFormatter<E>> instantiator, String format) {
StructuredLogFormatter<E> get(Instantiator<?> instantiator, String format) {
CommonStructuredLogFormat commonFormat = CommonStructuredLogFormat.forId(format);
CommonFormatterFactory<E> factory = (commonFormat != null) ? this.factories.get(commonFormat) : null;
return (factory != null) ? factory.createFormatter(instantiator) : null;
@@ -156,7 +199,7 @@ public class StructuredLogFormatterFactory<E> {
* @param instantiator instantiator that can be used to obtain arguments
* @return a new {@link StructuredLogFormatter} instance
*/
StructuredLogFormatter<E> createFormatter(Instantiator<StructuredLogFormatter<E>> instantiator);
StructuredLogFormatter<E> createFormatter(Instantiator<?> instantiator);
}

View File

@@ -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<String> include, Set<String> exclude, Map<String, String> rename,
Map<String, String> add, String customizer) {
static StructuredLoggingJsonProperties get(Environment environment) {
return Binder.get(environment)
.bind("logging.structured.json", StructuredLoggingJsonProperties.class)
.orElse(null);
}
}

View File

@@ -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<Object> {
private final Instantiator<?> instantiator;
private final StructuredLoggingJsonProperties properties;
StructuredLoggingJsonPropertiesJsonMembersCustomizer(Instantiator<?> instantiator,
StructuredLoggingJsonProperties properties) {
this.instantiator = instantiator;
this.properties = properties;
}
@Override
public void customize(Members<Object> members) {
members.applyingPathFilter(this::filterPath);
members.applyingNameProcessor(this::renameJsonMembers);
Map<String, String> 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<String, String> 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<Object> members, String customizerClassName) {
((StructureLoggingJsonMembersCustomizer) this.instantiator.instantiate(customizerClassName)).customize(members);
}
}

View File

@@ -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<java.lang.String,java.lang.String>",
"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<java.lang.String>",
"description": "Member paths that should be excluded from structured logging JSON"
},
{
"name": "logging.structured.json.include",
"type": "java.util.Set<java.lang.String>",
"description": "Member paths that should be included in structured logging JSON"
},
{
"name": "logging.structured.json.rename",
"type": "java.util.Map<java.lang.String,java.lang.String>",
"description": "Mapping between member paths and an alternative name that should be used in structured logging JSON"
},
{
"name": "logging.threshold.console",

View File

@@ -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<String, Object> map(Object... values) {
assertThat(values.length).isEven();
Map<String, Object> result = new HashMap<>();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<LogEvent> 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<LogEvent> {
CutomizedFormatter(StructureLoggingJsonMembersCustomizer<?> customizer) {
super((members) -> members.add("spring", "boot"), customizer);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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<Object> {
@Override
public void customize(Members<Object> members) {
members.applyingValueProcessor(
ValueProcessor.of(String.class, "!!%s!!"::formatted).whenHasUnescapedPath("process.thread.name"));
}
}

View File

@@ -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

View File

@@ -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