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 f609f576291..cc3ae220d7f 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 @@ -445,6 +445,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. @@ -492,6 +493,51 @@ 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":"No active profile set, falling back to 1 default profile: \"default\"","timestamp":1725958035.857,"level":6,"_level_name":"INFO","_process_pid":47649,"_process_thread_name":"main","_log_logger":"org.example.Application"} +---- + +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 +---- + +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 00000000000..8a31b50b997 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -0,0 +1,149 @@ +/* + * 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.BiConsumer; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +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.json.JsonWriter.WritableJson; +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.core.log.LogMessage; +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 + * @author Moritz Halbritter + */ +class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter { + + private static final Log logger = LogFactory.getLog(GraylogExtendedLogFormatStructuredLogFormatter.class); + + /** + * 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", "_id"); + + 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) -> createAdditionalField(key, value, pairs))); + members.add().whenNotNull(LogEvent::getThrownProxy).usingMembers((eventMembers) -> { + eventMembers.add("full_message", + GraylogExtendedLogFormatStructuredLogFormatter::formatFullMessageWithThrowable); + eventMembers.add("_error_type", (event) -> event.getThrownProxy().getThrowable()) + .whenNotNull() + .as(ObjectUtils::nullSafeClassName); + eventMembers.add("_error_stack_trace", (event) -> event.getThrownProxy().getExtendedStackTraceAsString()); + eventMembers.add("_error_message", (event) -> event.getThrownProxy().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 WritableJson formatTimeStamp(Instant timeStamp) { + return (out) -> out.append(new BigDecimal(timeStamp.getEpochMillisecond()).movePointLeft(3).toPlainString()); + } + + /** + * 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(LogEvent event) { + return Severity.getSeverity(event.getLevel()).getCode(); + } + + private static String formatFullMessageWithThrowable(LogEvent event) { + return event.getMessage().getFormattedMessage() + "\n\n" + + event.getThrownProxy().getExtendedStackTraceAsString(); + } + + private static void createAdditionalField(String fieldName, Object value, BiConsumer pairs) { + Assert.notNull(fieldName, "fieldName must not be null"); + if (!FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches()) { + logger.warn(LogMessage.format("'%s' is not a valid field name according to GELF standard", fieldName)); + return; + } + if (ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName)) { + logger.warn(LogMessage.format("'%s' is an illegal field name according to GELF standard", fieldName)); + return; + } + String key = (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) ? fieldName : ADDITIONAL_FIELD_PREFIX + fieldName; + pairs.accept(key, value); + } + +} 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 bdb99a9fd8b..5e4b49fbdf1 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 @@ final class StructuredLogLayout extends AbstractStringLayout { 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 00000000000..4d9df6a8149 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java @@ -0,0 +1,139 @@ +/* + * 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.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; + +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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.json.JsonWriter; +import org.springframework.boot.json.JsonWriter.WritableJson; +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.core.log.LogMessage; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Logback {@link StructuredLogFormatter} for + * {@link CommonStructuredLogFormat#GRAYLOG_EXTENDED_LOG_FORMAT}. Supports GELF version + * 1.1. + * + * @author Samuel Lissner + * @author Moritz Halbritter + */ +class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter { + + private static final Log logger = LogFactory.getLog(GraylogExtendedLogFormatStructuredLogFormatter.class); + + /** + * 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", "_id"); + + 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.from(ILoggingEvent::getMDCPropertyMap) + .when((mdc) -> !CollectionUtils.isEmpty(mdc)) + .usingPairs((mdc, pairs) -> mdc.forEach((key, value) -> createAdditionalField(key, value, pairs))); + members.from(ILoggingEvent::getKeyValuePairs) + .when((keyValuePairs) -> !CollectionUtils.isEmpty(keyValuePairs)) + .usingPairs((keyValuePairs, pairs) -> keyValuePairs + .forEach((keyValuePair) -> createAdditionalField(keyValuePair.key, keyValuePair.value, pairs))); + 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 WritableJson formatTimeStamp(long timeStamp) { + return (out) -> out.append(new BigDecimal(timeStamp).movePointLeft(3).toPlainString()); + } + + private static String formatFullMessageWithThrowable(ThrowableProxyConverter throwableProxyConverter, + ILoggingEvent event) { + return event.getFormattedMessage() + "\n\n" + throwableProxyConverter.convert(event); + } + + private static void createAdditionalField(String key, Object value, BiConsumer pairs) { + Assert.notNull(key, "fieldName must not be null"); + if (!FIELD_NAME_VALID_PATTERN.matcher(key).matches()) { + logger.warn(LogMessage.format("'%s' is not a valid field name according to GELF standard", key)); + return; + } + if (ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(key)) { + logger.warn(LogMessage.format("'%s' is an illegal field name according to GELF standard", key)); + return; + } + String keyWithPrefix = (key.startsWith(ADDITIONAL_FIELD_PREFIX)) ? key : ADDITIONAL_FIELD_PREFIX + key; + pairs.accept(keyWithPrefix, value); + } + +} 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 b8f1d15cb69..5b3c9d964e8 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 @@ public class StructuredLogEncoder extends EncoderBase { 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 e4a92ebd325..acba34b7cd6 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 00000000000..bbe854a1a98 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatService.java @@ -0,0 +1,68 @@ +/* + * 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 + * @author Samuel Lissner + * @since 3.4.0 + */ +public record GraylogExtendedLogFormatService(String name, String version) { + + static final GraylogExtendedLogFormatService NONE = new GraylogExtendedLogFormatService(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); + } + + 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(); + } + + /** + * 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 bc864ac7587..db3716f03d4 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,16 @@ "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.name", + "type": "java.lang.String", + "description": "Structured GELF service name (defaults to 'spring.application.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 +638,9 @@ { "value": "ecs" }, + { + "value": "gelf" + }, { "value": "logstash" } @@ -647,6 +660,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 00000000000..5a777888e22 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.logging.log4j2; + +import java.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.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. + * + * @author Samuel Lissner + * @author Moritz Halbritter + */ +@ExtendWith(OutputCaptureExtension.class) +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("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.0, "level", 6, "_level_name", "INFO", + "_process_pid", 1, "_process_thread_name", "main", "_service_version", "1.0.0", "_log_logger", + "org.example.Test", "short_message", "message", "_mdc-1", "mdc-v-1")); + } + + @Test + void shouldFormatMillisecondsInTimestamp() { + MutableLogEvent event = createEvent(); + event.setTimeMillis(1719910193123L); + String json = this.formatter.format(event); + assertThat(json).contains("\"timestamp\":1719910193.123"); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("version", "1.1", "host", "name", "timestamp", + 1719910193.123, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + } + + @Test + void shouldNotAllowInvalidFieldNames(CapturedOutput output) { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("/", "value"), 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.0, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + assertThat(output).contains("'/' is not a valid field name according to GELF standard"); + } + + @Test + void shouldNotAllowIllegalFieldNames(CapturedOutput output) { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("id", "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.0, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + assertThat(output).contains("'id' is an illegal field name according to GELF standard"); + } + + @Test + void shouldNotAddDoubleUnderscoreToCustomFields() { + MutableLogEvent event = createEvent(); + event.setContextData(new JdkMapAdapterStringMap(Map.of("_custom", "value"), 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.0, "level", 6, "_level_name", "INFO", + "_process_pid", 1, "_process_thread_name", "main", "_service_version", "1.0.0", "_log_logger", + "org.example.Test", "short_message", "message", "_custom", "value")); + } + + @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/log4j2/StructuredLoggingLayoutTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java index 64e56d6a775..cf688e167d7 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/StructuredLoggingLayoutTests.java @@ -110,7 +110,7 @@ class StructuredLoggingLayoutTests extends AbstractStructuredLoggingTests { void shouldFailIfNoCommonOrCustomFormatIsSet() { assertThatIllegalArgumentException().isThrownBy(() -> newBuilder().setFormat("does-not-exist").build()) .withMessageContaining("Unknown format 'does-not-exist'. " - + "Values can be a valid fully-qualified class name or one of the common formats: [ecs, logstash]"); + + "Values can be a valid fully-qualified class name or one of the common formats: [ecs, gelf, logstash]"); } private Builder newBuilder() { 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 00000000000..39ab27c638f --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatterTests.java @@ -0,0 +1,148 @@ +/* + * 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.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}. + * + * @author Samuel Lissner + * @author Moritz Halbritter + */ +@ExtendWith(OutputCaptureExtension.class) +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("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.0, "level", 6, "_level_name", "INFO", + "_process_pid", 1, "_process_thread_name", "main", "_service_version", "1.0.0", "_log_logger", + "org.example.Test", "short_message", "message", "_mdc-1", "mdc-v-1", "_kv-1", "kv-v-1")); + } + + @Test + void shouldFormatMillisecondsInTimestamp() { + LoggingEvent event = createEvent(); + event.setTimeStamp(1719910193123L); + event.setMDCPropertyMap(Collections.emptyMap()); + String json = this.formatter.format(event); + assertThat(json).contains("\"timestamp\":1719910193.123"); + assertThat(json).endsWith("\n"); + Map deserialized = deserialize(json); + assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("version", "1.1", "host", "name", "timestamp", + 1719910193.123, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + } + + @Test + void shouldNotAllowInvalidFieldNames(CapturedOutput output) { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("/", "value")); + 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.0, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + assertThat(output).contains("'/' is not a valid field name according to GELF standard"); + } + + @Test + void shouldNotAllowIllegalFieldNames(CapturedOutput output) { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("id", "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.0, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main", + "_service_version", "1.0.0", "_log_logger", "org.example.Test", "short_message", "message")); + assertThat(output).contains("'id' is an illegal field name according to GELF standard"); + } + + @Test + void shouldNotAddDoubleUnderscoreToCustomFields() { + LoggingEvent event = createEvent(); + event.setMDCPropertyMap(Map.of("_custom", "value")); + 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.0, "level", 6, "_level_name", "INFO", + "_process_pid", 1, "_process_thread_name", "main", "_service_version", "1.0.0", "_log_logger", + "org.example.Test", "short_message", "message", "_custom", "value")); + } + + @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 3928f7e9521..f24543c9235 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 @@ class StructuredLogEncoderTests extends AbstractStructuredLoggingTests { 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 00000000000..b80e9c9946d --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatServiceTests.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.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"); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService("spring", "1.2.3")); + } + + @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)); + } + + @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")); + } + + @Test + void getWhenNoPropertiesToBind() { + MockEnvironment environment = new MockEnvironment(); + GraylogExtendedLogFormatService service = GraylogExtendedLogFormatService.get(environment); + assertThat(service).isEqualTo(new GraylogExtendedLogFormatService(null, null)); + } + + @Test + void addToJsonMembersCreatesValidJson() { + GraylogExtendedLogFormatService service = new GraylogExtendedLogFormatService("spring", "1.2.3"); + JsonWriter writer = JsonWriter.of(service::jsonMembers); + assertThat(writer.writeToString(service)).isEqualTo("{\"host\":\"spring\",\"_service_version\":\"1.2.3\"}"); + } + +}