diff --git a/java-sdk-logging/log4j2-extension/pom.xml b/java-sdk-logging/log4j2-extension/pom.xml
index 6d8b6c79e4..020d9a6329 100644
--- a/java-sdk-logging/log4j2-extension/pom.xml
+++ b/java-sdk-logging/log4j2-extension/pom.xml
@@ -13,9 +13,22 @@
UTF-8
+ 2.24.3
+ 2.12.1
+
+ org.apache.logging.log4j
+ log4j-core
+ ${log4j.version}
+ true
+
+
+ com.google.code.gson
+ gson
+ ${gson.version}
+
org.junit.jupiter
junit-jupiter-api
diff --git a/java-sdk-logging/log4j2-extension/src/main/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayout.java b/java-sdk-logging/log4j2-extension/src/main/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayout.java
new file mode 100644
index 0000000000..51f2c94c27
--- /dev/null
+++ b/java-sdk-logging/log4j2-extension/src/main/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayout.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.cloud.sdk.logging;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.logging.log4j.core.Layout;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.Node;
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute;
+import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
+import org.apache.logging.log4j.core.layout.AbstractStringLayout;
+
+@Plugin(
+ name = "SDKLoggingJsonLayout",
+ category = Node.CATEGORY,
+ elementType = Layout.ELEMENT_TYPE,
+ printObject = true)
+public final class SDKLoggingJsonLayout extends AbstractStringLayout {
+
+ private final Gson gson = new Gson();
+ private static final String TIME_STAMP = "timestamp";
+ private static final String LEVEL = "level";
+ private static final String LOGGER_NAME = "logger_name";
+ private static final String THREAD_NAME = "thread_name";
+ private static final String THREAD_ID = "thread_ID";
+ private static final String MESSAGE = "message";
+
+ private SDKLoggingJsonLayout(final Builder builder) {
+ super(builder.getCharset());
+ }
+
+ @Override
+ public String toSerializable(LogEvent event) {
+ // use LinkedHashMap to fix iteration order.
+ Map jsonMap = new LinkedHashMap<>();
+ extractNonMdc(event, jsonMap);
+
+ Map mdcMap = new LinkedHashMap<>(event.getContextData().toMap());
+ for (Map.Entry entry : mdcMap.entrySet()) {
+ String key = entry.getKey();
+ String value = entry.getValue();
+ if (key == null || value == null) {
+ continue;
+ }
+
+ try {
+ jsonMap.put(key, toJsonElement(value));
+ } catch (JsonParseException e) {
+ // in case of conversion exception, just use String
+ jsonMap.put(key, value);
+ }
+ }
+
+ return String.format("%s\n", gson.toJson(jsonMap));
+ }
+
+ private void extractNonMdc(LogEvent event, Map jsonMap) {
+ jsonMap.put(TIME_STAMP, event.getTimeMillis());
+ jsonMap.put(LEVEL, event.getLevel().toString());
+ jsonMap.put(LOGGER_NAME, event.getLoggerName());
+ jsonMap.put(THREAD_NAME, event.getThreadName());
+ jsonMap.put(THREAD_ID, event.getThreadId());
+ jsonMap.put(MESSAGE, event.getMessage().getFormattedMessage());
+ }
+
+ private JsonElement toJsonElement(String jsonString) throws JsonParseException {
+ return JsonParser.parseString(jsonString);
+ }
+
+ @PluginBuilderFactory
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder
+ implements org.apache.logging.log4j.core.util.Builder {
+
+ @PluginBuilderAttribute private Charset charset = StandardCharsets.UTF_8;
+
+ public Charset getCharset() {
+ return charset;
+ }
+
+ public Builder setCharset(final Charset charset) {
+ this.charset = charset;
+ return this;
+ }
+
+ @Override
+ public SDKLoggingJsonLayout build() {
+ return new SDKLoggingJsonLayout(this);
+ }
+ }
+}
diff --git a/java-sdk-logging/log4j2-extension/src/test/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayoutTest.java b/java-sdk-logging/log4j2-extension/src/test/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayoutTest.java
new file mode 100644
index 0000000000..044707ac25
--- /dev/null
+++ b/java-sdk-logging/log4j2-extension/src/test/java/com/google/cloud/sdk/logging/SDKLoggingJsonLayoutTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.cloud.sdk.logging;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gson.JsonParser;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.message.SimpleMessage;
+import org.apache.logging.log4j.util.ReadOnlyStringMap;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class SDKLoggingJsonLayoutTest {
+ private final SDKLoggingJsonLayout sdkLoggingJsonLayout =
+ SDKLoggingJsonLayout.newBuilder().build();
+ private final LogEvent logEvent = mock(LogEvent.class);
+ private final ReadOnlyStringMap map = mock(ReadOnlyStringMap.class);
+ private final Map mdcMap = new HashMap<>();
+
+ @BeforeEach
+ void init() {
+ when(logEvent.getTimeMillis()).thenReturn(10000L);
+ when(logEvent.getLevel()).thenReturn(Level.DEBUG);
+ when(logEvent.getLoggerName()).thenReturn("com.example.Example");
+ when(logEvent.getThreadName()).thenReturn("example thread name");
+ when(logEvent.getThreadId()).thenReturn(123L);
+ when(logEvent.getMessage()).thenReturn(new SimpleMessage("example message"));
+ when(logEvent.getContextData()).thenReturn(map);
+ when(map.toMap()).thenReturn(mdcMap);
+ }
+
+ @Test
+ void testToSerializableContainsNonMdcContents() {
+ assertEquals(
+ "{\"timestamp\":10000,\"level\":\"DEBUG\",\"logger_name\":\"com.example.Example\",\"thread_name\":\"example thread name\",\"thread_ID\":123,\"message\":\"example message\"}\n",
+ sdkLoggingJsonLayout.toSerializable(logEvent));
+ }
+
+ @Test
+ void testToSerializableSkipNullKey() {
+ mdcMap.put(null, "example value");
+ String logString = sdkLoggingJsonLayout.toSerializable(logEvent);
+ assertFalse(logString.contains("example value"));
+ }
+
+ @Test
+ void testToSerializableSkipNullValue() {
+ mdcMap.put("example key", null);
+ String logString = sdkLoggingJsonLayout.toSerializable(logEvent);
+ assertFalse(logString.contains("example key"));
+ }
+
+ @Test
+ void testToSerializableInvalidJsonValueWriteString() {
+ // the last colon is invalid.
+ mdcMap.put("example key", "{key:value,jsonKey:{nestedKey:nestedValue,}}");
+ assertEquals(
+ "{\"timestamp\":10000,\"level\":\"DEBUG\",\"logger_name\":\"com.example.Example\",\"thread_name\":\"example thread name\",\"thread_ID\":123,\"message\":\"example message\",\"example key\":\"{key:value,jsonKey:{nestedKey:nestedValue,}}\"}\n",
+ sdkLoggingJsonLayout.toSerializable(logEvent));
+ }
+
+ @Test
+ void testToSerializableValidJsonValueWriteJson() {
+ // the last colon is invalid.
+ mdcMap.put("example key", "{key:value,jsonKey:{nestedKey:nestedValue}}");
+ String log = sdkLoggingJsonLayout.toSerializable(logEvent);
+ assertEquals(
+ "{\"timestamp\":10000,\"level\":\"DEBUG\",\"logger_name\":\"com.example.Example\",\"thread_name\":\"example thread name\",\"thread_ID\":123,\"message\":\"example message\",\"example key\":{\"key\":\"value\",\"jsonKey\":{\"nestedKey\":\"nestedValue\"}}}\n",
+ log);
+ assertDoesNotThrow(() -> JsonParser.parseString(log));
+ }
+}