You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by vy...@apache.org on 2021/05/10 10:32:52 UTC

[logging-log4j2] branch release-2.x updated: LOG4J2-3051 Add CaseConverterResolver to JsonTemplateLayout (#490)

This is an automated email from the ASF dual-hosted git repository.

vy pushed a commit to branch release-2.x
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git


The following commit(s) were added to refs/heads/release-2.x by this push:
     new 1dbe6e7  LOG4J2-3051 Add CaseConverterResolver to JsonTemplateLayout (#490)
1dbe6e7 is described below

commit 1dbe6e7c71953f74876a7fb09606906c4e149bf1
Author: Volkan Yazıcı <vo...@gmail.com>
AuthorDate: Mon May 10 12:32:43 2021 +0200

    LOG4J2-3051 Add CaseConverterResolver to JsonTemplateLayout (#490)
---
 .../json/resolver/CaseConverterResolver.java       | 309 +++++++++++++++++++++
 .../resolver/CaseConverterResolverFactory.java     |  34 +++
 .../json/resolver/TemplateResolverConfig.java      |  25 ++
 .../template/json/resolver/TemplateResolvers.java  |  17 +-
 .../template/json/resolver/TimestampResolver.java  |  16 +-
 .../layout/template/json/util/JsonReader.java      |  12 +-
 .../log4j/layout/template/json/EcsLayoutTest.java  |   6 +-
 .../log4j/layout/template/json/GelfLayoutTest.java |   6 +-
 .../log4j/layout/template/json/JsonLayoutTest.java |   6 +-
 .../template/json/JsonTemplateLayoutTest.java      |  58 +---
 .../template/json/LayoutComparisonHelpers.java     |  37 ---
 .../log4j/layout/template/json/TestHelpers.java    |  75 +++++
 .../json/resolver/CaseConverterResolverTest.java   | 125 +++++++++
 .../layout/template/json/util/JsonReaderTest.java  |   8 +
 src/changes/changes.xml                            |   3 +
 .../asciidoc/manual/json-template-layout.adoc.vm   | 131 ++++++++-
 src/site/xdoc/manual/configuration.xml.vm          |   2 +-
 17 files changed, 744 insertions(+), 126 deletions(-)

diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolver.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolver.java
new file mode 100644
index 0000000..a8e09be
--- /dev/null
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolver.java
@@ -0,0 +1,309 @@
+package org.apache.logging.log4j.layout.template.json.resolver;
+
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.layout.template.json.JsonTemplateLayoutDefaults;
+import org.apache.logging.log4j.layout.template.json.util.JsonReader;
+import org.apache.logging.log4j.layout.template.json.util.JsonWriter;
+
+import java.util.Locale;
+import java.util.function.Function;
+
+/**
+ * Converts the case of string values.
+ *
+ * <h3>Configuration</h3>
+ *
+ * <pre>
+ * config                = case , input , [ locale ] , [ errorHandlingStrategy ]
+ * input                 = JSON
+ * case                  = "case" -> ( "upper" | "lower" )
+ * locale                = "locale" -> (
+ *                             language                                   |
+ *                           ( language , "_" , country )                 |
+ *                           ( language , "_" , country , "_" , variant )
+ *                         )
+ * errorHandlingStrategy = "errorHandlingStrategy" -> (
+ *                           "fail"    |
+ *                           "pass"    |
+ *                           "replace"
+ *                         )
+ * replacement           = "replacement" -> JSON
+ *
+ * </pre>
+ *
+ * {@code input} can be any available template value; e.g., a JSON literal,
+ * a lookup string, an object pointing to another resolver.
+ * <p>
+ * Unless provided, {@code locale} points to the one returned by
+ * {@link JsonTemplateLayoutDefaults#getLocale()}, which is configured by
+ * {@code log4j.layout.jsonTemplate.locale} system property and by default set
+ * to the default system locale.
+ * <p>
+ * {@code errorHandlingStrategy} determines the behavior when either the
+ * {@code input} doesn't resolve to a string value or case conversion throws an
+ * exception:
+ * <ul>
+ * <li>{@code fail} propagates the failure
+ * <li>{@code pass} causes the resolved value to be passed as is
+ * <li>{@code replace} suppresses the failure and replaces it with the
+ * {@code replacement}, which is set to {@code null} by default
+ * </ul>
+ * {@code errorHandlingStrategy} is set to {@code replace} by default.
+ * <p>
+ * Most of the time JSON logs are persisted to a storage solution
+ * (e.g., Elasticsearch) that keeps a statically-typed index on fields.
+ * Hence, if a field is always expected to be of type string, using non-string
+ * {@code replacement}s or {@code pass} in {@code errorHandlingStrategy} might
+ * result in type incompatibility issues at the storage level.
+ * <p>
+ * Unless the {@code input} value is {@code pass}ed intact or {@code replace}d,
+ * case conversion is not garbage-free.
+ *
+ * <h3>Examples</h3>
+ *
+ * Convert the resolved log level strings to upper-case:
+ *
+ * <pre>
+ * {
+ *   "$resolver": "caseConverter",
+ *   "case": "upper",
+ *   "input": {
+ *     "$resolver": "level",
+ *     "field": "name"
+ *   }
+ * }
+ * </pre>
+ *
+ * Convert the resolved {@code USER} environment variable to lower-case using
+ * {@code nl_NL} locale:
+ *
+ * <pre>
+ * {
+ *   "$resolver": "caseConverter",
+ *   "case": "lower",
+ *   "locale": "nl_NL",
+ *   "input": "${env:USER}"
+ * }
+ * </pre>
+ *
+ * Convert the resolved {@code sessionId} thread context data (MDC) to
+ * lower-case:
+ *
+ * <pre>
+ * {
+ *   "$resolver": "caseConverter",
+ *   "case": "lower",
+ *   "input": {
+ *     "$resolver": "mdc",
+ *     "key": "sessionId"
+ *   }
+ * }
+ * </pre>
+ *
+ * Above, if {@code sessionId} MDC resolves to a, say, number, case conversion
+ * will fail. Since {@code errorHandlingStrategy} is set to {@code replace} and
+ * {@code replacement} is set to {@code null} by default, the resolved value
+ * will be {@code null}. One can suppress this behavior and let the resolved
+ * {@code sessionId} number be left as is:
+ *
+ * <pre>
+ * {
+ *   "$resolver": "caseConverter",
+ *   "case": "lower",
+ *   "input": {
+ *     "$resolver": "mdc",
+ *     "key": "sessionId"
+ *   },
+ *   "errorHandlingStrategy": "pass"
+ * }
+ * </pre>
+ *
+ * or replace it with a custom string:
+ *
+ * <pre>
+ * {
+ *   "$resolver": "caseConverter",
+ *   "case": "lower",
+ *   "input": {
+ *     "$resolver": "mdc",
+ *     "key": "sessionId"
+ *   },
+ *   "errorHandlingStrategy": "replace"
+ *   "replacement": "unknown"
+ * }
+ * </pre>
+ */
+public final class CaseConverterResolver implements EventResolver {
+
+    private final TemplateResolver<LogEvent> inputResolver;
+
+    private final Function<String, String> converter;
+
+    private final ErrorHandlingStrategy errorHandlingStrategy;
+
+    private final TemplateResolver<LogEvent> replacementResolver;
+
+    private enum ErrorHandlingStrategy {
+
+        FAIL("fail"),
+
+        PASS("pass"),
+
+        REPLACE("replace");
+
+        private final String name;
+
+        ErrorHandlingStrategy(String name) {
+            this.name = name;
+        }
+
+    }
+
+    CaseConverterResolver(
+            final EventResolverContext context,
+            final TemplateResolverConfig config) {
+        this.inputResolver = createDelegate(context, config);
+        this.converter = createConverter(config);
+        this.errorHandlingStrategy = readErrorHandlingStrategy(config);
+        this.replacementResolver = createReplacement(context, config);
+    }
+
+    private static TemplateResolver<LogEvent> createDelegate(
+            final EventResolverContext context,
+            final TemplateResolverConfig config) {
+        Object delegateObject = config.getObject("input");
+        return TemplateResolvers.ofObject(context, delegateObject);
+    }
+
+    private static Function<String, String> createConverter(
+            final TemplateResolverConfig config) {
+        final Locale locale = config.getLocale("locale");
+        final String _case = config.getString("case");
+        if ("upper".equals(_case)) {
+            return input -> input.toUpperCase(locale);
+        } else if ("lower".equals(_case)) {
+            return input -> input.toLowerCase(locale);
+        } else {
+            throw new IllegalArgumentException("invalid case: " + config);
+        }
+    }
+
+    private static ErrorHandlingStrategy readErrorHandlingStrategy(
+            final TemplateResolverConfig config) {
+        final String strategyName = config.getString("errorHandlingStrategy");
+        if (strategyName == null) {
+            return ErrorHandlingStrategy.REPLACE;
+        }
+        for (ErrorHandlingStrategy strategy : ErrorHandlingStrategy.values()) {
+            if (strategy.name.equals(strategyName)) {
+                return strategy;
+            }
+        }
+        throw new IllegalArgumentException(
+                "illegal error handling strategy: " + config);
+    }
+
+    private static TemplateResolver<LogEvent> createReplacement(
+            final EventResolverContext context,
+            final TemplateResolverConfig config) {
+        Object replacementObject = config.getObject("replacement");
+        return TemplateResolvers.ofObject(context, replacementObject);
+    }
+
+    static String getName() {
+        return "caseConverter";
+    }
+
+    @Override
+    public boolean isFlattening() {
+        return inputResolver.isFlattening();
+    }
+
+    @Override
+    public boolean isResolvable() {
+        return inputResolver.isResolvable();
+    }
+
+    @Override
+    public boolean isResolvable(final LogEvent logEvent) {
+        return inputResolver.isResolvable(logEvent);
+    }
+
+    @Override
+    public void resolve(final LogEvent logEvent, final JsonWriter jsonWriter) {
+        final int startIndex = jsonWriter.getStringBuilder().length();
+        inputResolver.resolve(logEvent, jsonWriter);
+        convertCase(logEvent, jsonWriter, startIndex);
+    }
+
+    @Override
+    public void resolve(
+            final LogEvent logEvent,
+            final JsonWriter jsonWriter,
+            final boolean succeedingEntry) {
+        final int startIndex = jsonWriter.getStringBuilder().length();
+        inputResolver.resolve(logEvent, jsonWriter, succeedingEntry);
+        convertCase(logEvent, jsonWriter, startIndex);
+    }
+
+    private void convertCase(
+            final LogEvent logEvent,
+            final JsonWriter jsonWriter,
+            final int startIndex) {
+
+        // If the last emitted JSON token was a string, convert it.
+        final StringBuilder jsonWriterStringBuilder = jsonWriter.getStringBuilder();
+        final int endIndex = jsonWriterStringBuilder.length();
+        final boolean stringTyped = (startIndex + 1) < endIndex
+                && jsonWriterStringBuilder.charAt(startIndex) == '"'
+                && jsonWriterStringBuilder.charAt(endIndex - 1) == '"';
+        if (stringTyped) {
+            final String json = jsonWriterStringBuilder.substring(startIndex, endIndex);
+            convertCase(logEvent, jsonWriter, startIndex, json);
+        }
+
+        // Otherwise, see what we can do.
+        else if (ErrorHandlingStrategy.FAIL.equals(errorHandlingStrategy)) {
+            final String json = jsonWriterStringBuilder.substring(startIndex, endIndex);
+            throw new RuntimeException(
+                    "was expecting a string value, found: " + json);
+        } else if (ErrorHandlingStrategy.PASS.equals(errorHandlingStrategy)) {
+            // Do nothing.
+        } else if (ErrorHandlingStrategy.REPLACE.equals(errorHandlingStrategy)) {
+            jsonWriterStringBuilder.setLength(startIndex);
+            replacementResolver.resolve(logEvent, jsonWriter);
+        } else {
+            throw new AssertionError("should not have reached here");
+        }
+
+    }
+
+    private void convertCase(
+            final LogEvent logEvent,
+            final JsonWriter jsonWriter,
+            final int startIndex,
+            final String json) {
+        final StringBuilder jsonWriterStringBuilder = jsonWriter.getStringBuilder();
+        final String string = (String) JsonReader.read(json);
+        final String convertedString;
+        try {
+            convertedString = converter.apply(string);
+        } catch (final Exception error) {
+            if (ErrorHandlingStrategy.FAIL.equals(errorHandlingStrategy)) {
+                throw new RuntimeException(
+                        "case conversion failure for string: " + string,
+                        error);
+            } else if (ErrorHandlingStrategy.PASS.equals(errorHandlingStrategy)) {
+                return;
+            } else if (ErrorHandlingStrategy.REPLACE.equals(errorHandlingStrategy)) {
+                jsonWriterStringBuilder.setLength(startIndex);
+                replacementResolver.resolve(logEvent, jsonWriter);
+                return;
+            }
+            throw new AssertionError("should not have reached here");
+        }
+        jsonWriterStringBuilder.setLength(startIndex);
+        jsonWriter.writeString(convertedString);
+    }
+
+}
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverFactory.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverFactory.java
new file mode 100644
index 0000000..3d0395a
--- /dev/null
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverFactory.java
@@ -0,0 +1,34 @@
+package org.apache.logging.log4j.layout.template.json.resolver;
+
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.apache.logging.log4j.core.config.plugins.PluginFactory;
+
+/**
+ * {@link CaseConverterResolver} factory.
+ */
+@Plugin(name = "CaseConverterResolverFactory", category = TemplateResolverFactory.CATEGORY)
+public final class CaseConverterResolverFactory implements EventResolverFactory {
+
+    private static final CaseConverterResolverFactory INSTANCE =
+            new CaseConverterResolverFactory();
+
+    private CaseConverterResolverFactory() {}
+
+    @PluginFactory
+    public static CaseConverterResolverFactory getInstance() {
+        return INSTANCE;
+    }
+
+    @Override
+    public String getName() {
+        return CaseConverterResolver.getName();
+    }
+
+    @Override
+    public CaseConverterResolver create(
+            final EventResolverContext context,
+            final TemplateResolverConfig config) {
+        return new CaseConverterResolver(context, config);
+    }
+
+}
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverConfig.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverConfig.java
index 1ca0c9b..fc9d6df 100644
--- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverConfig.java
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverConfig.java
@@ -16,8 +16,11 @@
  */
 package org.apache.logging.log4j.layout.template.json.resolver;
 
+import org.apache.logging.log4j.layout.template.json.JsonTemplateLayoutDefaults;
 import org.apache.logging.log4j.layout.template.json.util.MapAccessor;
 
+import java.util.Arrays;
+import java.util.Locale;
 import java.util.Map;
 
 /**
@@ -61,4 +64,26 @@ public class TemplateResolverConfig extends MapAccessor {
         super(map);
     }
 
+    public Locale getLocale(final String key) {
+        final String[] path = {key};
+        return getLocale(path);
+    }
+
+    public Locale getLocale(final String[] path) {
+        final String spec = getString(path);
+        if (spec == null) {
+            return JsonTemplateLayoutDefaults.getLocale();
+        }
+        final String[] specFields = spec.split("_", 3);
+        switch (specFields.length) {
+            case 1: return new Locale(specFields[0]);
+            case 2: return new Locale(specFields[0], specFields[1]);
+            case 3: return new Locale(specFields[0], specFields[1], specFields[2]);
+        }
+        final String message = String.format(
+                "was expecting a locale at path %s: %s",
+                Arrays.asList(path), this);
+        throw new IllegalArgumentException(message);
+    }
+
 }
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolvers.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolvers.java
index 27a5b49..20ad802 100644
--- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolvers.java
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolvers.java
@@ -22,6 +22,7 @@ import org.apache.logging.log4j.layout.template.json.util.JsonWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
@@ -78,6 +79,10 @@ public final class TemplateResolvers {
             final C context,
             final String template) {
 
+        // Check arguments.
+        Objects.requireNonNull(context, "context");
+        Objects.requireNonNull(template, "template");
+
         // Read the template.
         Object node;
         try {
@@ -104,7 +109,7 @@ public final class TemplateResolvers {
 
     }
 
-    private static <V, C extends TemplateResolverContext<V, C>> TemplateResolver<V> ofObject(
+    static <V, C extends TemplateResolverContext<V, C>> TemplateResolver<V> ofObject(
             final C context,
             final Object object) {
         if (object == null) {
@@ -289,10 +294,14 @@ public final class TemplateResolvers {
 
     private static <V, C extends TemplateResolverContext<V, C>> TemplateResolver<V> ofResolver(
             final C context,
-            final Map<String, Object> map) {
+            final Map<String, Object> configMap) {
+
+        // Check arguments.
+        Objects.requireNonNull(context, "context");
+        Objects.requireNonNull(configMap, "configMap");
 
         // Extract the resolver name.
-        final Object resolverNameObject = map.get(RESOLVER_FIELD_NAME);
+        final Object resolverNameObject = configMap.get(RESOLVER_FIELD_NAME);
         if (!(resolverNameObject instanceof String)) {
             throw new IllegalArgumentException(
                     "invalid resolver name: " + resolverNameObject);
@@ -305,7 +314,7 @@ public final class TemplateResolvers {
         if (resolverFactory == null) {
             throw new IllegalArgumentException("unknown resolver: " + resolverName);
         }
-        final TemplateResolverConfig resolverConfig = new TemplateResolverConfig(map);
+        final TemplateResolverConfig resolverConfig = new TemplateResolverConfig(configMap);
         return resolverFactory.create(context, resolverConfig);
 
     }
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TimestampResolver.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TimestampResolver.java
index fece006..fe0e2ca 100644
--- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TimestampResolver.java
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TimestampResolver.java
@@ -236,7 +236,7 @@ public final class TimestampResolver implements EventResolver {
                 final TemplateResolverConfig config) {
             final String format = readFormat(config);
             final TimeZone timeZone = readTimeZone(config);
-            final Locale locale = readLocale(config);
+            final Locale locale = config.getLocale(new String[]{"pattern", "locale"});
             final FastDateFormat fastDateFormat =
                     FastDateFormat.getInstance(format, timeZone, locale);
             return new FormatResolverContext(timeZone, locale, fastDateFormat);
@@ -276,20 +276,6 @@ public final class TimestampResolver implements EventResolver {
             return TimeZone.getTimeZone(timeZoneId);
         }
 
-        private static Locale readLocale(final TemplateResolverConfig config) {
-            final String locale = config.getString(new String[]{"pattern", "locale"});
-            if (locale == null) {
-                return JsonTemplateLayoutDefaults.getLocale();
-            }
-            final String[] localeFields = locale.split("_", 3);
-            switch (localeFields.length) {
-                case 1: return new Locale(localeFields[0]);
-                case 2: return new Locale(localeFields[0], localeFields[1]);
-                case 3: return new Locale(localeFields[0], localeFields[1], localeFields[2]);
-            }
-            throw new IllegalArgumentException("invalid timestamp locale: " + config);
-        }
-
     }
 
     /**
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonReader.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonReader.java
index cfc3fdc..c1377f7 100644
--- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonReader.java
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/util/JsonReader.java
@@ -24,6 +24,7 @@ import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 /**
  * A simple JSON parser mapping tokens to basic Java types.
@@ -88,13 +89,16 @@ public final class JsonReader {
 
     private Object readToken;
 
-    private StringBuilder buffer = new StringBuilder();
+    private final StringBuilder buffer;
 
-    private JsonReader() {}
+    private JsonReader() {
+         this.buffer = new StringBuilder();
+    }
 
-    public static Object read(final String string) {
+    public static Object read(final String json) {
+        Objects.requireNonNull(json, "json");
         final JsonReader reader = new JsonReader();
-        return reader.read(new StringCharacterIterator(string));
+        return reader.read(new StringCharacterIterator(json));
     }
 
     private Object read(final CharacterIterator ci) {
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/EcsLayoutTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/EcsLayoutTest.java
index f58e3c4..6e59620 100644
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/EcsLayoutTest.java
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/EcsLayoutTest.java
@@ -30,7 +30,7 @@ import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
-import static org.apache.logging.log4j.layout.template.json.LayoutComparisonHelpers.renderUsing;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.serializeUsingLayout;
 
 class EcsLayoutTest {
 
@@ -100,12 +100,12 @@ class EcsLayoutTest {
 
     private static Map<String, Object> renderUsingJsonTemplateLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, JSON_TEMPLATE_LAYOUT);
+        return serializeUsingLayout(logEvent, JSON_TEMPLATE_LAYOUT);
     }
 
     private static Map<String, Object> renderUsingEcsLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, ECS_LAYOUT);
+        return serializeUsingLayout(logEvent, ECS_LAYOUT);
     }
 
 }
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/GelfLayoutTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/GelfLayoutTest.java
index 7601d44..45355d7 100644
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/GelfLayoutTest.java
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/GelfLayoutTest.java
@@ -30,7 +30,7 @@ import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
-import static org.apache.logging.log4j.layout.template.json.LayoutComparisonHelpers.renderUsing;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.serializeUsingLayout;
 
 class GelfLayoutTest {
 
@@ -86,12 +86,12 @@ class GelfLayoutTest {
 
     private static Map<String, Object> renderUsingJsonTemplateLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, JSON_TEMPLATE_LAYOUT);
+        return serializeUsingLayout(logEvent, JSON_TEMPLATE_LAYOUT);
     }
 
     private static Map<String, Object> renderUsingGelfLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, GELF_LAYOUT);
+        return serializeUsingLayout(logEvent, GELF_LAYOUT);
     }
 
     /**
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonLayoutTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonLayoutTest.java
index 90288f1..938b275 100644
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonLayoutTest.java
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonLayoutTest.java
@@ -27,7 +27,7 @@ import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
-import static org.apache.logging.log4j.layout.template.json.LayoutComparisonHelpers.renderUsing;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.serializeUsingLayout;
 
 class JsonLayoutTest {
 
@@ -76,12 +76,12 @@ class JsonLayoutTest {
 
     private static Map<String, Object> renderUsingJsonTemplateLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, JSON_TEMPLATE_LAYOUT);
+        return serializeUsingLayout(logEvent, JSON_TEMPLATE_LAYOUT);
     }
 
     private static Map<String, Object> renderUsingJsonLayout(
             final LogEvent logEvent) {
-        return renderUsing(logEvent, JSON_LAYOUT);
+        return serializeUsingLayout(logEvent, JSON_LAYOUT);
     }
 
 }
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonTemplateLayoutTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonTemplateLayoutTest.java
index d75e0ab..190f695 100644
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonTemplateLayoutTest.java
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/JsonTemplateLayoutTest.java
@@ -22,11 +22,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.Marker;
 import org.apache.logging.log4j.MarkerManager;
-import org.apache.logging.log4j.core.Layout;
 import org.apache.logging.log4j.core.LogEvent;
 import org.apache.logging.log4j.core.appender.SocketAppender;
 import org.apache.logging.log4j.core.config.Configuration;
-import org.apache.logging.log4j.core.config.DefaultConfiguration;
 import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
 import org.apache.logging.log4j.core.config.plugins.Plugin;
 import org.apache.logging.log4j.core.config.plugins.PluginFactory;
@@ -42,9 +40,7 @@ import org.apache.logging.log4j.layout.template.json.resolver.EventResolverFacto
 import org.apache.logging.log4j.layout.template.json.resolver.TemplateResolver;
 import org.apache.logging.log4j.layout.template.json.resolver.TemplateResolverConfig;
 import org.apache.logging.log4j.layout.template.json.resolver.TemplateResolverFactory;
-import org.apache.logging.log4j.layout.template.json.util.JsonReader;
 import org.apache.logging.log4j.layout.template.json.util.JsonWriter;
-import org.apache.logging.log4j.layout.template.json.util.MapAccessor;
 import org.apache.logging.log4j.message.Message;
 import org.apache.logging.log4j.message.MessageFactory;
 import org.apache.logging.log4j.message.ObjectMessage;
@@ -82,25 +78,20 @@ import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.CONFIGURATION;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.asMap;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.usingSerializedLogEventAccessor;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.writeJson;
 import static org.assertj.core.api.Assertions.assertThat;
 
 @SuppressWarnings("DoubleBraceInitialization")
 class JsonTemplateLayoutTest {
 
-    private static final Configuration CONFIGURATION = new DefaultConfiguration();
-
     private static final List<LogEvent> LOG_EVENTS = LogEventFixture.createFullLogEvents(5);
 
-    private static final JsonWriter JSON_WRITER = JsonWriter
-            .newBuilder()
-            .setMaxStringLength(10_000)
-            .setTruncatedStringSuffix("…")
-            .build();
-
     private static final ObjectMapper OBJECT_MAPPER = JacksonFixture.getObjectMapper();
 
     private static final String LOGGER_NAME = JsonTemplateLayoutTest.class.getSimpleName();
@@ -1863,7 +1854,6 @@ class JsonTemplateLayoutTest {
                 .build();
 
         // Check the serialized log event.
-        final String expectedClassName = JsonTemplateLayoutTest.class.getCanonicalName();
         usingSerializedLogEventAccessor(layout, logEvent, accessor -> Assertions
                 .assertThat(accessor.getString("customField"))
                 .matches("CustomValue-[0-9]+"));
@@ -2392,44 +2382,4 @@ class JsonTemplateLayoutTest {
 
     }
 
-    private static synchronized String writeJson(final Object value) {
-        final StringBuilder stringBuilder = JSON_WRITER.getStringBuilder();
-        stringBuilder.setLength(0);
-        try {
-            JSON_WRITER.writeValue(value);
-            return stringBuilder.toString();
-        } finally {
-            stringBuilder.setLength(0);
-        }
-    }
-
-    private static void usingSerializedLogEventAccessor(
-            final Layout<String> layout,
-            final LogEvent logEvent,
-            final Consumer<MapAccessor> accessorConsumer) {
-        final String serializedLogEventJson = layout.toSerializable(logEvent);
-        @SuppressWarnings("unchecked")
-        final Map<String, Object> deserializedLogEvent =
-                (Map<String, Object>) readJson(serializedLogEventJson);
-        final MapAccessor serializedLogEventAccessor = new MapAccessor(deserializedLogEvent);
-        accessorConsumer.accept(serializedLogEventAccessor);
-    }
-
-    private static Object readJson(final String json) {
-        return JsonReader.read(json);
-    }
-
-    private static Map<String, Object> asMap(final Object... pairs) {
-        final Map<String, Object> map = new LinkedHashMap<>();
-        if (pairs.length % 2 != 0) {
-            throw new IllegalArgumentException("odd number of arguments: " + pairs.length);
-        }
-        for (int i = 0; i < pairs.length; i += 2) {
-            final String key = (String) pairs[i];
-            final Object value = pairs[i + 1];
-            map.put(key, value);
-        }
-        return map;
-    }
-
 }
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/LayoutComparisonHelpers.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/LayoutComparisonHelpers.java
deleted file mode 100644
index 1c343c5..0000000
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/LayoutComparisonHelpers.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * 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.layout.template.json;
-
-import org.apache.logging.log4j.core.Layout;
-import org.apache.logging.log4j.core.LogEvent;
-import org.apache.logging.log4j.layout.template.json.util.JsonReader;
-
-import java.util.Map;
-
-final class LayoutComparisonHelpers {
-
-    private LayoutComparisonHelpers() {}
-
-    @SuppressWarnings("unchecked")
-    static Map<String, Object> renderUsing(
-            final LogEvent logEvent,
-            final Layout<String> layout) {
-        final String json = layout.toSerializable(logEvent);
-        return (Map<String, Object>) JsonReader.read(json);
-    }
-
-}
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/TestHelpers.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/TestHelpers.java
new file mode 100644
index 0000000..78404b6
--- /dev/null
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/TestHelpers.java
@@ -0,0 +1,75 @@
+package org.apache.logging.log4j.layout.template.json;
+
+import org.apache.logging.log4j.core.Layout;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.DefaultConfiguration;
+import org.apache.logging.log4j.layout.template.json.util.JsonReader;
+import org.apache.logging.log4j.layout.template.json.util.JsonWriter;
+import org.apache.logging.log4j.layout.template.json.util.MapAccessor;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public final class TestHelpers {
+
+    public static final Configuration CONFIGURATION = new DefaultConfiguration();
+
+    private static final JsonWriter JSON_WRITER = JsonWriter
+            .newBuilder()
+            .setMaxStringLength(10_000)
+            .setTruncatedStringSuffix("…")
+            .build();
+
+    private TestHelpers() {}
+
+    @SuppressWarnings("unchecked")
+    static Map<String, Object> serializeUsingLayout(
+            final LogEvent logEvent,
+            final Layout<String> layout) {
+        final String json = layout.toSerializable(logEvent);
+        return (Map<String, Object>) JsonReader.read(json);
+    }
+
+    public static synchronized String writeJson(final Object value) {
+        final StringBuilder stringBuilder = JSON_WRITER.getStringBuilder();
+        stringBuilder.setLength(0);
+        try {
+            JSON_WRITER.writeValue(value);
+            return stringBuilder.toString();
+        } finally {
+            stringBuilder.setLength(0);
+        }
+    }
+
+    public static void usingSerializedLogEventAccessor(
+            final Layout<String> layout,
+            final LogEvent logEvent,
+            final Consumer<MapAccessor> accessorConsumer) {
+        final String serializedLogEventJson = layout.toSerializable(logEvent);
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> deserializedLogEvent =
+                (Map<String, Object>) readJson(serializedLogEventJson);
+        final MapAccessor serializedLogEventAccessor = new MapAccessor(deserializedLogEvent);
+        accessorConsumer.accept(serializedLogEventAccessor);
+    }
+
+    public static Object readJson(final String json) {
+        return JsonReader.read(json);
+    }
+
+    public static Map<String, Object> asMap(final Object... pairs) {
+        final Map<String, Object> map = new LinkedHashMap<>();
+        if (pairs.length % 2 != 0) {
+            throw new IllegalArgumentException("odd number of arguments: " + pairs.length);
+        }
+        for (int i = 0; i < pairs.length; i += 2) {
+            final String key = (String) pairs[i];
+            final Object value = pairs[i + 1];
+            map.put(key, value);
+        }
+        return map;
+    }
+
+}
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverTest.java
new file mode 100644
index 0000000..a52659c
--- /dev/null
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/resolver/CaseConverterResolverTest.java
@@ -0,0 +1,125 @@
+package org.apache.logging.log4j.layout.template.json.resolver;
+
+import org.apache.logging.log4j.core.LogEvent;
+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.apache.logging.log4j.util.StringMap;
+import org.apache.logging.log4j.util.Strings;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.CONFIGURATION;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.asMap;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.readJson;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.usingSerializedLogEventAccessor;
+import static org.apache.logging.log4j.layout.template.json.TestHelpers.writeJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CaseConverterResolverTest {
+
+    @ParameterizedTest
+    @CsvSource({
+            // case     | locale    | input         | output
+            "upper"     + ",nl"     + ",ioz"        + ",IOZ",
+            "upper"     + ",nl"     + ",IOZ"        + ",IOZ",
+            "lower"     + ",nl"     + ",ioz"        + ",ioz",
+            "lower"     + ",nl"     + ",IOZ"        + ",ioz",
+            "upper"     + ",tr"     + ",ıiğüşöç"    + ",IİĞÜŞÖÇ",
+            "upper"     + ",tr"     + ",IİĞÜŞÖÇ"    + ",IİĞÜŞÖÇ",
+            "lower"     + ",tr"     + ",ıiğüşöç"    + ",ıiğüşöç",
+            "lower"     + ",tr"     + ",IİĞÜŞÖÇ"    + ",ıiğüşöç"
+    })
+    void test_upper(
+            final String case_,
+            final String locale,
+            final String input,
+            final String output) {
+
+        // Create the event template.
+        final String eventTemplate = writeJson(asMap(
+                "output", asMap(
+                        "$resolver", "caseConverter",
+                        "case", case_,
+                        "locale", locale,
+                        "input", asMap(
+                                "$resolver", "mdc",
+                                "key", "input"))));
+
+        // Create the layout.
+        final JsonTemplateLayout layout = JsonTemplateLayout
+                .newBuilder()
+                .setConfiguration(CONFIGURATION)
+                .setEventTemplate(eventTemplate)
+                .build();
+
+        // Create the log event.
+        final StringMap contextData = new SortedArrayStringMap();
+        contextData.putValue("input", input);
+        final LogEvent logEvent = Log4jLogEvent
+                .newBuilder()
+                .setContextData(contextData)
+                .build();
+
+        // Check the serialized event.
+        usingSerializedLogEventAccessor(layout, logEvent, accessor ->
+                assertThat(accessor.getString("output")).isEqualTo(output));
+
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            // failure message              | locale    | input     | strategy      | replacement   | output
+            ""                              + ",nl"     + ",1"      + ",pass"       + ",null"       + ",1",
+            ""                              + ",nl"     + ",[2]"    + ",pass"       + ",null"       + ",[2]",
+            "was expecting a string value"  + ",nl"     + ",1"      + ",fail"       + ",null"       + ",null",
+            ""                              + ",nl"     + ",1"      + ",replace"    + ",null"       + ",null",
+            ""                              + ",nl"     + ",1"      + ",replace"    + ",2"          + ",2",
+            ""                              + ",nl"     + ",1"      + ",replace"    + ",\"s\""      + ",\"s\""
+    })
+    void test_errorHandlingStrategy(
+            final String failureMessage,
+            final String locale,
+            final String inputJson,
+            final String errorHandlingStrategy,
+            final String replacementJson,
+            final String outputJson) {
+
+        // Parse arguments.
+        final Object input = readJson(inputJson);
+        final Object replacement = readJson(replacementJson);
+        final Object output = readJson(outputJson);
+
+        // Create the event template.
+        final String eventTemplate = writeJson(asMap(
+                "output", asMap(
+                        "$resolver", "caseConverter",
+                        "case", "lower",
+                        "locale", locale,
+                        "input", input,
+                        "errorHandlingStrategy", errorHandlingStrategy,
+                        "replacement", replacement)));
+
+        // Create the layout.
+        final JsonTemplateLayout layout = JsonTemplateLayout
+                .newBuilder()
+                .setConfiguration(CONFIGURATION)
+                .setEventTemplate(eventTemplate)
+                .build();
+
+        // Create the log event.
+        final LogEvent logEvent = Log4jLogEvent.newBuilder().build();
+
+        // Check the serialized event.
+        final boolean failureExpected = Strings.isNotBlank(failureMessage);
+        if (failureExpected) {
+            assertThatThrownBy(() -> layout.toSerializable(logEvent))
+                    .hasMessageContaining(failureMessage);
+        } else {
+            usingSerializedLogEventAccessor(layout, logEvent, accessor ->
+                    assertThat(accessor.getObject("output")).isEqualTo(output));
+        }
+    }
+
+}
diff --git a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonReaderTest.java b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonReaderTest.java
index aa7992e..2c35e5a 100644
--- a/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonReaderTest.java
+++ b/log4j-layout-template-json/src/test/java/org/apache/logging/log4j/layout/template/json/util/JsonReaderTest.java
@@ -28,6 +28,14 @@ import java.util.LinkedHashMap;
 class JsonReaderTest {
 
     @Test
+    void test_null() {
+        Assertions
+                .assertThatThrownBy(() -> JsonReader.read(null))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessage("json");
+    }
+
+    @Test
     void test_valid_null() {
         test("null", null);
         test("[null, null]", Arrays.asList(null, null));
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 32f569f..cfdaa03 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -31,6 +31,9 @@
     -->
     <release version="2.15.0" date="2021-MM-DD" description="GA Release 2.15.0">
       <!-- ADDS -->
+      <action issue="LOG4J2-3051" dev="vy" type="add">
+        Add CaseConverterResolver to JsonTemplateLayout.
+      </action>
       <action issue="LOG4J2-3064" dev="rgoers" type="add">
         Add Arbiters and SpringProfile plugin.
       </action>
diff --git a/src/site/asciidoc/manual/json-template-layout.adoc.vm b/src/site/asciidoc/manual/json-template-layout.adoc.vm
index c3a9e05..97e9a05 100644
--- a/src/site/asciidoc/manual/json-template-layout.adoc.vm
+++ b/src/site/asciidoc/manual/json-template-layout.adoc.vm
@@ -454,11 +454,136 @@ similar to the following:
 The complete list of available event template resolvers are provided below in
 detail.
 
+[#event-template-resolver-caseConverter]
+===== `caseConverter`
+
+[source]
+----
+config                = case , input , [ locale ] , [ errorHandlingStrategy ]
+input                 = JSON
+case                  = "case" -> ( "upper" | "lower" )
+locale                = "locale" -> (
+                            language                                   |
+                          ( language , "_" , country )                 |
+                          ( language , "_" , country , "_" , variant )
+                        )
+errorHandlingStrategy = "errorHandlingStrategy" -> (
+                          "fail"    |
+                          "pass"    |
+                          "replace"
+                        )
+replacement           = "replacement" -> JSON
+----
+
+Converts the case of string values.
+
+`input` can be any available template value;  e.g., a JSON literal, a lookup
+string, an object pointing to another resolver.
+
+Unless provided, `locale` points to the one returned by
+`JsonTemplateLayoutDefaults.getLocale()`, which is configured by
+`log4j.layout.jsonTemplate.locale` system property and by default set to the
+default system locale.
+
+`errorHandlingStrategy` determines the behavior when either the input doesn't
+resolve to a string value or case conversion throws an exception:
+
+* `fail` propagates the failure
+* `pass` causes the resolved value to be passed as is
+* `replace` suppresses the failure and replaces it with the `replacement`,
+which is set to `null` by default
+
+`errorHandlingStrategy` is set to `replace` by default.
+
+Most of the time JSON logs are persisted to a storage solution (e.g.,
+Elasticsearch) that keeps a statically-typed index on fields. Hence, if a field
+is always expected to be of type string, using non-string ``replacement``s or
+`pass` in `errorHandlingStrategy` might result in type incompatibility issues at
+the storage level.
+
+Unless the input value is ``pass``ed intact or ``replace``d, case conversion is
+not garbage-free.
+
+====== Examples
+
+Convert the resolved log level strings to upper-case:
+
+[source,json]
+----
+{
+  "$resolver": "caseConverter",
+  "case": "upper",
+  "input": {
+    "$resolver": "level",
+    "field": "name"
+  }
+}
+----
+
+Convert the resolved `USER` environment variable to lower-case using `nl_NL`
+locale:
+
+[source,json]
+----
+{
+  "$resolver": "caseConverter",
+  "case": "lower",
+  "locale": "nl_NL",
+  "input": "${dollar}{env:USER}"
+}
+----
+
+Convert the resolved `sessionId` thread context data (MDC) to lower-case:
+
+[source,json]
+----
+{
+  "$resolver": "caseConverter",
+  "case": "lower",
+  "input": {
+    "$resolver": "mdc",
+    "key": "sessionId"
+  }
+}
+----
+
+Above, if `sessionId` MDC resolves to a, say, number, case conversion will fail.
+Since `errorHandlingStrategy` is set to `replace` and replacement is set to
+`null` by default, the resolved value will be `null`. One can suppress this
+behavior  and let the resolved `sessionId` number be left as is:
+
+[source,json]
+----
+{
+  "$resolver": "caseConverter",
+  "case": "lower",
+  "input": {
+    "$resolver": "mdc",
+    "key": "sessionId"
+  },
+  "errorHandlingStrategy": "pass"
+}
+----
+
+or replace it with a custom string:
+
+[source,json]
+----
+{
+  "$resolver": "caseConverter",
+  "case": "lower",
+  "input": {
+    "$resolver": "mdc",
+    "key": "sessionId"
+  },
+  "errorHandlingStrategy": "replace",
+  "replacement": "unknown"
+}
+----
+
 [#event-template-resolver-endOfBatch]
 ===== `endOfBatch`
 
-Resolves `logEvent.isEndOfBatch()` boolean flag:
-
 [source,json]
 ----
 {
@@ -466,6 +591,8 @@ Resolves `logEvent.isEndOfBatch()` boolean flag:
 }
 ----
 
+Resolves `logEvent.isEndOfBatch()` boolean flag.
+
 [#event-template-resolver-exception]
 ===== `exception`
 
diff --git a/src/site/xdoc/manual/configuration.xml.vm b/src/site/xdoc/manual/configuration.xml.vm
index 0b6e77e..5530ac8 100644
--- a/src/site/xdoc/manual/configuration.xml.vm
+++ b/src/site/xdoc/manual/configuration.xml.vm
@@ -1904,7 +1904,7 @@ public class AwesomeTest {
       Available context selector implementation classes:<br />
     <!-- deliberately inserted spaces to allow line break -->
       <tt>org.apache.logging.log4j.core.async .AsyncLoggerContextSelector</tt> - makes <a href="async.html">all loggers asynchronous</a>.<br />
-      <tt>org.apache.logging.log4j.core.async .BasicAsyncLoggerContextSelector</tt> - makes <a href="async.html"> all loggers asynchronous using a single shared AsyncLoggerContext.</a><br />
+      <tt>org.apache.logging.log4j.core.async .BasicAsyncLoggerContextSelector</tt> - makes <a href="async.html">all loggers asynchronous</a> using a single shared AsyncLoggerContext.<br />
       <tt>org.apache.logging.log4j.core.selector .BasicContextSelector</tt> - creates a single shared LoggerContext.<br />
       <tt>org.apache.logging.log4j.core.selector .ClassLoaderContextSelector</tt> - separate LoggerContexts for each web application.<br />
       <tt>org.apache.logging.log4j.core.selector .JndiContextSelector</tt> - use JNDI to locate each web application's LoggerContext.<br/>