This is an automated email from the ASF dual-hosted git repository.
vy pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
The following commit(s) were added to refs/heads/2.x by this push:
new e8f7ec059a Add literal Context Data (MDC) filtering to JSON Template
Layout (#4186)
e8f7ec059a is described below
commit e8f7ec059a49c19b67b404e18c561128d7af110d
Author: Ramanathan <[email protected]>
AuthorDate: Fri Sep 11 18:51:47 2026 +0530
Add literal Context Data (MDC) filtering to JSON Template Layout (#4186)
Co-authored-by: Piotr P. Karwasz <[email protected]>
Co-authored-by: Volkan Yazıcı <[email protected]>
---
.../resolver/ReadOnlyStringMapResolverTest.java | 121 +++++++++++++++++++++
log4j-layout-template-json/pom.xml | 8 ++
.../json/resolver/ReadOnlyStringMapResolver.java | 66 ++++++++++-
.../src/main/resources/EcsLayout.json | 14 ++-
.../log4j/perf/jmh/MdcKeyFilterBenchmark.java | 90 +++++++++++++++
src/changelog/.2.x.x/4166_add_mdc_key_filter.xml | 14 +++
.../ROOT/pages/manual/json-template-layout.adoc | 34 +++++-
7 files changed, 342 insertions(+), 5 deletions(-)
diff --git
a/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolverTest.java
b/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolverTest.java
index 422514e9f4..10b749c115 100644
---
a/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolverTest.java
+++
b/log4j-layout-template-json-test/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolverTest.java
@@ -26,6 +26,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.regex.PatternSyntaxException;
import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.DefaultConfiguration;
import org.apache.logging.log4j.core.impl.Log4jLogEvent;
import org.apache.logging.log4j.layout.template.json.JsonTemplateLayout;
import org.apache.logging.log4j.message.Message;
@@ -386,4 +387,124 @@ class ReadOnlyStringMapResolverTest {
assertThat(accessor.getString("stringifiedValue")).isEqualTo(String.valueOf(value));
});
}
+
+ @Test
+ void test_key_filter_disallowed() {
+
+ final String eventTemplate = "" + "{\n"
+ + " \"$resolver\": \"mdc\",\n"
+ + " \"key\": {\n"
+ + " \"disallowed\": [\"@timestamp\", \"message\",
\"log.logger\"]\n"
+ + " }\n"
+ + "}";
+
+ final String serializedJson = serializeContextData(eventTemplate);
+
+ assertThat(serializedJson).contains("\"allowedKey1\":\"value1\"");
+ assertThat(serializedJson).contains("\"allowedKey2\":\"value2\"");
+ assertThat(serializedJson).doesNotContain("\"message\"");
+ assertThat(serializedJson).doesNotContain("\"@timestamp\"");
+ assertThat(serializedJson).doesNotContain("this should be hidden");
+ }
+
+ @Test
+ void test_key_filter_allowed() {
+
+ final String eventTemplate = "" + "{\n"
+ + " \"$resolver\": \"mdc\",\n"
+ + " \"key\": {\n"
+ + " \"allowed\": [\"allowedKey1\", \"allowedKey2\"]\n"
+ + " }\n"
+ + "}";
+
+ final String serializedJson = serializeContextData(eventTemplate);
+
+ assertThat(serializedJson).contains("\"allowedKey1\":\"value1\"");
+ assertThat(serializedJson).contains("\"allowedKey2\":\"value2\"");
+ assertThat(serializedJson).doesNotContain("\"message\"");
+ assertThat(serializedJson).doesNotContain("\"@timestamp\"");
+ assertThat(serializedJson).doesNotContain("this should be hidden");
+ }
+
+ @Test
+ void test_key_filter_allowed_and_disallowed() {
+
+ final String eventTemplate = "" + "{\n"
+ + " \"$resolver\": \"mdc\",\n"
+ + " \"key\": {\n"
+ + " \"allowed\": [\"allowedKey1\", \"allowedKey2\"],\n"
+ + " \"disallowed\": [\"allowedKey2\"]\n"
+ + " }\n"
+ + "}";
+
+ final String serializedJson = serializeContextData(eventTemplate);
+
+ assertThat(serializedJson).contains("\"allowedKey1\":\"value1\"");
+ assertThat(serializedJson).doesNotContain("\"allowedKey2\"");
+ }
+
+ @Test
+ void test_key_filter_combined_with_pattern() {
+
+ final String eventTemplate = "" + "{\n"
+ + " \"$resolver\": \"mdc\",\n"
+ + " \"pattern\": \"allowedKey(1|2)\",\n"
+ + " \"replacement\": \"key$1\",\n"
+ + " \"key\": {\n"
+ + " \"disallowed\": [\"allowedKey2\"]\n"
+ + " }\n"
+ + "}";
+
+ final String serializedJson = serializeContextData(eventTemplate);
+
+ assertThat(serializedJson).contains("\"key1\":\"value1\"");
+ assertThat(serializedJson).doesNotContain("key2");
+ assertThat(serializedJson).doesNotContain("\"message\"");
+ }
+
+ @Test
+ void test_key_filter_combined_with_flatten() {
+
+ final String eventTemplate = "" + "{\n"
+ + " \"$resolver\": \"mdc\",\n"
+ + " \"flatten\": true,\n"
+ + " \"key\": {\n"
+ + " \"disallowed\": [\"message\"]\n"
+ + " }\n"
+ + "}";
+
+ final String serializedJson = serializeContextData(eventTemplate);
+
+ assertThat(serializedJson).contains("\"allowedKey1\":\"value1\"");
+ assertThat(serializedJson).doesNotContain("\"message\"");
+ }
+
+ @Test
+ void test_key_filter_invalid() {
+
+ final String eventTemplate = "" + "{\n" + " \"$resolver\":
\"mdc\",\n" + " \"key\": [\"message\"]\n" + "}";
+
+ Assertions.assertThatThrownBy(() ->
serializeContextData(eventTemplate))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("invalid key option");
+ }
+
+ private static String serializeContextData(final String eventTemplate) {
+
+ final StringMap contextData = new SortedArrayStringMap();
+ contextData.putValue("allowedKey1", "value1");
+ contextData.putValue("message", "this should be hidden");
+ contextData.putValue("@timestamp", "this should also be hidden");
+ contextData.putValue("allowedKey2", "value2");
+
+ final LogEvent logEvent =
+ Log4jLogEvent.newBuilder().setContextData(contextData).build();
+
+ final JsonTemplateLayout layout = JsonTemplateLayout.newBuilder()
+ .setConfiguration(new DefaultConfiguration())
+ .setEventTemplate(eventTemplate)
+ .build();
+
+ return layout.toSerializable(logEvent);
+ }
}
diff --git a/log4j-layout-template-json/pom.xml
b/log4j-layout-template-json/pom.xml
index 9b1351fae3..751f85fe09 100644
--- a/log4j-layout-template-json/pom.xml
+++ b/log4j-layout-template-json/pom.xml
@@ -44,7 +44,9 @@
<bnd-extra-package-options>
<!-- JCTools is optional (#1895) -->
org.jctools.*;resolution:=optional,
+ org.jspecify.*;resolution:=optional
</bnd-extra-package-options>
+
<bnd-extra-module-options>org.jspecify;transitive=false</bnd-extra-module-options>
<Fragment-Host>org.apache.logging.log4j.core</Fragment-Host>
</properties>
@@ -62,6 +64,12 @@
<optional>true</optional>
</dependency>
+ <dependency>
+ <groupId>org.jspecify</groupId>
+ <artifactId>jspecify</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
</dependencies>
</project>
diff --git
a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolver.java
b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolver.java
index 144525a06e..be2b20c918 100644
---
a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolver.java
+++
b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/ReadOnlyStringMapResolver.java
@@ -16,7 +16,11 @@
*/
package org.apache.logging.log4j.layout.template.json.resolver;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -26,6 +30,7 @@ import
org.apache.logging.log4j.layout.template.json.util.Recycler;
import org.apache.logging.log4j.layout.template.json.util.RecyclerFactory;
import org.apache.logging.log4j.util.ReadOnlyStringMap;
import org.apache.logging.log4j.util.TriConsumer;
+import org.jspecify.annotations.NonNull;
/**
* {@link ReadOnlyStringMap} resolver.
@@ -39,7 +44,11 @@ import org.apache.logging.log4j.util.TriConsumer;
* key = "key" -> string
* stringified = "stringified" -> boolean
*
- * multiAccess = [ pattern ] , [ replacement ] , [ flatten ] , [ stringified
]
+ * multiAccess = [ keyFilter ] , [ pattern ] , [ replacement ] , [ flatten ]
, [ stringified ]
+ * keyFilter = "key" -> keyConfig
+ * keyConfig = [ allowed ] , [ disallowed ]
+ * allowed = "allowed" -> array of strings
+ * disallowed = "disallowed" -> array of strings
* pattern = "pattern" -> string
* replacement = "replacement" -> string
* flatten = "flatten" -> ( boolean | flattenConfig )
@@ -60,6 +69,13 @@ import org.apache.logging.log4j.util.TriConsumer;
* These two are effectively equivalent to
* <tt>Pattern.compile(pattern).matcher(key).matches()</tt> and
* <tt>Pattern.compile(pattern).matcher(key).replaceAll(replacement)</tt>
calls.
+ * <p>
+ * <tt>key</tt> given as an object filters keys against literal names rather
+ * than a regex. If <tt>allowed</tt> is provided, only the listed keys are
+ * resolved. Keys listed in <tt>disallowed</tt> are silently dropped, and take
+ * precedence over <tt>allowed</tt>. Both are matched against the key as found
+ * in the map, that is, before <tt>replacement</tt> is applied, and both can be
+ * combined with <tt>pattern</tt>, which a key must then satisfy as well.
*
* <h3>Garbage Footprint</h3>
*
@@ -192,7 +208,21 @@ class ReadOnlyStringMapResolver implements EventResolver {
throw new IllegalArgumentException("invalid flatten option: " +
config);
}
final String prefix = config.getString(new String[] {"flatten",
"prefix"});
- final String key = config.getString("key");
+ final Object keyObject = config.getObject("key");
+ final String key;
+ final Set<String> allowedKeys;
+ final Set<String> disallowedKeys;
+ if (keyObject == null || keyObject instanceof Map) {
+ key = null;
+ allowedKeys = readKeyFilter(config, "allowed");
+ disallowedKeys = readKeyFilter(config, "disallowed");
+ } else if (keyObject instanceof String) {
+ key = (String) keyObject;
+ allowedKeys = Collections.emptySet();
+ disallowedKeys = Collections.emptySet();
+ } else {
+ throw new IllegalArgumentException("invalid key option: " +
config);
+ }
if (key != null && flatten) {
throw new IllegalArgumentException("key and flatten options cannot
be combined: " + config);
}
@@ -209,10 +239,24 @@ class ReadOnlyStringMapResolver implements EventResolver {
return createKeyResolver(key, stringified, mapAccessor);
} else {
final RecyclerFactory recyclerFactory =
context.getRecyclerFactory();
- return createResolver(recyclerFactory, flatten, prefix, pattern,
replacement, stringified, mapAccessor);
+ return createResolver(
+ recyclerFactory,
+ flatten,
+ prefix,
+ pattern,
+ replacement,
+ allowedKeys,
+ disallowedKeys,
+ stringified,
+ mapAccessor);
}
}
+ private static Set<String> readKeyFilter(final TemplateResolverConfig
config, final String filterName) {
+ final List<String> keys = config.getList(new String[] {"key",
filterName}, String.class);
+ return keys == null || keys.isEmpty() ? Collections.emptySet() : new
HashSet<>(keys);
+ }
+
private static EventResolver createKeyResolver(
final String key, final boolean stringified, final
Function<LogEvent, ReadOnlyStringMap> mapAccessor) {
return new EventResolver() {
@@ -243,6 +287,8 @@ class ReadOnlyStringMapResolver implements EventResolver {
final String prefix,
final String pattern,
final String replacement,
+ final Set<String> allowedKeys,
+ final Set<String> disallowedKeys,
final boolean stringified,
final Function<LogEvent, ReadOnlyStringMap> mapAccessor) {
@@ -258,6 +304,8 @@ class ReadOnlyStringMapResolver implements EventResolver {
}
loopContext.pattern = compiledPattern;
loopContext.replacement = replacement;
+ loopContext.allowedKeys = allowedKeys;
+ loopContext.disallowedKeys = disallowedKeys;
loopContext.stringified = stringified;
return loopContext;
});
@@ -331,6 +379,12 @@ class ReadOnlyStringMapResolver implements EventResolver {
private String replacement;
+ @NonNull
+ private Set<String> allowedKeys;
+
+ @NonNull
+ private Set<String> disallowedKeys;
+
private boolean stringified;
private JsonWriter jsonWriter;
@@ -345,6 +399,12 @@ class ReadOnlyStringMapResolver implements EventResolver {
@Override
public void accept(final String key, final Object value, final
LoopContext loopContext) {
+ if (!loopContext.allowedKeys.isEmpty() &&
!loopContext.allowedKeys.contains(key)) {
+ return;
+ }
+ if (loopContext.disallowedKeys.contains(key)) {
+ return;
+ }
final Matcher matcher = loopContext.pattern != null ?
loopContext.pattern.matcher(key) : null;
final boolean keyMatched = matcher == null || matcher.matches();
if (keyMatched) {
diff --git a/log4j-layout-template-json/src/main/resources/EcsLayout.json
b/log4j-layout-template-json/src/main/resources/EcsLayout.json
index 8d215ab56a..863e37aabc 100644
--- a/log4j-layout-template-json/src/main/resources/EcsLayout.json
+++ b/log4j-layout-template-json/src/main/resources/EcsLayout.json
@@ -26,7 +26,19 @@
"labels": {
"$resolver": "mdc",
"flatten": true,
- "stringified": true
+ "stringified": true,
+ "key": {
+ "disallowed": [
+ "@timestamp",
+ "message",
+ "log.logger",
+ "log.level",
+ "event.dataset",
+ "process.thread.name",
+ "process.thread.id",
+ "ecs.version"
+ ]
+ }
},
"tags": {
"$resolver": "ndc"
diff --git
a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/MdcKeyFilterBenchmark.java
b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/MdcKeyFilterBenchmark.java
new file mode 100644
index 0000000000..d04fc9cb1b
--- /dev/null
+++
b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/MdcKeyFilterBenchmark.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you 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
+ *
+ * http://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.apache.logging.log4j.perf.jmh;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.DefaultConfiguration;
+import org.apache.logging.log4j.core.impl.Log4jLogEvent;
+import org.apache.logging.log4j.layout.template.json.JsonTemplateLayout;
+import org.apache.logging.log4j.util.SortedArrayStringMap;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Fork(1)
+@State(Scope.Benchmark)
+public class MdcKeyFilterBenchmark {
+
+ private JsonTemplateLayout patternLayout;
+ private JsonTemplateLayout keyFilterLayout;
+ private LogEvent logEvent;
+
+ @Setup
+ public void setup() {
+
+ SortedArrayStringMap contextData = new SortedArrayStringMap();
+ contextData.putValue("userId", "12345");
+ contextData.putValue("transactionId", "tx-98765");
+ contextData.putValue("userRole", "admin");
+ contextData.putValue("@timestamp", "2026-07-22T10:15:30.123Z");
+ contextData.putValue("message", "Tx completed");
+ contextData.putValue("log.level", "INFO");
+
+ logEvent =
Log4jLogEvent.newBuilder().setContextData(contextData).build();
+
+ // Layout using regex pattern
+ String patternTemplate = "{" + "\"$resolver\": \"mdc\", "
+ + "\"pattern\":
\"^(?!@timestamp|message|log\\\\.logger|log\\\\.level|event\\\\.dataset|process\\\\.thread\\\\.name|process\\\\.thread\\\\.id|ecs\\\\.version).*$\""
+ + "}";
+
+ patternLayout = JsonTemplateLayout.newBuilder()
+ .setConfiguration(new DefaultConfiguration())
+ .setEventTemplate(patternTemplate)
+ .build();
+
+ String keyFilterTemplate = "{" + "\"$resolver\": \"mdc\", "
+ + "\"key\": {\"disallowed\": [\"@timestamp\", \"message\",
\"log.logger\", \"log.level\", \"event.dataset\", \"process.thread.name\",
\"process.thread.id\", \"ecs.version\"]}"
+ + "}";
+
+ keyFilterLayout = JsonTemplateLayout.newBuilder()
+ .setConfiguration(new DefaultConfiguration())
+ .setEventTemplate(keyFilterTemplate)
+ .build();
+ }
+
+ @Benchmark
+ public String testPatternResolver() {
+ return patternLayout.toSerializable(logEvent);
+ }
+
+ @Benchmark
+ public String testKeyFilterResolver() {
+ return keyFilterLayout.toSerializable(logEvent);
+ }
+}
diff --git a/src/changelog/.2.x.x/4166_add_mdc_key_filter.xml
b/src/changelog/.2.x.x/4166_add_mdc_key_filter.xml
new file mode 100644
index 0000000000..e9f6076563
--- /dev/null
+++ b/src/changelog/.2.x.x/4166_add_mdc_key_filter.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns="https://logging.apache.org/xml/ns"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="
+ https://logging.apache.org/xml/ns
+ https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
+ type="added">
+ <issue id="4166"
link="https://github.com/apache/logging-log4j2/issues/4166"/>
+ <issue id="4186"
link="https://github.com/apache/logging-log4j2/pull/4186"/>
+ <description format="asciidoc">
+ Add `key` based literal filtering to JSON Template Layout's Context
Data (MDC) and Map resolvers.
+ Harden `EcsLayout.json` using that.
+ </description>
+</entry>
\ No newline at end of file
diff --git
a/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc
b/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc
index 8fc7b16de4..756a1116eb 100644
--- a/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc
@@ -1435,7 +1435,11 @@ singleAccess = key , [ stringified ]
key = "key" -> string
stringified = "stringified" -> boolean
-multiAccess = [ pattern ] , [ replacement ] , [ flatten ] , [ stringified ]
+multiAccess = [ keyFilter ] , [ pattern ] , [ replacement ] , [ flatten ] ,
[ stringified ]
+keyFilter = "key" -> keyConfig
+keyConfig = [ allowed ] , [ disallowed ]
+allowed = "allowed" -> array of strings
+disallowed = "disallowed" -> array of strings
pattern = "pattern" -> string
replacement = "replacement" -> string
flatten = "flatten" -> ( boolean | flattenConfig )
@@ -1452,6 +1456,11 @@ Regex provided in the `pattern` is used to match against
the keys.
If provided, `replacement` will be used to replace the matched keys.
These two are analogous to `Pattern.compile(pattern).matcher(key).matches()`
and `Pattern.compile(pattern).matcher(key).replaceAll(replacement)` calls.
+`key` given as an object filters keys against literal names rather than a
regex.
+If `allowed` is provided, only the listed keys are resolved.
+Keys listed in `disallowed` are silently dropped, and take precedence over
`allowed`.
+Both are matched against the key as found in the map, that is, before
`replacement` is applied, and both can be combined with `pattern`, which a key
must then satisfy as well.
+
[WARNING]
====
Regarding garbage footprint, `stringified` flag translates to
`String.valueOf(value)`, hence mind values that are not `String`-typed.
@@ -1526,6 +1535,29 @@ Resolve all fields whose keys match with the
`user:(role|rank)` regex into an ob
"replacement": "$1"
}
----
+Resolve all fields into an object, but silently drop `message` and `log.level`:
+
+[source,json]
+----
+{
+ "$resolver": "…",
+ "key": {
+ "disallowed": ["message", "log.level"]
+ }
+}
+----
+
+Resolve only the `userId` and `userRole` fields into an object:
+
+[source,json]
+----
+{
+ "$resolver": "…",
+ "key": {
+ "allowed": ["userId", "userRole"]
+ }
+}
+----
Merge all fields whose keys are matching with the `user:(role|rank)` regex
into the parent: