Merge pull request #42158 from slissner
* pr/42158: Polish "Add Graylog Extended Log Format (GELF) for structured logging" Add Graylog Extended Log Format (GELF) for structured logging Closes gh-42158
This commit is contained in:
@@ -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]]
|
||||
|
||||
@@ -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<LogEvent> {
|
||||
|
||||
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<String> ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("id", "_id");
|
||||
|
||||
GraylogExtendedLogFormatStructuredLogFormatter(Environment environment) {
|
||||
super((members) -> jsonMembers(environment, members));
|
||||
}
|
||||
|
||||
private static void jsonMembers(Environment environment, JsonWriter.Members<LogEvent> 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 <b>decimal places for
|
||||
* milliseconds</b>". 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<Object, Object> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<ILoggingEvent> {
|
||||
|
||||
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<String> 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<ILoggingEvent> 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 <b>decimal places for
|
||||
* milliseconds</b>". 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<Object, Object> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,6 +82,11 @@ public class StructuredLogEncoder extends EncoderBase<ILoggingEvent> {
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ public enum CommonStructuredLogFormat {
|
||||
*/
|
||||
ELASTIC_COMMON_SCHEMA("ecs"),
|
||||
|
||||
/**
|
||||
* <a href="https://go2docs.graylog.org/current/getting_in_log_data/gelf.html">Graylog
|
||||
* Extended Log Format</a> (GELF) log format.
|
||||
*/
|
||||
GRAYLOG_EXTENDED_LOG_FORMAT("gelf"),
|
||||
|
||||
/**
|
||||
* The <a href=
|
||||
* "https://github.com/logfellow/logstash-logback-encoder?tab=readme-ov-file#standard-fields">Logstash</a>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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""");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<GraylogExtendedLogFormatService> writer = JsonWriter.of(service::jsonMembers);
|
||||
assertThat(writer.writeToString(service)).isEqualTo("{\"host\":\"spring\",\"_service_version\":\"1.2.3\"}");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user