Skip to content

Commit b5e7302

Browse files
committed
Polish "Add Graylog Extended Log Format (GELF) for structured logging"
See gh-42158
1 parent 74a9d11 commit b5e7302

File tree

9 files changed

+194
-143
lines changed

9 files changed

+194
-143
lines changed

spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/logging.adoc

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ A log line looks like this:
516516

517517
[source,json]
518518
----
519-
{"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"}
519+
{"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"}
520520
----
521521

522522
This format also adds every key value pair contained in the MDC to the JSON object.
@@ -532,8 +532,6 @@ logging:
532532
service:
533533
name: MyService
534534
version: 1.0
535-
environment: Production
536-
node-name: Primary
537535
----
538536

539537
NOTE: configprop:logging.structured.gelf.service.name[] will default to configprop:spring.application.name[] if not specified.

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/GraylogExtendedLogFormatStructuredLogFormatter.java

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,26 @@
1919
import java.math.BigDecimal;
2020
import java.util.Objects;
2121
import java.util.Set;
22-
import java.util.function.Function;
22+
import java.util.function.BiConsumer;
2323
import java.util.regex.Pattern;
2424

25+
import org.apache.commons.logging.Log;
26+
import org.apache.commons.logging.LogFactory;
2527
import org.apache.logging.log4j.Level;
2628
import org.apache.logging.log4j.core.LogEvent;
27-
import org.apache.logging.log4j.core.impl.ThrowableProxy;
2829
import org.apache.logging.log4j.core.net.Severity;
2930
import org.apache.logging.log4j.core.time.Instant;
3031
import org.apache.logging.log4j.message.Message;
3132
import org.apache.logging.log4j.util.ReadOnlyStringMap;
3233

3334
import org.springframework.boot.json.JsonWriter;
35+
import org.springframework.boot.json.JsonWriter.WritableJson;
3436
import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
3537
import org.springframework.boot.logging.structured.GraylogExtendedLogFormatService;
3638
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
3739
import org.springframework.boot.logging.structured.StructuredLogFormatter;
3840
import org.springframework.core.env.Environment;
41+
import org.springframework.core.log.LogMessage;
3942
import org.springframework.util.Assert;
4043
import org.springframework.util.ObjectUtils;
4144

@@ -45,14 +48,17 @@
4548
* 1.1.
4649
*
4750
* @author Samuel Lissner
51+
* @author Moritz Halbritter
4852
*/
4953
class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter<LogEvent> {
5054

55+
private static final Log logger = LogFactory.getLog(GraylogExtendedLogFormatStructuredLogFormatter.class);
56+
5157
/**
5258
* Allowed characters in field names are any word character (letter, number,
5359
* underscore), dashes and dots.
5460
*/
55-
private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w\\.\\-]*$");
61+
private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w.\\-]*$");
5662

5763
/**
5864
* Every field been sent and prefixed with an underscore "_" will be treated as an
@@ -64,54 +70,38 @@ class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructure
6470
* Libraries SHOULD not allow to send id as additional field ("_id"). Graylog server
6571
* nodes omit this field automatically.
6672
*/
67-
private static final Set<String> ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("_id");
68-
69-
/**
70-
* Default format to be used for the `full_message` property when there is a throwable
71-
* present in the log event.
72-
*/
73-
private static final String DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT = "%s%n%n%s";
73+
private static final Set<String> ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("id", "_id");
7474

7575
GraylogExtendedLogFormatStructuredLogFormatter(Environment environment) {
7676
super((members) -> jsonMembers(environment, members));
7777
}
7878

7979
private static void jsonMembers(Environment environment, JsonWriter.Members<LogEvent> members) {
8080
members.add("version", "1.1");
81-
8281
// note: a blank message will lead to a Graylog error as of Graylog v6.0.x. We are
8382
// ignoring this here.
8483
members.add("short_message", LogEvent::getMessage).as(Message::getFormattedMessage);
85-
8684
members.add("timestamp", LogEvent::getInstant)
8785
.as(GraylogExtendedLogFormatStructuredLogFormatter::formatTimeStamp);
8886
members.add("level", GraylogExtendedLogFormatStructuredLogFormatter::convertLevel);
8987
members.add("_level_name", LogEvent::getLevel).as(Level::name);
90-
9188
members.add("_process_pid", environment.getProperty("spring.application.pid", Long.class))
9289
.when(Objects::nonNull);
9390
members.add("_process_thread_name", LogEvent::getThreadName);
94-
9591
GraylogExtendedLogFormatService.get(environment).jsonMembers(members);
96-
9792
members.add("_log_logger", LogEvent::getLoggerName);
98-
9993
members.from(LogEvent::getContextData)
10094
.whenNot(ReadOnlyStringMap::isEmpty)
10195
.usingPairs((contextData, pairs) -> contextData
102-
.forEach((key, value) -> pairs.accept(makeAdditionalFieldName(key), value)));
103-
96+
.forEach((key, value) -> createAdditionalField(key, value, pairs)));
10497
members.add().whenNotNull(LogEvent::getThrownProxy).usingMembers((eventMembers) -> {
105-
final Function<LogEvent, ThrowableProxy> throwableProxyGetter = LogEvent::getThrownProxy;
106-
10798
eventMembers.add("full_message",
10899
GraylogExtendedLogFormatStructuredLogFormatter::formatFullMessageWithThrowable);
109-
eventMembers.add("_error_type", throwableProxyGetter.andThen(ThrowableProxy::getThrowable))
100+
eventMembers.add("_error_type", (event) -> event.getThrownProxy().getThrowable())
110101
.whenNotNull()
111102
.as(ObjectUtils::nullSafeClassName);
112-
eventMembers.add("_error_stack_trace",
113-
throwableProxyGetter.andThen(ThrowableProxy::getExtendedStackTraceAsString));
114-
eventMembers.add("_error_message", throwableProxyGetter.andThen(ThrowableProxy::getMessage));
103+
eventMembers.add("_error_stack_trace", (event) -> event.getThrownProxy().getExtendedStackTraceAsString());
104+
eventMembers.add("_error_message", (event) -> event.getThrownProxy().getMessage());
115105
});
116106
}
117107

@@ -123,8 +113,8 @@ private static void jsonMembers(Environment environment, JsonWriter.Members<LogE
123113
* `Instant` type but {@link org.apache.logging.log4j.core.time}
124114
* @return the timestamp formatted as string with millisecond precision
125115
*/
126-
private static double formatTimeStamp(final Instant timeStamp) {
127-
return new BigDecimal(timeStamp.getEpochMillisecond()).movePointLeft(3).doubleValue();
116+
private static WritableJson formatTimeStamp(Instant timeStamp) {
117+
return (out) -> out.append(new BigDecimal(timeStamp.getEpochMillisecond()).movePointLeft(3).toPlainString());
128118
}
129119

130120
/**
@@ -133,30 +123,27 @@ private static double formatTimeStamp(final Instant timeStamp) {
133123
* @return an integer representing the syslog log level code
134124
* @see Severity class from Log4j2 which contains the conversion logic
135125
*/
136-
private static int convertLevel(final LogEvent event) {
126+
private static int convertLevel(LogEvent event) {
137127
return Severity.getSeverity(event.getLevel()).getCode();
138128
}
139129

140-
private static String formatFullMessageWithThrowable(final LogEvent event) {
141-
return String.format(DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT, event.getMessage().getFormattedMessage(),
142-
event.getThrownProxy().getExtendedStackTraceAsString());
130+
private static String formatFullMessageWithThrowable(LogEvent event) {
131+
return event.getMessage().getFormattedMessage() + "\n\n"
132+
+ event.getThrownProxy().getExtendedStackTraceAsString();
143133
}
144134

145-
private static String makeAdditionalFieldName(String fieldName) {
135+
private static void createAdditionalField(String fieldName, Object value, BiConsumer<Object, Object> pairs) {
146136
Assert.notNull(fieldName, "fieldName must not be null");
147-
Assert.isTrue(FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches(),
148-
() -> String.format("fieldName must be a valid according to GELF standard. [fieldName=%s]", fieldName));
149-
Assert.isTrue(!ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName), () -> String.format(
150-
"fieldName must not be an illegal additional field key according to GELF standard. [fieldName=%s]",
151-
fieldName));
152-
153-
if (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) {
154-
// No need to prepend the `ADDITIONAL_FIELD_PREFIX` in case the caller already
155-
// has prepended the prefix.
156-
return fieldName;
137+
if (!FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches()) {
138+
logger.warn(LogMessage.format("'%s' is not a valid field name according to GELF standard", fieldName));
139+
return;
157140
}
158-
159-
return ADDITIONAL_FIELD_PREFIX + fieldName;
141+
if (ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName)) {
142+
logger.warn(LogMessage.format("'%s' is an illegal field name according to GELF standard", fieldName));
143+
return;
144+
}
145+
String key = (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) ? fieldName : ADDITIONAL_FIELD_PREFIX + fieldName;
146+
pairs.accept(key, value);
160147
}
161148

162149
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/GraylogExtendedLogFormatStructuredLogFormatter.java

Lines changed: 32 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,46 @@
1717
package org.springframework.boot.logging.logback;
1818

1919
import java.math.BigDecimal;
20-
import java.util.Map;
2120
import java.util.Objects;
2221
import java.util.Set;
23-
import java.util.function.Function;
22+
import java.util.function.BiConsumer;
2423
import java.util.regex.Pattern;
25-
import java.util.stream.Collectors;
2624

2725
import ch.qos.logback.classic.pattern.ThrowableProxyConverter;
2826
import ch.qos.logback.classic.spi.ILoggingEvent;
2927
import ch.qos.logback.classic.spi.IThrowableProxy;
3028
import ch.qos.logback.classic.util.LevelToSyslogSeverity;
31-
import org.slf4j.event.KeyValuePair;
29+
import org.apache.commons.logging.Log;
30+
import org.apache.commons.logging.LogFactory;
3231

3332
import org.springframework.boot.json.JsonWriter;
34-
import org.springframework.boot.json.JsonWriter.PairExtractor;
33+
import org.springframework.boot.json.JsonWriter.WritableJson;
3534
import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
3635
import org.springframework.boot.logging.structured.GraylogExtendedLogFormatService;
3736
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
3837
import org.springframework.boot.logging.structured.StructuredLogFormatter;
3938
import org.springframework.core.env.Environment;
39+
import org.springframework.core.log.LogMessage;
4040
import org.springframework.util.Assert;
41+
import org.springframework.util.CollectionUtils;
4142

4243
/**
4344
* Logback {@link StructuredLogFormatter} for
4445
* {@link CommonStructuredLogFormat#GRAYLOG_EXTENDED_LOG_FORMAT}. Supports GELF version
4546
* 1.1.
4647
*
4748
* @author Samuel Lissner
49+
* @author Moritz Halbritter
4850
*/
4951
class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter<ILoggingEvent> {
5052

53+
private static final Log logger = LogFactory.getLog(GraylogExtendedLogFormatStructuredLogFormatter.class);
54+
5155
/**
5256
* Allowed characters in field names are any word character (letter, number,
5357
* underscore), dashes and dots.
5458
*/
55-
private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w\\.\\-]*$");
59+
private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w.\\-]*$");
5660

5761
/**
5862
* Every field been sent and prefixed with an underscore "_" will be treated as an
@@ -64,16 +68,7 @@ class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructure
6468
* Libraries SHOULD not allow to send id as additional field ("_id"). Graylog server
6569
* nodes omit this field automatically.
6670
*/
67-
private static final Set<String> ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("_id");
68-
69-
/**
70-
* Default format to be used for the `full_message` property when there is a throwable
71-
* present in the log event.
72-
*/
73-
private static final String DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT = "%s%n%n%s";
74-
75-
private static final PairExtractor<KeyValuePair> keyValuePairExtractor = PairExtractor
76-
.of((pair) -> makeAdditionalFieldName(pair.key), (pair) -> pair.value);
71+
private static final Set<String> ADDITIONAL_FIELD_ILLEGAL_KEYS = Set.of("id", "_id");
7772

7873
GraylogExtendedLogFormatStructuredLogFormatter(Environment environment,
7974
ThrowableProxyConverter throwableProxyConverter) {
@@ -83,30 +78,25 @@ class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructure
8378
private static void jsonMembers(Environment environment, ThrowableProxyConverter throwableProxyConverter,
8479
JsonWriter.Members<ILoggingEvent> members) {
8580
members.add("version", "1.1");
86-
8781
// note: a blank message will lead to a Graylog error as of Graylog v6.0.x. We are
8882
// ignoring this here.
8983
members.add("short_message", ILoggingEvent::getFormattedMessage);
90-
9184
members.add("timestamp", ILoggingEvent::getTimeStamp)
9285
.as(GraylogExtendedLogFormatStructuredLogFormatter::formatTimeStamp);
9386
members.add("level", LevelToSyslogSeverity::convert);
9487
members.add("_level_name", ILoggingEvent::getLevel);
95-
9688
members.add("_process_pid", environment.getProperty("spring.application.pid", Long.class))
9789
.when(Objects::nonNull);
9890
members.add("_process_thread_name", ILoggingEvent::getThreadName);
99-
10091
GraylogExtendedLogFormatService.get(environment).jsonMembers(members);
101-
10292
members.add("_log_logger", ILoggingEvent::getLoggerName);
103-
104-
members.addMapEntries(mapMDCProperties(ILoggingEvent::getMDCPropertyMap));
105-
93+
members.from(ILoggingEvent::getMDCPropertyMap)
94+
.when((mdc) -> !CollectionUtils.isEmpty(mdc))
95+
.usingPairs((mdc, pairs) -> mdc.forEach((key, value) -> createAdditionalField(key, value, pairs)));
10696
members.from(ILoggingEvent::getKeyValuePairs)
107-
.whenNotEmpty()
108-
.usingExtractedPairs(Iterable::forEach, keyValuePairExtractor);
109-
97+
.when((keyValuePairs) -> !CollectionUtils.isEmpty(keyValuePairs))
98+
.usingPairs((keyValuePairs, pairs) -> keyValuePairs
99+
.forEach((keyValuePair) -> createAdditionalField(keyValuePair.key, keyValuePair.value, pairs)));
110100
members.add().whenNotNull(ILoggingEvent::getThrowableProxy).usingMembers((throwableMembers) -> {
111101
throwableMembers.add("full_message",
112102
(event) -> formatFullMessageWithThrowable(throwableProxyConverter, event));
@@ -123,38 +113,27 @@ private static void jsonMembers(Environment environment, ThrowableProxyConverter
123113
* @param timeStamp the timestamp of the log message
124114
* @return the timestamp formatted as string with millisecond precision
125115
*/
126-
private static double formatTimeStamp(final long timeStamp) {
127-
return new BigDecimal(timeStamp).movePointLeft(3).doubleValue();
116+
private static WritableJson formatTimeStamp(long timeStamp) {
117+
return (out) -> out.append(new BigDecimal(timeStamp).movePointLeft(3).toPlainString());
128118
}
129119

130-
private static String formatFullMessageWithThrowable(final ThrowableProxyConverter throwableProxyConverter,
120+
private static String formatFullMessageWithThrowable(ThrowableProxyConverter throwableProxyConverter,
131121
ILoggingEvent event) {
132-
return String.format(DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT, event.getFormattedMessage(),
133-
throwableProxyConverter.convert(event));
122+
return event.getFormattedMessage() + "\n\n" + throwableProxyConverter.convert(event);
134123
}
135124

136-
private static Function<ILoggingEvent, Map<String, String>> mapMDCProperties(
137-
Function<ILoggingEvent, Map<String, String>> MDCPropertyMapGetter) {
138-
return MDCPropertyMapGetter.andThen((mdc) -> mdc.entrySet()
139-
.stream()
140-
.collect(Collectors.toMap((entry) -> makeAdditionalFieldName(entry.getKey()), Map.Entry::getValue)));
141-
}
142-
143-
private static String makeAdditionalFieldName(String fieldName) {
144-
Assert.notNull(fieldName, "fieldName must not be null");
145-
Assert.isTrue(FIELD_NAME_VALID_PATTERN.matcher(fieldName).matches(),
146-
() -> String.format("fieldName must be a valid according to GELF standard. [fieldName=%s]", fieldName));
147-
Assert.isTrue(!ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(fieldName), () -> String.format(
148-
"fieldName must not be an illegal additional field key according to GELF standard. [fieldName=%s]",
149-
fieldName));
150-
151-
if (fieldName.startsWith(ADDITIONAL_FIELD_PREFIX)) {
152-
// No need to prepend the `ADDITIONAL_FIELD_PREFIX` in case the caller already
153-
// has prepended the prefix.
154-
return fieldName;
125+
private static void createAdditionalField(String key, Object value, BiConsumer<Object, Object> pairs) {
126+
Assert.notNull(key, "fieldName must not be null");
127+
if (!FIELD_NAME_VALID_PATTERN.matcher(key).matches()) {
128+
logger.warn(LogMessage.format("'%s' is not a valid field name according to GELF standard", key));
129+
return;
155130
}
156-
157-
return ADDITIONAL_FIELD_PREFIX + fieldName;
131+
if (ADDITIONAL_FIELD_ILLEGAL_KEYS.contains(key)) {
132+
logger.warn(LogMessage.format("'%s' is an illegal field name according to GELF standard", key));
133+
return;
134+
}
135+
String keyWithPrefix = (key.startsWith(ADDITIONAL_FIELD_PREFIX)) ? key : ADDITIONAL_FIELD_PREFIX + key;
136+
pairs.accept(keyWithPrefix, value);
158137
}
159138

160139
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/structured/GraylogExtendedLogFormatService.java

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,17 @@
2626
*
2727
* @param name the application name
2828
* @param version the version of the application
29-
* @param environment the name of the environment the application is running in
30-
* @param nodeName the name of the node the application is running on
3129
* @author Samuel Lissner
3230
* @since 3.4.0
3331
*/
34-
public record GraylogExtendedLogFormatService(String name, String version, String environment, String nodeName) {
32+
public record GraylogExtendedLogFormatService(String name, String version) {
3533

36-
static final GraylogExtendedLogFormatService NONE = new GraylogExtendedLogFormatService(null, null, null, null);
34+
static final GraylogExtendedLogFormatService NONE = new GraylogExtendedLogFormatService(null, null);
3735

3836
private GraylogExtendedLogFormatService withDefaults(Environment environment) {
3937
String name = withFallbackProperty(environment, this.name, "spring.application.name");
4038
String version = withFallbackProperty(environment, this.version, "spring.application.version");
41-
return new GraylogExtendedLogFormatService(name, version, this.environment, this.nodeName);
39+
return new GraylogExtendedLogFormatService(name, version);
4240
}
4341

4442
private String withFallbackProperty(Environment environment, String value, String property) {
@@ -53,8 +51,6 @@ public void jsonMembers(JsonWriter.Members<?> members) {
5351
// note "host" is a field name prescribed by GELF
5452
members.add("host", this::name).whenHasLength();
5553
members.add("_service_version", this::version).whenHasLength();
56-
members.add("_service_environment", this::environment).whenHasLength();
57-
members.add("_service_node_name", this::nodeName).whenHasLength();
5854
}
5955

6056
/**

spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -254,21 +254,11 @@
254254
"type": "java.lang.String",
255255
"description": "Structured logging format for output to a file. Must be either a format id or a fully qualified class name."
256256
},
257-
{
258-
"name": "logging.structured.gelf.service.environment",
259-
"type": "java.lang.String",
260-
"description": "Structured GELF service environment."
261-
},
262257
{
263258
"name": "logging.structured.gelf.service.name",
264259
"type": "java.lang.String",
265260
"description": "Structured GELF service name (defaults to 'spring.application.name')."
266261
},
267-
{
268-
"name": "logging.structured.gelf.service.node-name",
269-
"type": "java.lang.String",
270-
"description": "Structured GELF service node name."
271-
},
272262
{
273263
"name": "logging.structured.gelf.service.version",
274264
"type": "java.lang.String",

0 commit comments

Comments
 (0)