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

ramanathan1504 pushed a commit to branch fix-4129-locale-without-timezone
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 31e6e23f673ecfa161b90f4f10bf86b4732e9f9b
Author: Ramanathan <[email protected]>
AuthorDate: Mon May 25 23:42:49 2026 +0530

    Fix `DatePatternConverter` to correctly apply locale when timezone is 
omitted
---
 .../core/pattern/DatePatternConverterTestBase.java | 137 +++++++++++++++++++++
 .../log4j/core/pattern/DatePatternConverter.java   |  45 ++++++-
 ...atePatternConverter_locale_without_timezone.xml |  14 +++
 3 files changed, 194 insertions(+), 2 deletions(-)

diff --git 
a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java
 
b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java
index 16fd89ac30..236a5d109a 100644
--- 
a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java
+++ 
b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java
@@ -17,7 +17,9 @@
 package org.apache.logging.log4j.core.pattern;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.text.SimpleDateFormat;
 import java.time.ZoneId;
@@ -25,6 +27,7 @@ import java.time.format.DateTimeFormatter;
 import java.time.temporal.TemporalAccessor;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.Locale;
 import java.util.TimeZone;
 import java.util.stream.Stream;
 import org.apache.logging.log4j.core.AbstractLogEvent;
@@ -36,6 +39,8 @@ import org.apache.logging.log4j.util.Strings;
 import org.junit.jupiter.api.Test;
 
 abstract class DatePatternConverterTestBase {
+    // Mid-month instant to keep month-name assertions stable across all time 
zones.
+    private static final long LOCALE_TEST_EPOCH_MILLIS = 1705276800000L;
 
     private static final class MyLogEvent extends AbstractLogEvent {
         private static final long serialVersionUID = 0;
@@ -352,4 +357,136 @@ abstract class DatePatternConverterTestBase {
             assertEquals(expectedPattern, converter.getPattern());
         }
     }
+
+    /**
+     * Helper: format a fixed UTC instant through the given options.
+     * The epoch is 2024-01-01T00:00:00Z: January / Januar depending on locale.
+     */
+    private static String formatLocaleInstant(final String... options) {
+        final MutableInstant instant = new MutableInstant();
+        instant.initFromEpochMilli(LOCALE_TEST_EPOCH_MILLIS, 0);
+        final DatePatternConverter converter = 
DatePatternConverter.newInstance(options);
+        final StringBuilder sb = new StringBuilder();
+        converter.format(instant, sb);
+        return sb.toString();
+    }
+
+    @Test
+    void testIsLocaleOptionDetectsLocaleWithRegion() {
+        assertTrue(DatePatternConverter.isLocaleOption("de-DE"));
+        assertTrue(DatePatternConverter.isLocaleOption("fr-FR"));
+        assertTrue(DatePatternConverter.isLocaleOption("zh-Hans-CN"));
+        assertTrue(DatePatternConverter.isLocaleOption("en-US"));
+    }
+
+    @Test
+    void testIsLocaleOptionDetectsLanguageOnlyLocale() {
+        assertTrue(DatePatternConverter.isLocaleOption("de"));
+        assertTrue(DatePatternConverter.isLocaleOption("fr"));
+        assertTrue(DatePatternConverter.isLocaleOption("zh"));
+    }
+
+    @Test
+    void testIsLocaleOptionRejectsTimezones() {
+        assertFalse(DatePatternConverter.isLocaleOption("UTC"));
+        assertFalse(DatePatternConverter.isLocaleOption("GMT"));
+        assertFalse(DatePatternConverter.isLocaleOption("JST"));
+        assertFalse(DatePatternConverter.isLocaleOption("PST"));
+        assertFalse(DatePatternConverter.isLocaleOption("utc"));
+        assertFalse(DatePatternConverter.isLocaleOption("gmt"));
+        assertFalse(DatePatternConverter.isLocaleOption("pst"));
+        assertFalse(DatePatternConverter.isLocaleOption("America/New_York"));
+        assertFalse(DatePatternConverter.isLocaleOption("Etc/GMT+5"));
+        assertFalse(DatePatternConverter.isLocaleOption("GB-Eire"));
+        assertFalse(DatePatternConverter.isLocaleOption("gb-eire"));
+        assertFalse(DatePatternConverter.isLocaleOption("NZ-CHAT"));
+        assertFalse(DatePatternConverter.isLocaleOption(null));
+    }
+
+    @Test
+    void testHyphenatedTimezonesAreNotTreatedAsLocales() {
+        assertEquals(
+                TimeZone.getTimeZone("GB-Eire"),
+                DatePatternConverter.newInstance(new String[] {"ISO8601", 
"GB-Eire"})
+                        .getTimeZone());
+        assertEquals(
+                TimeZone.getTimeZone("NZ-CHAT"),
+                DatePatternConverter.newInstance(new String[] {"ISO8601", 
"NZ-CHAT"})
+                        .getTimeZone());
+    }
+
+    /**
+     * %d{dd-MMMM-yyyy}{GMT}{de-DE}: three args, always worked.
+     * Verify baseline still produces German month name.
+     */
+    @Test
+    void testLocaleAppliedWhenTimezoneAndLocaleBothProvided() {
+        final String result = formatLocaleInstant("dd-MMMM-yyyy", "GMT", 
"de-DE");
+        assertTrue(
+                result.contains("Januar"),
+                () -> "Expected German month 'Januar' with 
[pattern][GMT][de-DE], got: " + result);
+    }
+
+    /**
+     * Locale-only argument should work generally across multiple locales.
+     */
+    @Test
+    void testLocaleAppliedWhenTimezoneOmitted_monthName_multipleLocales() {
+        final String[][] localeCases = {
+            {"de-DE", "januar"},
+            {"fr-FR", "janvier"},
+            {"es-ES", "enero"},
+            {"it-IT", "gennaio"},
+            {"pt-BR", "janeiro"}
+        };
+        for (final String[] localeCase : localeCases) {
+            final String localeTag = localeCase[0];
+            final String expectedMonth = localeCase[1];
+            final String result = formatLocaleInstant("dd-MMMM-yyyy", 
localeTag);
+            final String lowerCaseResult = result.toLowerCase(Locale.ROOT);
+            assertTrue(
+                    lowerCaseResult.contains(expectedMonth),
+                    () -> "Expected month '" + expectedMonth + "' with 
[pattern][" + localeTag
+                            + "] (no timezone), got: " + result);
+            assertFalse(
+                    lowerCaseResult.contains("january"),
+                    () -> "locale '" + localeTag + "' must not produce English 
'January', got: " + result);
+        }
+    }
+
+    @Test
+    void testLocaleAppliedWhenTimezoneOmitted_languageOnlyTag() {
+        final String result = formatLocaleInstant("dd-MMMM-yyyy", "de");
+        assertTrue(
+                result.contains("Januar"),
+                () -> "Expected German month 'Januar' with [pattern][de] (no 
timezone), got: " + result);
+        assertFalse(result.contains("January"), () -> "locale 'de' must not 
produce English 'January', got: " + result);
+    }
+
+    /**
+     * %d{EEEE, dd. MMMM yyyy}{de-DE}: full date with day-of-week and locale 
only.
+     * Regression: locale was silently ignored.
+     * Fix: German day-of-week and month must be used.
+     * See: https://github.com/apache/logging-log4j2/issues/4129
+     */
+    @Test
+    void testLocaleAppliedWhenTimezoneOmitted_fullDate() {
+        final String result = formatLocaleInstant("EEEE, dd. MMMM yyyy", 
"de-DE");
+        assertTrue(
+                result.contains("Januar"),
+                () -> "Expected German month 'Januar' with full-date pattern + 
de-DE locale, got: " + result);
+        assertTrue(
+                result.contains("Montag"),
+                () -> "Expected German day-of-week 'Montag' with full-date 
pattern + de-DE locale, got: " + result);
+    }
+
+    /**
+     * When second arg is a locale tag, the effective timezone must remain the 
JVM default:
+     * NOT be interpreted as the bogus GMT that {@code 
TimeZone.getTimeZone("de-DE")} returns.
+     */
+    @Test
+    void testDefaultTimezoneUsedWhenOnlyLocaleProvided() {
+        final DatePatternConverter converter = 
DatePatternConverter.newInstance(new String[] {"ISO8601", "de-DE"});
+        assertEquals(TimeZone.getDefault(), converter.getTimeZone());
+    }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java
index 8b598c3de8..cfce19ba9a 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java
@@ -21,7 +21,9 @@ import static java.util.Objects.requireNonNull;
 import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.Locale;
+import java.util.Set;
 import java.util.TimeZone;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.time.FastDateFormat;
@@ -49,6 +51,13 @@ public final class DatePatternConverter extends 
LogEventPatternConverter impleme
 
     private static final String CLASS_NAME = 
DatePatternConverter.class.getSimpleName();
 
+    private static final Set<String> AVAILABLE_TIME_ZONE_IDS = new 
HashSet<>(Arrays.asList(TimeZone.getAvailableIDs()));
+    private static final Set<String> UPPERCASE_TIME_ZONE_IDS = 
AVAILABLE_TIME_ZONE_IDS.stream()
+            .map(id -> id.toUpperCase(Locale.ROOT))
+            .collect(Collectors.toSet());
+    private static final String LOCALE_LANGUAGE_ONLY_PATTERN = "[a-zA-Z]{2}";
+    private static final String LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN = 
"[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})+";
+
     private final InstantFormatter formatter;
 
     private DatePatternConverter(@Nullable final String[] options) {
@@ -123,7 +132,7 @@ public final class DatePatternConverter extends 
LogEventPatternConverter impleme
 
     private static TimeZone readTimeZone(@Nullable final String[] options) {
         try {
-            if (options != null && options.length > 1 && options[1] != null) {
+            if (options != null && options.length > 1 && options[1] != null && 
!isLocaleOption(options[1])) {
                 return TimeZone.getTimeZone(options[1]);
             }
         } catch (final Exception error) {
@@ -140,15 +149,47 @@ public final class DatePatternConverter extends 
LogEventPatternConverter impleme
             if (options != null && options.length > 2 && options[2] != null) {
                 return Locale.forLanguageTag(options[2]);
             }
+            if (options != null && options.length > 1 && options[1] != null && 
isLocaleOption(options[1])) {
+                return Locale.forLanguageTag(options[1]);
+            }
         } catch (final Exception error) {
             logOptionReadFailure(
                     options,
                     error,
-                    "failed to read the locale at index 2 of options: {}, 
falling back to the default locale");
+                    "failed to read the locale at index 1 or 2 of options: {}, 
falling back to the default locale");
         }
         return Locale.getDefault();
     }
 
+    /**
+     * Returns {@code true} if the given string looks like a locale tag and is 
not a timezone ID.
+     *
+     * <p>Supported locale forms include language-only tags ({@code de}), full 
BCP 47 tags ({@code de-DE},
+     * {@code zh-Hans-CN}), and locale strings using underscore separators 
({@code de_DE}). Time zone IDs
+     * always take precedence, including legacy IDs such as {@code GB-Eire} 
and case variants like {@code utc}.</p>
+     *
+     * @param value the string to test
+     * @return {@code true} if {@code value} is recognized as a locale tag and 
not as a timezone ID
+     */
+    static boolean isLocaleOption(@Nullable final String value) {
+        if (value == null || isTimeZoneOption(value)) {
+            return false;
+        }
+        final boolean localeShape =
+                value.matches(LOCALE_LANGUAGE_ONLY_PATTERN) || 
value.matches(LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN);
+        if (!localeShape) {
+            return false;
+        }
+        final Locale locale = Locale.forLanguageTag(value.replace('_', '-'));
+        final String language = locale.getLanguage();
+        return !language.isEmpty() && !"und".equals(language);
+    }
+
+    private static boolean isTimeZoneOption(final String value) {
+        return AVAILABLE_TIME_ZONE_IDS.contains(value)
+                || 
UPPERCASE_TIME_ZONE_IDS.contains(value.toUpperCase(Locale.ROOT));
+    }
+
     private static void logOptionReadFailure(final String[] options, final 
Exception error, final String message) {
         if (LOGGER.isWarnEnabled()) {
             final String quotedOptions =
diff --git 
a/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml
 
b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml
new file mode 100644
index 0000000000..2ed7b364ee
--- /dev/null
+++ 
b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.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="fixed">
+  <issue id="4129" 
link="https://github.com/apache/logging-log4j2/issues/4129"/>
+  <description format="asciidoc">
+    Fix `DatePatternConverter` to treat `%d{pattern}{de-DE}` as locale when 
timezone is omitted.
+    `%d{pattern}{timezone}{locale}` behavior is unchanged.
+  </description>
+</entry>
+

Reply via email to