Skip to content

Commit 1c91275

Browse files
committed
Add gelf structured log formatter for Log4j2
1 parent c835af8 commit 1c91275

File tree

4 files changed

+258
-1
lines changed

4 files changed

+258
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.logging.log4j2;
18+
19+
import java.math.BigDecimal;
20+
import java.util.Objects;
21+
import java.util.Set;
22+
import java.util.function.Function;
23+
import java.util.regex.Pattern;
24+
25+
import org.apache.logging.log4j.Level;
26+
import org.apache.logging.log4j.core.LogEvent;
27+
import org.apache.logging.log4j.core.impl.ThrowableProxy;
28+
import org.apache.logging.log4j.core.net.Severity;
29+
import org.apache.logging.log4j.core.time.Instant;
30+
import org.apache.logging.log4j.message.Message;
31+
import org.apache.logging.log4j.util.ReadOnlyStringMap;
32+
33+
import org.springframework.boot.json.JsonWriter;
34+
import org.springframework.boot.logging.structured.CommonStructuredLogFormat;
35+
import org.springframework.boot.logging.structured.GraylogExtendedLogFormatService;
36+
import org.springframework.boot.logging.structured.JsonWriterStructuredLogFormatter;
37+
import org.springframework.boot.logging.structured.StructuredLogFormatter;
38+
import org.springframework.core.env.Environment;
39+
import org.springframework.util.Assert;
40+
import org.springframework.util.ObjectUtils;
41+
42+
/**
43+
* Log4j2 {@link StructuredLogFormatter} for
44+
* {@link CommonStructuredLogFormat#GRAYLOG_EXTENDED_LOG_FORMAT}. Supports GELF version
45+
* 1.1.
46+
*
47+
* @author Samuel Lissner
48+
*/
49+
class GraylogExtendedLogFormatStructuredLogFormatter extends JsonWriterStructuredLogFormatter<LogEvent> {
50+
51+
/**
52+
* Allowed characters in field names are any word character (letter, number,
53+
* underscore), dashes and dots.
54+
*/
55+
private static final Pattern FIELD_NAME_VALID_PATTERN = Pattern.compile("^[\\w\\.\\-]*$");
56+
57+
/**
58+
* Every field been sent and prefixed with an underscore "_" will be treated as an
59+
* additional field.
60+
*/
61+
private static final String ADDITIONAL_FIELD_PREFIX = "_";
62+
63+
/**
64+
* Libraries SHOULD not allow to send id as additional field ("_id"). Graylog server
65+
* nodes omit this field automatically.
66+
*/
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+
GraylogExtendedLogFormatStructuredLogFormatter(Environment environment) {
76+
super((members) -> jsonMembers(environment, members));
77+
}
78+
79+
private static void jsonMembers(Environment environment, JsonWriter.Members<LogEvent> members) {
80+
members.add("version", "1.1");
81+
82+
// note: a blank message will lead to a Graylog error as of Graylog v6.0.x. We are
83+
// ignoring this here.
84+
members.add("short_message", LogEvent::getMessage).as(Message::getFormattedMessage);
85+
86+
members.add("timestamp", LogEvent::getInstant)
87+
.as(GraylogExtendedLogFormatStructuredLogFormatter::formatTimeStamp);
88+
members.add("level", GraylogExtendedLogFormatStructuredLogFormatter::convertLevel);
89+
members.add("_level_name", LogEvent::getLevel).as(Level::name);
90+
91+
members.add("_process_pid", environment.getProperty("spring.application.pid", Long.class))
92+
.when(Objects::nonNull);
93+
members.add("_process_thread_name", LogEvent::getThreadName);
94+
95+
GraylogExtendedLogFormatService.get(environment).jsonMembers(members);
96+
97+
members.add("_log_logger", LogEvent::getLoggerName);
98+
99+
members.from(LogEvent::getContextData)
100+
.whenNot(ReadOnlyStringMap::isEmpty)
101+
.usingPairs((contextData, pairs) -> contextData
102+
.forEach((key, value) -> pairs.accept(makeAdditionalFieldName(key), value)));
103+
104+
members.add().whenNotNull(LogEvent::getThrownProxy).usingMembers((eventMembers) -> {
105+
final Function<LogEvent, ThrowableProxy> throwableProxyGetter = LogEvent::getThrownProxy;
106+
107+
eventMembers.add("full_message",
108+
GraylogExtendedLogFormatStructuredLogFormatter::formatFullMessageWithThrowable);
109+
eventMembers.add("_error_type", throwableProxyGetter.andThen(ThrowableProxy::getThrowable))
110+
.whenNotNull()
111+
.as(ObjectUtils::nullSafeClassName);
112+
eventMembers.add("_error_stack_trace",
113+
throwableProxyGetter.andThen(ThrowableProxy::getExtendedStackTraceAsString));
114+
eventMembers.add("_error_message", throwableProxyGetter.andThen(ThrowableProxy::getMessage));
115+
});
116+
}
117+
118+
/**
119+
* GELF requires "seconds since UNIX epoch with optional <b>decimal places for
120+
* milliseconds</b>". To comply with this requirement, we format a POSIX timestamp
121+
* with millisecond precision as e.g. "1725459730385" -> "1725459730.385"
122+
* @param timeStamp the timestamp of the log message. Note it is not the standard Java
123+
* `Instant` type but {@link org.apache.logging.log4j.core.time}
124+
* @return the timestamp formatted as string with millisecond precision
125+
*/
126+
private static double formatTimeStamp(final Instant timeStamp) {
127+
return new BigDecimal(timeStamp.getEpochMillisecond()).movePointLeft(3).doubleValue();
128+
}
129+
130+
/**
131+
* Converts the log4j2 event level to the Syslog event level code.
132+
* @param event the log event
133+
* @return an integer representing the syslog log level code
134+
* @see Severity class from Log4j2 which contains the conversion logic
135+
*/
136+
private static int convertLevel(final LogEvent event) {
137+
return Severity.getSeverity(event.getLevel()).getCode();
138+
}
139+
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());
143+
}
144+
145+
private static String makeAdditionalFieldName(String fieldName) {
146+
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;
157+
}
158+
159+
return ADDITIONAL_FIELD_PREFIX + fieldName;
160+
}
161+
162+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ private void addCommonFormatters(CommonFormatters<LogEvent> commonFormatters) {
105105
commonFormatters.add(CommonStructuredLogFormat.ELASTIC_COMMON_SCHEMA,
106106
(instantiator) -> new ElasticCommonSchemaStructuredLogFormatter(
107107
instantiator.getArg(Environment.class)));
108+
commonFormatters.add(CommonStructuredLogFormat.GRAYLOG_EXTENDED_LOG_FORMAT,
109+
(instantiator) -> new GraylogExtendedLogFormatStructuredLogFormatter(
110+
instantiator.getArg(Environment.class)));
108111
commonFormatters.add(CommonStructuredLogFormat.LOGSTASH,
109112
(instantiator) -> new LogstashStructuredLogFormatter());
110113
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ private static double formatTimeStamp(final long timeStamp) {
127127
return new BigDecimal(timeStamp).movePointLeft(3).doubleValue();
128128
}
129129

130-
private static String formatFullMessageWithThrowable(ThrowableProxyConverter throwableProxyConverter,
130+
private static String formatFullMessageWithThrowable(final ThrowableProxyConverter throwableProxyConverter,
131131
ILoggingEvent event) {
132132
return String.format(DEFAULT_FULL_MESSAGE_WITH_THROWABLE_FORMAT, event.getFormattedMessage(),
133133
throwableProxyConverter.convert(event));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.logging.log4j2;
18+
19+
import java.util.Map;
20+
21+
import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap;
22+
import org.apache.logging.log4j.core.impl.MutableLogEvent;
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.mock.env.MockEnvironment;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
/**
31+
* Tests for {@link GraylogExtendedLogFormatStructuredLogFormatter}.
32+
*
33+
* @author Samuel Lissner
34+
*/
35+
class GraylogExtendedLogFormatStructuredLogFormatterTests extends AbstractStructuredLoggingTests {
36+
37+
private GraylogExtendedLogFormatStructuredLogFormatter formatter;
38+
39+
@BeforeEach
40+
void setUp() {
41+
MockEnvironment environment = new MockEnvironment();
42+
environment.setProperty("logging.structured.gelf.service.name", "name");
43+
environment.setProperty("logging.structured.gelf.service.version", "1.0.0");
44+
environment.setProperty("logging.structured.gelf.service.environment", "test");
45+
environment.setProperty("logging.structured.gelf.service.node-name", "node-1");
46+
environment.setProperty("spring.application.pid", "1");
47+
this.formatter = new GraylogExtendedLogFormatStructuredLogFormatter(environment);
48+
}
49+
50+
@Test
51+
void shouldFormat() {
52+
MutableLogEvent event = createEvent();
53+
event.setContextData(new JdkMapAdapterStringMap(Map.of("mdc-1", "mdc-v-1"), true));
54+
String json = this.formatter.format(event);
55+
assertThat(json).endsWith("\n");
56+
Map<String, Object> deserialized = deserialize(json);
57+
assertThat(deserialized).containsExactlyInAnyOrderEntriesOf(map("version", "1.1", "host", "name", "timestamp",
58+
1719910193.000D, "level", 6, "_level_name", "INFO", "_process_pid", 1, "_process_thread_name", "main",
59+
"_service_version", "1.0.0", "_service_environment", "test", "_service_node_name", "node-1",
60+
"_log_logger", "org.example.Test", "short_message", "message", "_mdc-1", "mdc-v-1"));
61+
}
62+
63+
@Test
64+
void shouldFormatException() {
65+
MutableLogEvent event = createEvent();
66+
event.setThrown(new RuntimeException("Boom"));
67+
68+
String json = this.formatter.format(event);
69+
Map<String, Object> deserialized = deserialize(json);
70+
71+
String fullMessage = (String) deserialized.get("full_message");
72+
String stackTrace = (String) deserialized.get("_error_stack_trace");
73+
74+
assertThat(fullMessage).startsWith(
75+
"""
76+
message
77+
78+
java.lang.RuntimeException: Boom
79+
\tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException""");
80+
assertThat(stackTrace).startsWith(
81+
"""
82+
java.lang.RuntimeException: Boom
83+
\tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException""");
84+
85+
assertThat(deserialized)
86+
.containsAllEntriesOf(map("_error_type", "java.lang.RuntimeException", "_error_message", "Boom"));
87+
assertThat(json).contains(
88+
"""
89+
message\\n\\njava.lang.RuntimeException: Boom\\n\\tat org.springframework.boot.logging.log4j2.GraylogExtendedLogFormatStructuredLogFormatterTests.shouldFormatException""");
90+
}
91+
92+
}

0 commit comments

Comments
 (0)