Skip to content

Commit e88e72c

Browse files
authored
Add support for serializing ObjectMessage with Jackson (#37)
1 parent a122515 commit e88e72c

File tree

8 files changed

+281
-12
lines changed

8 files changed

+281
-12
lines changed

log4j2-ecs-layout/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ logger.info(new StringMapMessage()
7272
.with("foo", "bar"));
7373
```
7474

75+
If Jackson is on the classpath, you can also use an `ObjectMessage` to add a custom object the resulting JSON.
76+
77+
```java
78+
logger.info(new ObjectMessage(myObject));
79+
```
80+
81+
The `myObject` variable refers to a custom object which can be serialized by a Jackson `ObjectMapper`.
82+
83+
Using either will merge the object at the top-level (not nested under `message`) of the log event if it is a JSON object.
84+
If it's a string, number boolean or an array, it will be converted into a string and added as the `message` property.
85+
The conversion is done in order to avoid mapping conflicts as `message` is typed as a string in the Elasticsearch mapping.
86+
7587
### Tips
7688
It's recommended to use existing [ECS fields](https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html).
7789

log4j2-ecs-layout/pom.xml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@
4848
<dependency>
4949
<groupId>org.apache.logging.log4j</groupId>
5050
<artifactId>log4j-core</artifactId>
51-
<version>2.12.0</version>
51+
<version>${version.log4j}</version>
5252
<scope>provided</scope>
5353
</dependency>
54+
<dependency>
55+
<groupId>com.fasterxml.jackson.core</groupId>
56+
<artifactId>jackson-databind</artifactId>
57+
<version>2.9.10</version>
58+
<optional>true</optional>
59+
</dependency>
5460
<dependency>
5561
<groupId>${project.groupId}</groupId>
5662
<artifactId>ecs-logging-core</artifactId>
@@ -61,7 +67,7 @@
6167
<dependency>
6268
<groupId>org.apache.logging.log4j</groupId>
6369
<artifactId>log4j-core</artifactId>
64-
<version>2.12.0</version>
70+
<version>${version.log4j}</version>
6571
<type>test-jar</type>
6672
<scope>test</scope>
6773
</dependency>

log4j2-ecs-layout/src/main/java/co/elastic/logging/log4j2/EcsLayout.java

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.apache.logging.log4j.core.util.KeyValuePair;
4646
import org.apache.logging.log4j.message.Message;
4747
import org.apache.logging.log4j.message.MultiformatMessage;
48+
import org.apache.logging.log4j.message.ObjectMessage;
4849
import org.apache.logging.log4j.util.MultiFormatStringBuilderFormattable;
4950
import org.apache.logging.log4j.util.StringBuilderFormattable;
5051
import org.apache.logging.log4j.util.TriConsumer;
@@ -86,6 +87,7 @@ public void accept(final String key, final Object value, final StringBuilder str
8687
private final boolean includeMarkers;
8788
private final boolean includeOrigin;
8889
private final ConcurrentMap<Class<? extends MultiformatMessage>, Boolean> supportsJson = new ConcurrentHashMap<Class<? extends MultiformatMessage>, Boolean>();
90+
private final ObjectMessageJacksonSerializer objectMessageJacksonSerializer = ObjectMessageJacksonSerializer.Resolver.INSTANCE.resolve();
8991

9092
private EcsLayout(Configuration config, String serviceName, boolean includeMarkers, KeyValuePair[] additionalFields, Collection<String> topLevelLabels, boolean includeOrigin, boolean stackTraceAsArray) {
9193
super(config, UTF_8, null, null);
@@ -235,26 +237,41 @@ private void serializeMessage(StringBuilder builder, boolean gcFree, Message mes
235237
} else {
236238
serializeSimpleMessage(builder, gcFree, message, thrown);
237239
}
240+
} else if (objectMessageJacksonSerializer != null && message instanceof ObjectMessage) {
241+
final StringBuilder jsonBuffer = EcsJsonSerializer.getMessageStringBuilder();
242+
objectMessageJacksonSerializer.formatTo(jsonBuffer, (ObjectMessage) message);
243+
addJson(builder, jsonBuffer);
238244
} else {
239245
serializeSimpleMessage(builder, gcFree, message, thrown);
240246
}
241247
}
242248

243-
private void serializeJsonMessage(StringBuilder builder, MultiformatMessage message) {
249+
private static void serializeJsonMessage(StringBuilder builder, MultiformatMessage message) {
244250
final StringBuilder messageBuffer = EcsJsonSerializer.getMessageStringBuilder();
245251
if (message instanceof MultiFormatStringBuilderFormattable) {
246252
((MultiFormatStringBuilderFormattable) message).formatTo(JSON_FORMAT, messageBuffer);
247253
} else {
248254
messageBuffer.append(message.getFormattedMessage(JSON_FORMAT));
249255
}
250-
if (isObject(messageBuffer)) {
251-
moveToRoot(messageBuffer);
252-
builder.append(messageBuffer);
253-
builder.append(", ");
256+
addJson(builder, messageBuffer);
257+
}
258+
259+
private static void addJson(StringBuilder buffer, StringBuilder jsonBuffer) {
260+
if (isObject(jsonBuffer)) {
261+
moveToRoot(jsonBuffer);
262+
buffer.append(jsonBuffer);
263+
buffer.append(", ");
254264
} else {
255-
builder.append("\"message\":");
256-
builder.append(messageBuffer);
257-
builder.append(", ");
265+
buffer.append("\"message\":");
266+
if (isString(jsonBuffer)) {
267+
buffer.append(jsonBuffer);
268+
} else {
269+
// message always has to be a string to avoid mapping conflicts
270+
buffer.append('"');
271+
JsonUtils.quoteAsString(jsonBuffer, buffer);
272+
buffer.append('"');
273+
}
274+
buffer.append(", ");
258275
}
259276
}
260277

@@ -276,11 +293,15 @@ private void serializeSimpleMessage(StringBuilder builder, boolean gcFree, Messa
276293
builder.append("\", ");
277294
}
278295

279-
private boolean isObject(StringBuilder messageBuffer) {
296+
private static boolean isObject(StringBuilder messageBuffer) {
280297
return messageBuffer.length() > 1 && messageBuffer.charAt(0) == '{' && messageBuffer.charAt(messageBuffer.length() -1) == '}';
281298
}
282299

283-
private void moveToRoot(StringBuilder messageBuffer) {
300+
private static boolean isString(StringBuilder messageBuffer) {
301+
return messageBuffer.length() > 1 && messageBuffer.charAt(0) == '"' && messageBuffer.charAt(messageBuffer.length() -1) == '"';
302+
}
303+
304+
private static void moveToRoot(StringBuilder messageBuffer) {
284305
messageBuffer.setCharAt(0, ' ');
285306
messageBuffer.setCharAt(messageBuffer.length() -1, ' ');
286307
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*-
2+
* #%L
3+
* Java ECS logging
4+
* %%
5+
* Copyright (C) 2019 Elastic and contributors
6+
* %%
7+
* Licensed to Elasticsearch B.V. under one or more contributor
8+
* license agreements. See the NOTICE file distributed with
9+
* this work for additional information regarding copyright
10+
* ownership. Elasticsearch B.V. licenses this file to you under
11+
* the Apache License, Version 2.0 (the "License"); you may
12+
* not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing,
18+
* software distributed under the License is distributed on an
19+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
20+
* KIND, either express or implied. See the License for the
21+
* specific language governing permissions and limitations
22+
* under the License.
23+
* #L%
24+
*/
25+
package co.elastic.logging.log4j2;
26+
27+
import com.fasterxml.jackson.databind.ObjectMapper;
28+
29+
/**
30+
* To customize the {@link ObjectMapper} used to serialize the {@link org.apache.logging.log4j.message.ObjectMessage#obj} implement this
31+
* interface and add the fully qualified class name to {@code src/main/resources/META-INF/services/co.elastic.logging.log4j2.ObjectMapperFactory}.
32+
*/
33+
public interface ObjectMapperFactory {
34+
ObjectMapper create();
35+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*-
2+
* #%L
3+
* Java ECS logging
4+
* %%
5+
* Copyright (C) 2019 Elastic and contributors
6+
* %%
7+
* Licensed to Elasticsearch B.V. under one or more contributor
8+
* license agreements. See the NOTICE file distributed with
9+
* this work for additional information regarding copyright
10+
* ownership. Elasticsearch B.V. licenses this file to you under
11+
* the Apache License, Version 2.0 (the "License"); you may
12+
* not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing,
18+
* software distributed under the License is distributed on an
19+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
20+
* KIND, either express or implied. See the License for the
21+
* specific language governing permissions and limitations
22+
* under the License.
23+
* #L%
24+
*/
25+
package co.elastic.logging.log4j2;
26+
27+
import com.fasterxml.jackson.databind.ObjectMapper;
28+
import com.fasterxml.jackson.databind.SerializationFeature;
29+
import org.apache.logging.log4j.core.util.StringBuilderWriter;
30+
import org.apache.logging.log4j.message.ObjectMessage;
31+
import org.apache.logging.log4j.status.StatusLogger;
32+
33+
import java.io.IOException;
34+
import java.util.ServiceLoader;
35+
36+
interface ObjectMessageJacksonSerializer {
37+
38+
void formatTo(StringBuilder buffer, ObjectMessage objectMessage);
39+
40+
enum Resolver {
41+
INSTANCE;
42+
43+
ObjectMessageJacksonSerializer resolve() {
44+
ObjectMessageJacksonSerializer localDelegate = null;
45+
try {
46+
// safely discovers if Jackson is available
47+
Class.forName("com.fasterxml.jackson.databind.ObjectMapper");
48+
// this method has been introduced in 2.7
49+
Class.forName("org.apache.logging.log4j.message.ObjectMessage").getMethod("getParameter");
50+
// avoid initializing ObjectMessageSerializer$WithJackson if Jackson is not on the classpath to avoid linkage errors
51+
return (ObjectMessageJacksonSerializer) Class.forName("co.elastic.logging.log4j2.ObjectMessageJacksonSerializer$Available").getEnumConstants()[0];
52+
} catch (Exception e) {
53+
return null;
54+
} catch (LinkageError e) {
55+
// we should not cause linkage errors but just in case...
56+
return null;
57+
}
58+
}
59+
}
60+
61+
enum Available implements ObjectMessageJacksonSerializer {
62+
INSTANCE;
63+
64+
private final ObjectMapper objectMapper;
65+
66+
Available() {
67+
ObjectMapper mapper = null;
68+
for (ObjectMapperFactory objectMapperFactory : ServiceLoader.load(ObjectMapperFactory.class, ObjectMessageJacksonSerializer.class.getClassLoader())) {
69+
mapper = objectMapperFactory.create();
70+
break;
71+
}
72+
if (mapper == null) {
73+
mapper = new ObjectMapper().disable(SerializationFeature.FAIL_ON_EMPTY_BEANS).findAndRegisterModules();
74+
}
75+
objectMapper = mapper;
76+
}
77+
78+
@Override
79+
public void formatTo(StringBuilder buffer, ObjectMessage objectMessage) {
80+
try {
81+
objectMapper.writeValue(new StringBuilderWriter(buffer), objectMessage.getParameter());
82+
} catch (IOException e) {
83+
StatusLogger.getLogger().catching(e);
84+
objectMessage.formatTo(buffer);
85+
}
86+
}
87+
}
88+
}

log4j2-ecs-layout/src/test/java/co/elastic/logging/log4j2/AbstractLog4j2EcsLayoutTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@
3131
import org.apache.logging.log4j.MarkerManager;
3232
import org.apache.logging.log4j.ThreadContext;
3333
import org.apache.logging.log4j.core.Logger;
34+
import org.apache.logging.log4j.message.ObjectMessage;
3435
import org.apache.logging.log4j.message.StringMapMessage;
3536
import org.apache.logging.log4j.test.appender.ListAppender;
3637
import org.junit.jupiter.api.AfterEach;
3738
import org.junit.jupiter.api.Test;
3839

40+
import java.util.List;
41+
3942
import static org.assertj.core.api.Assertions.assertThat;
4043

4144
abstract class AbstractLog4j2EcsLayoutTest extends AbstractEcsLoggingTest {
@@ -97,6 +100,85 @@ void testCustomPatternConverter() throws Exception {
97100
assertThat(getLastLogLine().get("custom").textValue()).isEqualTo("foo");
98101
}
99102

103+
@Test
104+
void testJsonMessageObject() throws Exception {
105+
root.info(new ObjectMessage(new TestClass("foo", 42, true)));
106+
107+
assertThat(getLastLogLine().get("foo").textValue()).isEqualTo("foo");
108+
assertThat(getLastLogLine().get("bar").intValue()).isEqualTo(42);
109+
assertThat(getLastLogLine().get("baz").booleanValue()).isEqualTo(true);
110+
}
111+
112+
@Test
113+
void testJsonMessageArray() throws Exception {
114+
root.info(new ObjectMessage(List.of("foo", "bar")));
115+
116+
assertThat(getLastLogLine().get("message").isArray()).isFalse();
117+
assertThat(getLastLogLine().get("message").textValue()).isEqualTo("[\"foo\",\"bar\"]");
118+
}
119+
120+
@Test
121+
void testJsonMessageString() throws Exception {
122+
root.info(new ObjectMessage("foo"));
123+
124+
assertThat(getLastLogLine().get("message").textValue()).isEqualTo("foo");
125+
}
126+
127+
@Test
128+
void testJsonMessageNumber() throws Exception {
129+
root.info(new ObjectMessage(42));
130+
131+
assertThat(getLastLogLine().get("message").isNumber()).isFalse();
132+
assertThat(getLastLogLine().get("message").textValue()).isEqualTo("42");
133+
}
134+
135+
@Test
136+
void testJsonMessageBoolean() throws Exception {
137+
root.info(new ObjectMessage(true));
138+
139+
assertThat(getLastLogLine().get("message").isBoolean()).isFalse();
140+
assertThat(getLastLogLine().get("message").textValue()).isEqualTo("true");
141+
}
142+
143+
public static class TestClass {
144+
String foo;
145+
int bar;
146+
boolean baz;
147+
148+
private TestClass() {
149+
}
150+
151+
private TestClass(String foo, int bar, boolean baz) {
152+
this.foo = foo;
153+
this.bar = bar;
154+
this.baz = baz;
155+
}
156+
157+
public String getFoo() {
158+
return foo;
159+
}
160+
161+
public void setFoo(String foo) {
162+
this.foo = foo;
163+
}
164+
165+
public int getBar() {
166+
return bar;
167+
}
168+
169+
public void setBar(int bar) {
170+
this.bar = bar;
171+
}
172+
173+
public boolean isBaz() {
174+
return baz;
175+
}
176+
177+
public void setBaz(boolean baz) {
178+
this.baz = baz;
179+
}
180+
}
181+
100182
@Override
101183
public void putMdc(String key, String value) {
102184
ThreadContext.put(key, value);

log4j2-ecs-layout/src/test/java/co/elastic/logging/log4j2/ParameterizedStructuredMessage.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
/*-
2+
* #%L
3+
* Java ECS logging
4+
* %%
5+
* Copyright (C) 2019 Elastic and contributors
6+
* %%
7+
* Licensed to Elasticsearch B.V. under one or more contributor
8+
* license agreements. See the NOTICE file distributed with
9+
* this work for additional information regarding copyright
10+
* ownership. Elasticsearch B.V. licenses this file to you under
11+
* the Apache License, Version 2.0 (the "License"); you may
12+
* not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing,
18+
* software distributed under the License is distributed on an
19+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
20+
* KIND, either express or implied. See the License for the
21+
* specific language governing permissions and limitations
22+
* under the License.
23+
* #L%
24+
*/
125
package co.elastic.logging.log4j2;
226

327
import org.apache.logging.log4j.message.MapMessage;

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
5353
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
5454
<parent.base.dir>${project.basedir}</parent.base.dir>
55+
<version.log4j>2.12.0</version.log4j>
5556
</properties>
5657

5758
<distributionManagement>

0 commit comments

Comments
 (0)