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 2f60b42b27cc..5064dc295496 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 @@ -443,6 +443,7 @@ Structured logging is a technique where the log output is written in a well-defi Spring Boot supports structured logging and has support for the following JSON formats out of the box: * xref:#features.logging.structured.ecs[Elastic Common Schema (ECS)] +* xref:#features.logging.structured.gelf[Graylog Extended Log Format (GELF)] * xref:#features.logging.structured.logstash[Logstash] To enable structured logging, set the property configprop:logging.structured.format.console[] (for console output) or configprop:logging.structured.format.file[] (for file output) to the id of the format you want to use. @@ -490,6 +491,53 @@ logging: NOTE: configprop:logging.structured.ecs.service.name[] will default to configprop:spring.application.name[] if not specified. +NOTE: configprop:logging.structured.ecs.service.version[] will default to configprop:spring.application.version[] if not specified. + + + +[[features.logging.structured.gelf]] +=== Graylog Extended Log Format (GELF) +https://go2docs.graylog.org/current/getting_in_log_data/gelf.html[Graylog Extended Log Format] is a JSON based logging format for the Graylog log analytics platform. + +To enable the Graylog Extended Log Format, set the appropriate `format` property to `gelf`: + +[configprops,yaml] +---- +logging: + structured: + format: + console: gelf + file: gelf +---- + +A log line looks like this: + +[source,json] +---- +{"version":"1.1","short_message":"Hello structured logging!","timestamp":1.725530750186E9,"level":6,"_level_name":"INFO","_process_pid":9086,"_process_thread_name":"main","host":"spring-boot-gelf","_log_logger":"com.slissner.springbootgelf.ExampleLogger","_userId":"1","_testkey_testmessage":"test"} +---- + +This format also adds every key value pair contained in the MDC to the JSON object. +You can also use the https://www.slf4j.org/manual.html#fluent[SLF4J fluent logging API] to add key value pairs to the logged JSON object with the https://www.slf4j.org/apidocs/org/slf4j/spi/LoggingEventBuilder.html#addKeyValue(java.lang.String,java.lang.Object)[addKeyValue] method. + +The `service` values can be customized using `logging.structured.gelf.service` properties: + +[configprops,yaml] +---- +logging: + structured: + gelf: + service: + name: MyService + version: 1.0 + environment: Production + node-name: Primary +---- + +NOTE: configprop:logging.structured.gelf.service.name[] will default to configprop:spring.application.name[] if not specified. + +NOTE: configprop:logging.structured.gelf.service.version[] will default to configprop:spring.application.version[] if not specified. + [[features.logging.structured.logstash]] 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 new file mode 100644 index 000000000000..270de27f5cae --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.ThrowableProxy; +import org.apache.logging.log4j.core.net.Severity; +import org.apache.logging.log4j.core.time.Instant; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.util.ReadOnlyStringMap; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.GraylogExtendedLogFormatService; +import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.core.env.Environment; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Log4j2 {@link StructuredLogFormatter} for + * {@link CommonStructuredLogFormat#GRAYLOG_EXTENDED_LOG_FORMAT}. Supports GELF version + * 1.1. + * + * @author Samuel Lissner + */ +class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter { + + /** + * Allowed characters in field names are any word character (letter, number, + * underscore), dashes and dots. + */ + private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w\\.\\-]*$"); + + /** + * Every field been sent and prefixed with an underscore "_" will be treated as an + * additional field. + */ + private static final String ADDITIONAL_FIELD_PREFIX = "_"; + + /** + * Libraries SHOULD not allow to send id as additional field ("_id"). Graylog server + * nodes omit this field automatically. + */ + private static final Set ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("_id"); + + /** + * Default format to be used for the `full_message` property when there is a throwable + * present in the log event. + */ + private static final String DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT = "%s%n%n%s"; + + GraylogExtendedLogFormatStructuredLogFormatter(Environment environment) { + super((members) -> jsonMembers(environment, members)); + } + + private static void jsonMembers(Environment environment, JsonWriter.Members members) { + members.add("version", "1.1"); + + // note: a blank message will lead to a Graylog error as of Graylog v6.0.x. We are + // ignoring this here. + members.add("short_message", LogEvent::getMessage).as(Message::getFormattedMessage); + + members.add("timestamp", LogEvent::getInstant) + .as(GraylogExtendedLogFormatStructuredLogFormatter::formatTimeStamp); + members.add("level", GraylogExtendedLogFormatStructuredLogFormatter::convertLevel); + members.add("_level_name", LogEvent::getLevel).as(Level::name); + + members.add("_process_pid", environment.getProperty("spring.application.pid", Long.class)) + .when(Objects::nonNull); + members.add("_process_thread_name", LogEvent::getThreadName); + + GraylogExtendedLogFormatService.get(environment).jsonMembers(members); + + members.add("_log_logger", LogEvent::getLoggerName); + + members.from(LogEvent::getContextData) + .whenNot(ReadOnlyStringMap::isEmpty) + .usingPairs((contextData, pairs) -> contextData + .forEach((key, value) -> pairs.accept(makeAdditionalFieldName(key), value))); + + members.add().whenNotNull(LogEvent::getThrownProxy).usingMembers((eventMembers) -> { + final Function throwableProxyGetter = LogEvent::getThrownProxy; + + eventMembers.add("full_message", + GraylogExtendedLogFormatStructuredLogFormatter::formatFullMessageWithThrowable); + eventMembers.add("_error_type", throwableProxyGetter.andThen(ThrowableProxy::getThrowable)) + .whenNotNull() + .as(ObjectUtils::nullSafeClassName); + eventMembers.add("_error_stack_trace", + throwableProxyGetter.andThen(ThrowableProxy::getExtendedStackTraceAsString)); + eventMembers.add("_error_message", throwableProxyGetter.andThen(ThrowableProxy::getMessage)); + }); + } + + /** + * GELF requires "seconds since UNIX epoch with optional decimal places for + * milliseconds". To comply with this requirement, we format a POSIX timestamp + * with millisecond precision as e.g. "1725459730385" -> "1725459730.385" + * @param timeStamp the timestamp of the log message. Note it is not the standard Java + * `Instant` type but {@link org.apache.logging.log4j.core.time} + * @return the timestamp formatted as string with millisecond precision + */ + private static double formatTimeStamp(final Instant timeStamp) { + return new BigDecimal(timeStamp.getEpochMillisecond()).movePointLeft(3).doubleValue(); + } + + /** + * Converts the log4j2 event level to the Syslog event level code. + * @param event the log event + * @return an integer representing the syslog log level code + * @see Severity class from Log4j2 which contains the conversion logic + */ + private static int convertLevel(final LogEvent event) { + return Severity.getSeverity(event.getLevel()).getCode(); + } + + private static String formatFullMessageWithThrowable(final LogEvent event) { + return String.format(DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT, event.getMessage().getFormattedMessage(), + event.getThrownProxy().getExtendedStackTraceAsString()); + } + + private static String makeAdditionalFieldName(String fieldName) { + Assert.notNull(fieldName, "fieldName must not be null"); + Assert.isTrue(FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches(), + () -> String.format("fieldName must be a valid according to GELF standard. [fieldName=%s]", fieldName)); + Assert.isTrue(!ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName), () -> String.format( + "fieldName must not be an illegal additional field key according to GELF standard. [fieldName=%s]", + fieldName)); + + if (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) { + // No need to prepend the `ADDITIONAL_FIELD_PREFIX` in case the caller already + // has prepended the prefix. + return fieldName; + } + + return ADDITIONAL_FIELD_PREFIX + fieldName; + } + +} 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 bdb99a9fd8b7..5e4b49fbdf1f 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 @@ -105,6 +105,9 @@ 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()); } 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 new file mode 100644 index 000000000000..b9f1d1fe62bd --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -0,0 +1,160 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.util.LevelToSyslogSeverity; +import org.slf4j.event.KeyValuePair; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.json.JsonWriter.PairExtractor; +import org.springframework.boot.logging.structured.CommonStructuredLogFormat; +import org.springframework.boot.logging.structured.GraylogExtendedLogFormatService; +import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter; +import org.springframework.boot.logging.structured.StructuredLogFormatter; +import org.springframework.core.env.Environment; +import org.springframework.util.Assert; + +/** + * Logback {@link StructuredLogFormatter} for + * {@link CommonStructuredLogFormat#GRAYLOG_EXTENDED_LOG_FORMAT}. Supports GELF version + * 1.1. + * + * @author Samuel Lissner + */ +class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter { + + /** + * Allowed characters in field names are any word character (letter, number, + * underscore), dashes and dots. + */ + private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w\\.\\-]*$"); + + /** + * Every field been sent and prefixed with an underscore "_" will be treated as an + * additional field. + */ + private static final String ADDITIONAL_FIELD_PREFIX = "_"; + + /** + * Libraries SHOULD not allow to send id as additional field ("_id"). Graylog server + * nodes omit this field automatically. + */ + private static final Set ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("_id"); + + /** + * Default format to be used for the `full_message` property when there is a throwable + * present in the log event. + */ + private static final String DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT = "%s%n%n%s"; + + private static final PairExtractor keyValuePairExtractor = PairExtractor + .of((pair) -> makeAdditionalFieldName(pair.key), (pair) -> pair.value); + + GraylogExtendedLogFormatStructuredLogFormatter(Environment environment, + ThrowableProxyConverter throwableProxyConverter) { + super((members) -> jsonMembers(environment, throwableProxyConverter, members)); + } + + private static void jsonMembers(Environment environment, ThrowableProxyConverter throwableProxyConverter, + JsonWriter.Members members) { + members.add("version", "1.1"); + + // note: a blank message will lead to a Graylog error as of Graylog v6.0.x. We are + // ignoring this here. + members.add("short_message", ILoggingEvent::getFormattedMessage); + + members.add("timestamp", ILoggingEvent::getTimeStamp) + .as(GraylogExtendedLogFormatStructuredLogFormatter::formatTimeStamp); + members.add("level", LevelToSyslogSeverity::convert); + members.add("_level_name", ILoggingEvent::getLevel); + + members.add("_process_pid", environment.getProperty("spring.application.pid", Long.class)) + .when(Objects::nonNull); + members.add("_process_thread_name", ILoggingEvent::getThreadName); + + GraylogExtendedLogFormatService.get(environment).jsonMembers(members); + + members.add("_log_logger", ILoggingEvent::getLoggerName); + + members.addMapEntries(mapMDCProperties(ILoggingEvent::getMDCPropertyMap)); + + members.from(ILoggingEvent::getKeyValuePairs) + .whenNotEmpty() + .usingExtractedPairs(Iterable::forEach, keyValuePairExtractor); + + members.add().whenNotNull(ILoggingEvent::getThrowableProxy).usingMembers((throwableMembers) -> { + throwableMembers.add("full_message", + (event) -> formatFullMessageWithThrowable(throwableProxyConverter, event)); + throwableMembers.add("_error_type", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getClassName); + throwableMembers.add("_error_stack_trace", throwableProxyConverter::convert); + throwableMembers.add("_error_message", ILoggingEvent::getThrowableProxy).as(IThrowableProxy::getMessage); + }); + } + + /** + * GELF requires "seconds since UNIX epoch with optional decimal places for + * milliseconds". To comply with this requirement, we format a POSIX timestamp + * with millisecond precision as e.g. "1725459730385" -> "1725459730.385" + * @param timeStamp the timestamp of the log message + * @return the timestamp formatted as string with millisecond precision + */ + private static double formatTimeStamp(final long timeStamp) { + return new BigDecimal(timeStamp).movePointLeft(3).doubleValue(); + } + + private static String formatFullMessageWithThrowable(final ThrowableProxyConverter throwableProxyConverter, + ILoggingEvent event) { + return String.format(DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT, event.getFormattedMessage(), + throwableProxyConverter.convert(event)); + } + + private static Function> mapMDCProperties( + Function> MDCPropertyMapGetter) { + return MDCPropertyMapGetter.andThen((mdc) -> mdc.entrySet() + .stream() + .collect(Collectors.toMap((entry) -> makeAdditionalFieldName(entry.getKey()), Map.Entry::getValue))); + } + + private static String makeAdditionalFieldName(String fieldName) { + Assert.notNull(fieldName, "fieldName must not be null"); + Assert.isTrue(FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches(), + () -> String.format("fieldName must be a valid according to GELF standard. [fieldName=%s]", fieldName)); + Assert.isTrue(!ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName), () -> String.format( + "fieldName must not be an illegal additional field key according to GELF standard. [fieldName=%s]", + fieldName)); + + if (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) { + // No need to prepend the `ADDITIONAL_FIELD_PREFIX` in case the caller already + // has prepended the prefix. + return fieldName; + } + + return ADDITIONAL_FIELD_PREFIX + fieldName; + } + +} 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 b8f1d15cb69d..5b3c9d964e84 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 @@ -82,6 +82,11 @@ private void addCommonFormatters(CommonFormatters commonFormatter commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA, (instantiator) -> new ElasticCommonSchemaStructuredLogFormatter(instantiator.getArg(Environment.class), instantiator.getArg(ThrowableProxyConverter.class))); + commonFormatters + .add(CommonStructuredLogFormat.GRAYLOG_EXTENDED_LOG_FORMAT, + (instantiator) -> new GraylogExtendedLogFormatStructuredLogFormatter( + instantiator.getArg(Environment.class), + instantiator.getArg(ThrowableProxyConverter.class))); commonFormatters.add(CommonStructuredLogFormat.LOGSTASH, (instantiator) -> new LogstashStructuredLogFormatter( instantiator.getArg(ThrowableProxyConverter.class))); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java index e4a92ebd325b..acba34b7cd68 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/CommonStructuredLogFormat.java @@ -31,6 +31,12 @@ public enum CommonStructuredLogFormat { */ ELASTIC_COMMON_SCHEMA("ecs"), + /** + * Graylog + * Extended Log Format (GELF) log format. + */ + GRAYLOG_EXTENDED_LOG_FORMAT("gelf"), + /** * The Logstash diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatService.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatService.java new file mode 100644 index 000000000000..75216bb1adf7 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatService.java @@ -0,0 +1,72 @@ +/* + * 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.context.properties.bind.Binder; +import org.springframework.boot.json.JsonWriter; +import org.springframework.core.env.Environment; +import org.springframework.util.StringUtils; + +/** + * Service details for Graylog Extended Log Format structured logging. + * + * @param name the application name + * @param version the version of the application + * @param environment the name of the environment the application is running in + * @param nodeName the name of the node the application is running on + * @author Samuel Lissner + * @since 3.4.0 + */ +public record GraylogExtendedLogFormatService(String name, String version, String environment, String nodeName) { + + static final GraylogExtendedLogFormatService NONE = new GraylogExtendedLogFormatService(null, null, null, null); + + private GraylogExtendedLogFormatService withDefaults(Environment environment) { + String name = withFallbackProperty(environment, this.name, "spring.application.name"); + String version = withFallbackProperty(environment, this.version, "spring.application.version"); + return new GraylogExtendedLogFormatService(name, version, this.environment, this.nodeName); + } + + private String withFallbackProperty(Environment environment, String value, String property) { + return (!StringUtils.hasLength(value)) ? environment.getProperty(property) : value; + } + + /** + * Add {@link JsonWriter} members for the service. + * @param members the members to add to + */ + public void jsonMembers(JsonWriter.Members members) { + // note "host" is a field name prescribed by GELF + members.add("host", this::name).whenHasLength(); + members.add("_service_version", this::version).whenHasLength(); + members.add("_service_environment", this::environment).whenHasLength(); + members.add("_service_node_name", this::nodeName).whenHasLength(); + } + + /** + * Return a new {@link GraylogExtendedLogFormatService} from bound from properties in + * the given {@link Environment}. + * @param environment the source environment + * @return a new {@link GraylogExtendedLogFormatService} instance + */ + public static GraylogExtendedLogFormatService get(Environment environment) { + return Binder.get(environment) + .bind("logging.structured.gelf.service", GraylogExtendedLogFormatService.class) + .orElse(NONE) + .withDefaults(environment); + } +} 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 bc864ac75878..44c11cc162b5 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 @@ -242,7 +242,7 @@ { "name": "logging.structured.ecs.service.version", "type": "java.lang.String", - "description": "Structured ECS service version." + "description": "Structured ECS service version (defaults to 'spring.application.version')." }, { "name": "logging.structured.format.console", @@ -254,6 +254,26 @@ "type": "java.lang.String", "description": "Structured logging format for output to a file. Must be either a format id or a fully qualified class name." }, + { + "name": "logging.structured.gelf.service.environment", + "type": "java.lang.String", + "description": "Structured GELF service environment." + }, + { + "name": "logging.structured.gelf.service.name", + "type": "java.lang.String", + "description": "Structured GELF service name (defaults to 'spring.application.name')." + }, + { + "name": "logging.structured.gelf.service.node-name", + "type": "java.lang.String", + "description": "Structured GELF service node name." + }, + { + "name": "logging.structured.gelf.service.version", + "type": "java.lang.String", + "description": "Structured GELF service version (defaults to 'spring.application.version')." + }, { "name": "logging.threshold.console", "type": "java.lang.String", @@ -628,6 +648,9 @@ { "value": "ecs" }, + { + "value": "gelf" + }, { "value": "logstash" } @@ -647,6 +670,9 @@ { "value": "ecs" }, + { + "value": "gelf" + }, { "value": "logstash" } 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 new file mode 100644 index 000000000000..11f29846aca9 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.util.Map; + +import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; +import org.apache.logging.log4j.core.impl.MutableLogEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. + * + * @author Samuel Lissner + */ +class GraylogExtendedLogFormatStructuredLogFormatterTests extends AbstractStructuredLoggingTests { + + private GraylogExtendedLogFormatStructuredLogFormatter formatter; + + @BeforeEach + void setUp() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("logging.structured.gelf.service.name", "name"); + environment.setProperty("logging.structured.gelf.service.version", "1.0.0"); + environment.setProperty("logging.structured.gelf.service.environment", "test"); + environment.setProperty("logging.structured.gelf.service.node-name", "node-1"); + environment.setProperty("spring.application.pid", "1"); + this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment); + } + + @Test + void shouldFormat() { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("mdc-1", "mdc-v-1"), true)); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("version", "1.1", "host", "name", "timestamp", + 1719910193.000D, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_service_environment", "test", "_service_node_name", "node-1", + "_log_logger", "org.example.Test", "short_message", "message", "_mdc-1", "mdc-v-1")); + } + + @Test + void shouldFormatException() { + MutableLogEvent event = createEvent(); + event.setThrown(new RuntimeException("Boom")); + + String json = this.formatter.format(event); + Map deserialized = deserialize(json); + + String fullMessage = (String) deserialized.get("full_message"); + String stackTrace = (String) deserialized.get("_error_stack_trace"); + + assertThat(fullMessage).startsWith( + """ + message + + java.lang.RuntimeException: Boom + \tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException"""); + assertThat(stackTrace).startsWith( + """ + java.lang.RuntimeException: Boom + \tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException"""); + + assertThat(deserialized) + .containsAllEntriesOf(map("_error_type", "java.lang.RuntimeException", "_error_message", "Boom")); + assertThat(json).contains( + """ + message\\n\\njava.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException"""); + } + +} 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 new file mode 100644 index 000000000000..fe1df90cf9bc --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.logback; + +import java.util.Collections; +import java.util.Map; + +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. + * + * @author Samuel Lissner + */ +class GraylogExtendedLogFormatStructuredLogFormatterTests extends AbstractStructuredLoggingTests { + + private GraylogExtendedLogFormatStructuredLogFormatter formatter; + + @Override + @BeforeEach + void setUp() { + super.setUp(); + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("logging.structured.gelf.service.name", "name"); + environment.setProperty("logging.structured.gelf.service.version", "1.0.0"); + environment.setProperty("logging.structured.gelf.service.environment", "test"); + environment.setProperty("logging.structured.gelf.service.node-name", "node-1"); + environment.setProperty("spring.application.pid", "1"); + this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment, getThrowableProxyConverter()); + } + + @Test + void shouldFormat() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("mdc-1", "mdc-v-1")); + event.setKeyValuePairs(keyValuePairs("kv-1", "kv-v-1")); + String json = this.formatter.format(event); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("version", "1.1", "host", "name", "timestamp", + 1719910193.000D, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_service_environment", "test", "_service_node_name", "node-1", + "_log_logger", "org.example.Test", "short_message", "message", "_mdc-1", "mdc-v-1", "_kv-1", "kv-v-1")); + } + + @Test + void shouldFormatException() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Collections.emptyMap()); + event.setThrowableProxy(new ThrowableProxy(new RuntimeException("Boom"))); + + String json = this.formatter.format(event); + Map deserialized = deserialize(json); + String fullMessage = (String) deserialized.get("full_message"); + String stackTrace = (String) deserialized.get("_error_stack_trace"); + + assertThat(fullMessage).startsWith( + "message\n\njava.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException" + .formatted()); + + assertThat(deserialized) + .containsAllEntriesOf(map("_error_type", "java.lang.RuntimeException", "_error_message", "Boom")); + + assertThat(stackTrace).startsWith( + "java.lang.RuntimeException: Boom%n\tat org.springframework.boot.logging.logback.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException" + .formatted()); + assertThat(json).contains( + "java.lang.RuntimeException: Boom%n\\tat org.springframework.boot.logging.logback.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException" + .formatted() + .replace("\n", "\\n") + .replace("\r", "\\r")); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java index 3928f7e9521c..f24543c92352 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/StructuredLogEncoderTests.java @@ -134,7 +134,7 @@ void shouldFailIfNoCommonOrCustomFormatIsSet() { this.encoder.start(); }) .withMessageContaining( - "Unknown format 'does-not-exist'. Values can be a valid fully-qualified class name or one of the common formats: [ecs, logstash]"); + "Unknown format 'does-not-exist'. Values can be a valid fully-qualified class name or one of the common formats: [ecs, gelf, logstash]"); } private String encode(LoggingEvent event) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatServiceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatServiceTests.java new file mode 100644 index 000000000000..5dd7fa9e458f --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatServiceTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.structured; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraylogExtendedLogFormatService}. + * + * @author Samuel Lissner + */ +class GraylogExtendedLogFormatServiceTests { + + @Test + void getBindsFromEnvironment() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("logging.structured.gelf.service.name", "spring"); + environment.setProperty("logging.structured.gelf.service.version", "1.2.3"); + environment.setProperty("logging.structured.gelf.service.environment", "prod"); + environment.setProperty("logging.structured.gelf.service.node-name", "boot"); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService("spring", "1.2.3", "prod", "boot")); + } + + @Test + void getWhenNoServiceNameUsesApplicationName() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("spring.application.name", "spring"); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService("spring", null, null, null)); + } + + @Test + void getWhenNoServiceVersionUsesApplicationVersion() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("spring.application.version", "1.2.3"); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService(null, "1.2.3", null, null)); + } + + @Test + void getWhenNoPropertiesToBind() { + MockEnvironment environment = new MockEnvironment(); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService(null, null, null, null)); + } + + @Test + void addToJsonMembersCreatesValidJson() { + GraylogExtendedLogFormatService service = new GraylogExtendedLogFormatService("spring", "1.2.3", "prod", + "boot"); + JsonWriter writer = JsonWriter.of(service::jsonMembers); + assertThat(writer.writeToString(service)).isEqualTo("{\"host\":\"spring\",\"_service_version\":\"1.2.3\"," + + "\"_service_environment\":\"prod\",\"_service_node_name\":\"boot\"}"); + } + +}