loggingEventEnhancerClassNames = new HashSet<>();
+ private LogbackBatchingSettings logbackBatchingSettings = null;
+
+ /**
+ * Sets a threshold for log severity level to flush all log entries that were batched so far.
+ *
+ * Defaults to OFF.
+ *
+ * @param flushLevel Logback log level
+ */
+ public void setFlushLevel(Level flushLevel) {
+ this.flushLevel = flushLevel;
+ }
+
+ /**
+ * Sets the LOG_ID part of the log
+ * name for which the logs are ingested.
+ *
+ * @param log LOG_ID part of the name
+ */
+ public void setLog(String log) {
+ this.log = log;
+ }
+
+ /**
+ * Sets the name of the monitored resource (Optional). If not define the appender will try to
+ * identify the resource type automatically. Currently support resource types include "gae_app",
+ * "gce_instance", "k8s_container", "cloud_run_revision" and "cloud_function". If the appender
+ * fails to identify the resource type, it will be set to "global".
+ *
+ *
Must be a one of the supported resource types.
+ *
+ * @param resourceType the name of the monitored resource.
+ */
+ public void setResourceType(String resourceType) {
+ this.resourceType = resourceType;
+ }
+
+ /**
+ * This method is obsolete because of a potential security risk. Use the {@link
+ * #setCredentials(GoogleCredentials)} method instead.
+ *
+ *
If you know that you will be loading credential configurations of a specific type, it is
+ * recommended to use a credential-type-specific `fromStream()` method. This will ensure that an
+ * unexpected credential type with potential for malicious intent is not loaded unintentionally.
+ * You might still have to do validation for certain credential types. Please follow the
+ * recommendation for that method.
+ *
+ *
If you are loading your credential configuration from an untrusted source and have not
+ * mitigated the risks (e.g. by validating the configuration yourself), make these changes as soon
+ * as possible to prevent security risks to your environment.
+ *
+ *
Regardless of the method used, it is always your responsibility to validate configurations
+ * received from external sources.
+ *
+ *
Sets the path to the credential
+ * file. If not set the appender will use {@link GoogleCredentials#getApplicationDefault()} to
+ * authenticate.
+ *
+ * @param credentialsFile the path to the credentials file.
+ */
+ @ObsoleteApi(
+ "This method is obsolete because of a potential security risk. Use the setCredentials() method instead")
+ public void setCredentialsFile(String credentialsFile) {
+ this.credentialsFile = credentialsFile;
+ }
+
+ /**
+ * Sets the credential to use. If not set the appender will use {@link
+ * GoogleCredentials#getApplicationDefault()} to authenticate.
+ *
+ * @param credentials the GoogleCredentials to set
+ */
+ public void setCredentials(GoogleCredentials credentials) {
+ Preconditions.checkNotNull(credentials, "Credentials cannot be null");
+ this.credentials = credentials;
+ }
+
+ /**
+ * Sets project ID to be used to customize log destination name for written log entries.
+ *
+ * @param projectId The project ID to be used to construct the resource destination name for log
+ * entries.
+ */
+ public void setLogDestinationProjectId(String projectId) {
+ this.logDestinationProjectId = projectId;
+ }
+
+ /**
+ * Sets the log ingestion mode. It can be one of the {@link Synchronicity} values.
+ *
+ *
Default to {@code Synchronicity.ASYNC}
+ *
+ * @param flag the new ingestion mode.
+ */
+ public void setWriteSynchronicity(Synchronicity flag) {
+ this.writeSyncFlag = flag;
+ }
+
+ /**
+ * Sets the automatic population of metadata fields for ingested logs.
+ *
+ *
Default to {@code true}.
+ *
+ * @param flag the metadata auto-population flag.
+ */
+ public void setAutoPopulateMetadata(boolean flag) {
+ autoPopulateMetadata = flag;
+ }
+
+ /**
+ * Sets the redirect of the appender's output to STDOUT instead of ingesting logs to Cloud Logging
+ * using Logging API.
+ *
+ *
Default to {@code false}.
+ *
+ * @param flag the redirect flag.
+ */
+ public void setRedirectToStdout(boolean flag) {
+ redirectToStdout = flag;
+ }
+
+ /**
+ * Sets the {@link LogbackBatchingSettings} to be used for the asynchronous mode call(s) to
+ * Logging API
+ *
+ *
Default to {@code null}.
+ *
+ * @param batchingSettings the {@link LogbackBatchingSettings} to be used for asynchronous mode
+ * call(s) to Logging API
+ */
+ public void setLogbackBatchingSettings(LogbackBatchingSettings batchingSettings) {
+ logbackBatchingSettings = batchingSettings;
+ }
+
+ /**
+ * Sets the flag indicating if a batch's valid entries should be written even if some other entry
+ * failed due to an error.
+ *
+ *
Default to {@code true}.
+ *
+ * @param flag the partialSuccess flag.
+ */
+ public void setPartialSuccess(boolean flag) {
+ partialSuccess = flag;
+ }
+
+ /** Add extra labels using classes that implement {@link LoggingEnhancer}. */
+ public void addEnhancer(String enhancerClassName) {
+ this.enhancerClassNames.add(enhancerClassName);
+ }
+
+ public void addLoggingEventEnhancer(String enhancerClassName) {
+ this.loggingEventEnhancerClassNames.add(enhancerClassName);
+ }
+
+ /**
+ * Returns the current value of the ingestion mode.
+ *
+ *
The method is deprecated. Use appender configuration to set up the ingestion
+ *
+ * @return a {@link Synchronicity} value of the ingestion module.
+ */
+ @Deprecated
+ public Synchronicity getWriteSynchronicity() {
+ return (this.writeSyncFlag != null) ? this.writeSyncFlag : Synchronicity.ASYNC;
+ }
+
+ private void setupMonitoredResource() {
+ if (monitoredResource == null && autoPopulateMetadata) {
+ monitoredResource = MonitoredResourceUtil.getResource(getProjectId(), resourceType);
+ }
+ }
+
+ @InternalApi("Visible for testing")
+ void setupMonitoredResource(MonitoredResource monitoredResource) {
+ this.monitoredResource = monitoredResource;
+ }
+
+ private Level getFlushLevel() {
+ return (flushLevel != null) ? flushLevel : Level.OFF;
+ }
+
+ private String getLogName() {
+ return (log != null) ? log : "java.log";
+ }
+
+ private List getLoggingEnhancers() {
+ return getEnhancers(enhancerClassNames, LoggingEnhancer.class);
+ }
+
+ private List getLoggingEventEnhancers() {
+ if (loggingEventEnhancerClassNames.isEmpty()) {
+ return DEFAULT_LOGGING_EVENT_ENHANCERS;
+ } else {
+ return getEnhancers(loggingEventEnhancerClassNames, LoggingEventEnhancer.class);
+ }
+ }
+
+ private List getEnhancers(Set classNames, Class classOfT) {
+ List enhancers = new ArrayList<>();
+ if (classNames != null) {
+ for (String className : classNames) {
+ if (className != null) {
+ try {
+ T enhancer =
+ Loader.loadClass(className.trim())
+ .asSubclass(classOfT)
+ .getDeclaredConstructor()
+ .newInstance();
+ enhancers.add(enhancer);
+ } catch (Exception ex) {
+ // invalid className: ignore
+ }
+ }
+ }
+ }
+ return enhancers;
+ }
+
+ /** Initialize and configure the cloud logging service. */
+ @Override
+ public synchronized void start() {
+ if (isStarted()) {
+ return;
+ }
+
+ setupMonitoredResource();
+
+ defaultWriteOptions =
+ new WriteOption[] {
+ WriteOption.logName(getLogName()),
+ WriteOption.resource(monitoredResource),
+ WriteOption.partialSuccess(partialSuccess)
+ };
+ Level flushLevel = getFlushLevel();
+ if (flushLevel != Level.OFF) {
+ getLogging().setFlushSeverity(severityFor(flushLevel));
+ }
+ loggingEnhancers = new ArrayList<>();
+ List resourceEnhancers = MonitoredResourceUtil.getResourceEnhancers();
+ loggingEnhancers.addAll(resourceEnhancers);
+ loggingEnhancers.addAll(getLoggingEnhancers());
+ loggingEventEnhancers = new ArrayList<>();
+ loggingEventEnhancers.addAll(getLoggingEventEnhancers());
+
+ super.start();
+ }
+
+ String getProjectId() {
+ return getLoggingOptions().getProjectId();
+ }
+
+ @Override
+ protected void append(ILoggingEvent e) {
+ List entriesList = new ArrayList<>();
+ entriesList.add(logEntryFor(e));
+ // Check if instrumentation was already added - if not, create a log entry with instrumentation
+ // data
+ if (!setInstrumentationStatus(true)) {
+ entriesList.add(
+ Instrumentation.createDiagnosticEntry(
+ JAVA_LOGBACK_LIBRARY_NAME, DEFAULT_INSTRUMENTATION_VERSION));
+ }
+ Iterable entries = entriesList;
+ if (autoPopulateMetadata) {
+ entries =
+ getLogging()
+ .populateMetadata(
+ entries,
+ monitoredResource,
+ "com.google.cloud.logging",
+ "jdk",
+ "sun",
+ "java",
+ "ch.qos.logback");
+ }
+ if (redirectToStdout) {
+ for (LogEntry entry : entries) {
+ System.out.println(entry.toStructuredJsonString());
+ }
+ } else {
+ getLogging().write(entries, defaultWriteOptions);
+ }
+ }
+
+ @Override
+ public synchronized void stop() {
+ if (logging != null) {
+ try {
+ logging.close();
+ } catch (Exception ex) {
+ // ignore
+ }
+ }
+ logging = null;
+ super.stop();
+ }
+
+ Logging getLogging() {
+ if (logging == null) {
+ synchronized (this) {
+ if (logging == null) {
+ logging = getLoggingOptions().getService();
+ logging.setWriteSynchronicity(writeSyncFlag);
+ }
+ }
+ }
+ return logging;
+ }
+
+ /** Flushes any pending asynchronous logging writes. */
+ @Deprecated
+ public void flush() {
+ if (!isStarted()) {
+ return;
+ }
+ synchronized (this) {
+ getLogging().flush();
+ }
+ }
+
+ /** Gets the {@link LoggingOptions} to use for this {@link LoggingAppender}. */
+ protected LoggingOptions getLoggingOptions() {
+ if (loggingOptions == null) {
+ LoggingOptions.Builder builder = LoggingOptions.newBuilder();
+ builder.setProjectId(logDestinationProjectId);
+ if (credentials != null) {
+ builder.setCredentials(credentials);
+ } else if (!Strings.isNullOrEmpty(credentialsFile)) {
+ try {
+ builder.setCredentials(
+ GoogleCredentials.fromStream(Files.newInputStream(Paths.get(credentialsFile))));
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Could not read credentials file %s. Please verify that the file exists and is a valid Google credentials file.",
+ credentialsFile),
+ e);
+ }
+ }
+ // opt-out metadata auto-population to control it in the appender code
+ builder.setAutoPopulateMetadata(false);
+ builder.setBatchingSettings(
+ this.logbackBatchingSettings != null ? this.logbackBatchingSettings.build() : null);
+ loggingOptions = builder.build();
+ }
+ return loggingOptions;
+ }
+
+ private LogEntry logEntryFor(ILoggingEvent e) {
+ StringBuilder payload = new StringBuilder().append(e.getFormattedMessage()).append('\n');
+ writeStack(e.getThrowableProxy(), "", payload);
+
+ Level level = e.getLevel();
+ Severity severity = severityFor(level);
+
+ Map jsonContent = new HashMap<>();
+ jsonContent.put("message", payload.toString().trim());
+ if (severity == Severity.ERROR) {
+ jsonContent.put("@type", TYPE);
+ }
+ LogEntry.Builder builder =
+ LogEntry.newBuilder(Payload.JsonPayload.of(jsonContent))
+ .setTimestamp(Instant.ofEpochMilli(e.getTimeStamp()))
+ .setSeverity(severity);
+ builder
+ .addLabel(LEVEL_NAME_KEY, level.toString())
+ .addLabel(LEVEL_VALUE_KEY, String.valueOf(level.toInt()))
+ .addLabel(LOGGER_NAME_KEY, e.getLoggerName());
+
+ if (loggingEnhancers != null) {
+ for (LoggingEnhancer enhancer : loggingEnhancers) {
+ enhancer.enhanceLogEntry(builder);
+ }
+ }
+
+ if (loggingEventEnhancers != null) {
+ for (LoggingEventEnhancer enhancer : loggingEventEnhancers) {
+ enhancer.enhanceLogEntry(builder, e);
+ }
+ }
+
+ return builder.build();
+ }
+
+ @InternalApi("Visible for testing")
+ static void writeStack(IThrowableProxy throwProxy, String prefix, StringBuilder payload) {
+ if (throwProxy == null) {
+ return;
+ }
+ payload
+ .append(prefix)
+ .append(throwProxy.getClassName())
+ .append(": ")
+ .append(throwProxy.getMessage())
+ .append('\n');
+ StackTraceElementProxy[] trace = throwProxy.getStackTraceElementProxyArray();
+ if (trace == null) {
+ trace = new StackTraceElementProxy[0];
+ }
+
+ int commonFrames = throwProxy.getCommonFrames();
+ int printFrames = trace.length - commonFrames;
+ for (int i = 0; i < printFrames; i++) {
+ payload.append(" ").append(trace[i]).append('\n');
+ }
+ if (commonFrames != 0) {
+ payload.append(" ... ").append(commonFrames).append(" common frames elided\n");
+ }
+
+ writeStack(throwProxy.getCause(), "caused by: ", payload);
+ }
+
+ /**
+ * Transforms Logback logging levels to Cloud severity.
+ *
+ * @param level Logback logging level
+ * @return Cloud severity level
+ */
+ private static Severity severityFor(Level level) {
+ switch (level.toInt()) {
+ // TRACE
+ case 5000:
+ return Severity.DEBUG;
+ // DEBUG
+ case 10000:
+ return Severity.DEBUG;
+ // INFO
+ case 20000:
+ return Severity.INFO;
+ // WARNING
+ case 30000:
+ return Severity.WARNING;
+ // ERROR
+ case 40000:
+ return Severity.ERROR;
+ default:
+ return Severity.DEFAULT;
+ }
+ }
+
+ /**
+ * The package-private helper method used to set the flag which indicates if instrumentation info
+ * already written or not.
+ *
+ * @return The value of the flag before it was set.
+ */
+ static boolean setInstrumentationStatus(boolean value) {
+ if (instrumentationAdded == value) return instrumentationAdded;
+ synchronized (instrumentationLock) {
+ boolean current = instrumentationAdded;
+ instrumentationAdded = value;
+ return current;
+ }
+ }
+}
diff --git a/java-logging-logback/src/main/java/com/google/cloud/logging/logback/LoggingEventEnhancer.java b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/LoggingEventEnhancer.java
new file mode 100644
index 000000000000..1e2d94afff2e
--- /dev/null
+++ b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/LoggingEventEnhancer.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import com.google.cloud.logging.LogEntry;
+
+/**
+ * An enhancer for {@linkplain ILoggingEvent} log entries. Used to add custom labels to the {@link
+ * LogEntry.Builder}.
+ */
+public interface LoggingEventEnhancer {
+ void enhanceLogEntry(LogEntry.Builder builder, ILoggingEvent e);
+}
diff --git a/java-logging-logback/src/main/java/com/google/cloud/logging/logback/MDCEventEnhancer.java b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/MDCEventEnhancer.java
new file mode 100644
index 000000000000..b93e2c150911
--- /dev/null
+++ b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/MDCEventEnhancer.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import com.google.cloud.logging.LogEntry;
+import java.util.Map;
+
+/**
+ * MDCEventEnhancer takes values found in the MDC property map and adds them as labels to the {@link
+ * LogEntry}. This {@link LoggingEventEnhancer} is turned on by default. If you wish to filter which
+ * MDC values get added as labels to your {@link LogEntry}, implement a {@link LoggingEventEnhancer}
+ * and add its classpath to your {@code logback.xml}. If any {@link LoggingEventEnhancer} is added
+ * this class is no longer registered.
+ */
+final class MDCEventEnhancer implements LoggingEventEnhancer {
+
+ @Override
+ public void enhanceLogEntry(LogEntry.Builder builder, ILoggingEvent e) {
+ for (Map.Entry entry : e.getMDCPropertyMap().entrySet()) {
+ if (null != entry.getKey() && null != entry.getValue()) {
+ builder.addLabel(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+}
diff --git a/java-logging-logback/src/main/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancer.java b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancer.java
new file mode 100644
index 000000000000..0625d52a4322
--- /dev/null
+++ b/java-logging-logback/src/main/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancer.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import com.google.cloud.logging.LogEntry;
+import org.slf4j.MDC;
+
+/** Adds support for grouping logs by incoming http request */
+public class TraceLoggingEventEnhancer implements LoggingEventEnhancer {
+
+ // A key used by Cloud Logging for trace Id
+ private static final String TRACE_ID = "logging.googleapis.trace";
+
+ /**
+ * Set the Trace ID associated with any logging done by the current thread.
+ *
+ * @param id The traceID, in the form projects/[PROJECT_ID]/traces/[TRACE_ID]
+ */
+ public static void setCurrentTraceId(String id) {
+ MDC.put(TRACE_ID, id);
+ }
+
+ /** Clearing a trace Id from the MDC */
+ public static void clearTraceId() {
+ MDC.remove(TRACE_ID);
+ }
+
+ /**
+ * Get the Trace ID associated with any logging done by the current thread.
+ *
+ * @return id The traceID
+ */
+ public static String getCurrentTraceId() {
+ return MDC.get(TRACE_ID);
+ }
+
+ @Override
+ public void enhanceLogEntry(LogEntry.Builder builder, ILoggingEvent e) {
+ Object value = e.getMDCPropertyMap().get(TRACE_ID);
+ String traceId = value != null ? value.toString() : null;
+ if (traceId != null) {
+ builder.setTrace(traceId);
+ }
+ }
+}
diff --git a/java-logging-logback/src/main/resources/META-INF/native-image/com.google.cloud/google-cloud-logging-logback/reflect-config.json b/java-logging-logback/src/main/resources/META-INF/native-image/com.google.cloud/google-cloud-logging-logback/reflect-config.json
new file mode 100644
index 000000000000..9d249db03f61
--- /dev/null
+++ b/java-logging-logback/src/main/resources/META-INF/native-image/com.google.cloud/google-cloud-logging-logback/reflect-config.json
@@ -0,0 +1,53 @@
+[
+ {
+ "name":"ch.qos.logback.classic.Level",
+ "methods":[{"name":"valueOf","parameterTypes":["java.lang.String"] }]
+ },
+ {
+ "name":"ch.qos.logback.classic.filter.ThresholdFilter",
+ "queryAllPublicMethods":true,
+ "methods":[
+ {"name":"","parameterTypes":[] },
+ {"name":"setLevel","parameterTypes":["java.lang.String"] }
+ ]
+ },
+ {
+ "name":"ch.qos.logback.core.UnsynchronizedAppenderBase",
+ "methods":[{"name":"addFilter","parameterTypes":["ch.qos.logback.core.filter.Filter"] }]
+ },
+ {
+ "name":"com.google.cloud.logging.logback.LogbackBatchingSettings",
+ "queryAllPublicMethods":true,
+ "methods":[
+ {"name":"","parameterTypes":[] },
+ {"name":"setDelayThreshold","parameterTypes":["java.lang.Long"] },
+ {"name":"setElementCountThreshold","parameterTypes":["java.lang.Long"] },
+ {"name":"setLimitExceededBehavior","parameterTypes":["com.google.api.gax.batching.FlowController$LimitExceededBehavior"] },
+ {"name":"setMaxOutstandingElementCount","parameterTypes":["java.lang.Long"] },
+ {"name":"setMaxOutstandingRequestBytes","parameterTypes":["java.lang.Long"] },
+ {"name":"setRequestByteThreshold","parameterTypes":["java.lang.Long"] }
+ ]
+ },
+ {
+ "name":"com.google.cloud.logging.logback.LoggingAppender",
+ "queryAllPublicMethods":true,
+ "methods":[
+ {"name":"","parameterTypes":[] },
+ {"name":"setAutoPopulateMetadata","parameterTypes":["boolean"] },
+ {"name":"setCredentialsFile","parameterTypes":["java.lang.String"] },
+ {"name":"setCredentials","parameterTypes":["com.google.auth.oauth2.GoogleCredentials"] },
+ {"name":"setFlushLevel","parameterTypes":["ch.qos.logback.classic.Level"] },
+ {"name":"setLog","parameterTypes":["java.lang.String"] },
+ {"name":"setLogDestinationProjectId","parameterTypes":["java.lang.String"] },
+ {"name":"setLogbackBatchingSettings","parameterTypes":["com.google.cloud.logging.logback.LogbackBatchingSettings"] },
+ {"name":"setPartialSuccess","parameterTypes":["boolean"] },
+ {"name":"setRedirectToStdout","parameterTypes":["boolean"] },
+ {"name":"setResourceType","parameterTypes":["java.lang.String"] },
+ {"name":"setWriteSynchronicity","parameterTypes":["com.google.cloud.logging.Synchronicity"] }
+ ]
+ },
+ {
+ "name":"java.lang.Long",
+ "methods":[{"name":"valueOf","parameterTypes":["java.lang.String"] }]
+ }
+]
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderLogbackTest.java b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderLogbackTest.java
new file mode 100644
index 000000000000..f09151b3182c
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderLogbackTest.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.joran.JoranConfigurator;
+import ch.qos.logback.core.joran.spi.JoranException;
+import com.google.api.gax.batching.FlowController.LimitExceededBehavior;
+import com.google.cloud.logging.LoggingOptions;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LoggingAppenderLogbackTest {
+ @Test
+ public void testLoggingOptionsFromLogbackXMLFileConfig() throws JoranException {
+ LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
+ JoranConfigurator jc = new JoranConfigurator();
+ jc.setContext(context);
+ context.reset();
+ jc.doConfigure("src/test/java/com/google/cloud/logging/logback/logback.xml");
+ Logger logger = LoggerFactory.getLogger(LoggingAppenderLogbackTest.class);
+ assertThat(logger.getName())
+ .isEqualTo("com.google.cloud.logging.logback.LoggingAppenderLogbackTest");
+ LoggingAppender appender = (LoggingAppender) context.getLogger("ROOT").getAppender("CLOUD");
+ LoggingOptions options = appender.getLoggingOptions();
+ assertThat(options.getAutoPopulateMetadata()).isEqualTo(false);
+ assertThat(options.getBatchingSettings().getDelayThreshold().toMillis()).isEqualTo(500);
+ assertThat(options.getBatchingSettings().getElementCountThreshold()).isEqualTo(100);
+ assertThat(options.getBatchingSettings().getIsEnabled()).isEqualTo(true);
+ assertThat(options.getBatchingSettings().getRequestByteThreshold()).isEqualTo(1000);
+ assertThat(options.getBatchingSettings().getFlowControlSettings().getLimitExceededBehavior())
+ .isEqualTo(LimitExceededBehavior.Ignore);
+ assertThat(
+ options.getBatchingSettings().getFlowControlSettings().getMaxOutstandingElementCount())
+ .isEqualTo(10000);
+ assertThat(
+ options.getBatchingSettings().getFlowControlSettings().getMaxOutstandingRequestBytes())
+ .isEqualTo(100000);
+ }
+}
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderTest.java b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderTest.java
new file mode 100644
index 000000000000..2399c9563fb1
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/LoggingAppenderTest.java
@@ -0,0 +1,586 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.capture;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.filter.ThresholdFilter;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.classic.spi.LoggingEvent;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.cloud.MonitoredResource;
+import com.google.cloud.Timestamp;
+import com.google.cloud.logging.Instrumentation;
+import com.google.cloud.logging.LogEntry;
+import com.google.cloud.logging.Logging;
+import com.google.cloud.logging.Logging.WriteOption;
+import com.google.cloud.logging.LoggingEnhancer;
+import com.google.cloud.logging.Payload;
+import com.google.cloud.logging.Payload.JsonPayload;
+import com.google.cloud.logging.Payload.Type;
+import com.google.cloud.logging.Severity;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.Value;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.time.Instant;
+import java.util.Map;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.easymock.EasyMockRunner;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.slf4j.MDC;
+
+@RunWith(EasyMockRunner.class)
+public class LoggingAppenderTest {
+ private static final String PROJECT_ID = "test-project";
+ private static final String CRED_FILE_PROJECT_ID = "project-12345";
+ private static final String OVERRIDDEN_PROJECT_ID = "some-project-id";
+ private static final String DUMMY_CRED_FILE_PATH =
+ "src/test/java/com/google/cloud/logging/logback/dummy-credentials.json";
+ private static final Payload.JsonPayload JSON_PAYLOAD =
+ Payload.JsonPayload.of(ImmutableMap.of("message", "this is a test"));
+ private static final Payload.JsonPayload JSON_ERROR_PAYLOAD =
+ Payload.JsonPayload.of(
+ ImmutableMap.of(
+ "message",
+ "this is a test",
+ "@type",
+ "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent"));
+ private static final MonitoredResource DEFAULT_RESOURCE =
+ MonitoredResource.of("global", ImmutableMap.of("project_id", PROJECT_ID));
+ private static final LogEntry WARN_ENTRY =
+ LogEntry.newBuilder(JSON_PAYLOAD)
+ .setTimestamp(Instant.ofEpochMilli(100000L))
+ .setSeverity(Severity.WARNING)
+ .setLabels(
+ new ImmutableMap.Builder()
+ .put("levelName", "WARN")
+ .put("levelValue", String.valueOf(30000L))
+ .put("loggerName", LoggingAppenderTest.class.getName())
+ // .put("test-label-1", "test-value-1")
+ // .put("test-label-2", "test-value-2")
+ .build())
+ .build();
+ private static final LogEntry ERROR_ENTRY =
+ LogEntry.newBuilder(JSON_ERROR_PAYLOAD)
+ .setTimestamp(Instant.ofEpochMilli(100000L))
+ .setSeverity(Severity.ERROR)
+ .setLabels(
+ new ImmutableMap.Builder()
+ .put("levelName", "ERROR")
+ .put("levelValue", String.valueOf(40000L))
+ .put("loggerName", LoggingAppenderTest.class.getName())
+ .build())
+ .build();
+ private static final LogEntry INFO_ENTRY =
+ LogEntry.newBuilder(JSON_PAYLOAD)
+ .setTimestamp(Instant.ofEpochMilli(100000L))
+ .setSeverity(Severity.INFO)
+ .setLabels(
+ new ImmutableMap.Builder()
+ .put("levelName", "INFO")
+ .put("levelValue", String.valueOf(20000L))
+ .put("loggerName", LoggingAppenderTest.class.getName())
+ .put("mdc1", "value1")
+ .put("mdc2", "value2")
+ .build())
+ .build();
+
+ private Logging logging;
+ private LoggingAppender loggingAppender;
+
+ static class CustomLoggingEventEnhancer implements LoggingEventEnhancer {
+
+ @Override
+ public void enhanceLogEntry(LogEntry.Builder builder, ILoggingEvent e) {
+ builder.addLabel("foo", "bar");
+ }
+ }
+
+ static class CustomLoggingEnhancer implements LoggingEnhancer {
+
+ @Override
+ public void enhanceLogEntry(LogEntry.Builder builder) {
+ builder.addLabel("foo", "bar");
+ }
+ }
+
+ class TestLoggingAppender extends LoggingAppender {
+ @Override
+ String getProjectId() {
+ return PROJECT_ID;
+ }
+
+ @Override
+ Logging getLogging() {
+ return logging;
+ }
+ }
+
+ @Before
+ public void setUp() {
+ LoggingAppender.setInstrumentationStatus(true);
+ logging = EasyMock.createStrictMock(Logging.class);
+ loggingAppender = new TestLoggingAppender();
+ loggingAppender.setAutoPopulateMetadata(false);
+ }
+
+ private final WriteOption[] defaultWriteOptions =
+ new WriteOption[] {
+ WriteOption.logName("java.log"),
+ WriteOption.resource(
+ MonitoredResource.newBuilder("global")
+ .setLabels(
+ new ImmutableMap.Builder()
+ .put("project_id", PROJECT_ID)
+ .build())
+ .build()),
+ WriteOption.partialSuccess(true),
+ };
+
+ @Test
+ public void testFlushLevelConfigUpdatesLoggingFlushSeverity() {
+ logging.setFlushSeverity(Severity.WARNING);
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ replay(logging);
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.WARN, timestamp.getSeconds());
+ // error is the default, updating to warn for test
+ loggingAppender.setFlushLevel(Level.WARN);
+ loggingAppender.start();
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ assertThat(capturedArgument.getValue().iterator().hasNext()).isTrue();
+ assertThat(capturedArgument.getValue().iterator().next()).isEqualTo(WARN_ENTRY);
+ }
+
+ @Test
+ public void testFlushLevelConfigSupportsFlushLevelOff() {
+ loggingAppender.setFlushLevel(Level.OFF);
+ loggingAppender.start();
+ Severity foundSeverity = logging.getFlushSeverity();
+ assertThat(foundSeverity).isEqualTo(null);
+ }
+
+ @Test
+ public void testDefaultFlushLevelOff() {
+ loggingAppender.start();
+ Severity foundSeverity = logging.getFlushSeverity();
+ assertThat(foundSeverity).isEqualTo(null);
+ }
+
+ @Test
+ public void testFilterLogsOnlyLogsAtOrAboveLogLevel() {
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent1 = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ ThresholdFilter thresholdFilter = new ThresholdFilter();
+ thresholdFilter.setLevel("ERROR");
+ thresholdFilter.start();
+ loggingAppender.addFilter(thresholdFilter);
+ loggingAppender.start();
+ // info event does not get logged
+ loggingAppender.doAppend(loggingEvent1);
+ LoggingEvent loggingEvent2 = createLoggingEvent(Level.ERROR, timestamp.getSeconds());
+ // error event gets logged
+ loggingAppender.doAppend(loggingEvent2);
+ verify(logging);
+ assertThat(capturedArgument.getValue().iterator().hasNext()).isTrue();
+ assertThat(capturedArgument.getValue().iterator().next()).isEqualTo(ERROR_ENTRY);
+ }
+
+ @Test
+ public void testPartialSuccessOverrideHasExpectedValue() {
+ Capture logNameArg = Capture.newInstance();
+ Capture resourceArg = Capture.newInstance();
+ Capture partialSuccessArg = Capture.newInstance();
+ logging.write(
+ EasyMock.>anyObject(),
+ capture(logNameArg),
+ capture(resourceArg),
+ capture(partialSuccessArg));
+ expectLastCall().once();
+ replay(logging);
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.ERROR, timestamp.getSeconds());
+ loggingAppender.doAppend(loggingEvent);
+
+ assertThat(logNameArg.getValue()).isEqualTo(defaultWriteOptions[0]);
+ // TODO(chingor): Fix this test to work on GCE and locally
+ // assertThat(resourceArg.getValue()).isEqualTo(defaultWriteOptions[1]);
+ assertThat(partialSuccessArg.getValue()).isEqualTo(defaultWriteOptions[2]);
+ }
+
+ @Test
+ public void testDefaultWriteOptionsHasExpectedDefaults() {
+ Capture partialSuccessArg = Capture.newInstance();
+ logging.write(
+ EasyMock.>anyObject(),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ capture(partialSuccessArg));
+ expectLastCall().once();
+ replay(logging);
+ loggingAppender.setPartialSuccess(false);
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.ERROR, timestamp.getSeconds());
+ loggingAppender.doAppend(loggingEvent);
+ assertThat(partialSuccessArg.getValue()).isEqualTo(WriteOption.partialSuccess(false));
+ }
+
+ @Test
+ public void testMdcValuesAreConvertedToLabels() {
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingEvent.setMDCPropertyMap(ImmutableMap.of("mdc1", "value1", "mdc2", "value2"));
+ loggingAppender.start();
+ // info event does not get logged
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ assertThat(capturedArgument.getValue().iterator().hasNext()).isTrue();
+ assertThat(capturedArgument.getValue().iterator().next()).isEqualTo(INFO_ENTRY);
+ }
+
+ @Test
+ public void testCreateLoggingOptionsWithValidCredentials() {
+ LoggingAppender appender = new LoggingAppender();
+ appender.setCredentials(GoogleCredentials.newBuilder().build());
+ // ServiceOptions requires a projectId to be set. Normally this is determined by the
+ // GoogleCredentials (Credential set above is a dummy value with no ProjectId).
+ appender.setLogDestinationProjectId(PROJECT_ID);
+ appender.getLoggingOptions();
+ }
+
+ @Test
+ public void testCreateLoggingOptionsWithNullCredentials() {
+ LoggingAppender appender = new LoggingAppender();
+ assertThrows(NullPointerException.class, () -> appender.setCredentials(null));
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void testCreateLoggingOptionsWithInvalidCredentials() {
+ final String nonExistentFile = "/path/to/non/existent/file";
+ LoggingAppender appender = new LoggingAppender();
+ appender.setCredentialsFile(nonExistentFile);
+ appender.getLoggingOptions();
+ }
+
+ @Test
+ public void testCreateLoggingOptionsWithCredentials() {
+ // Try to build LoggingOptions with file based credentials.
+ LoggingAppender appender = new LoggingAppender();
+ appender.setCredentialsFile(DUMMY_CRED_FILE_PATH);
+ assertThat(appender.getLoggingOptions().getProjectId()).isEqualTo(CRED_FILE_PROJECT_ID);
+ }
+
+ @Test
+ public void testCreateLoggingOptionsWithDestination() {
+ // Try to build LoggingOptions with file based credentials.
+ LoggingAppender appender = new LoggingAppender();
+ appender.setCredentialsFile(DUMMY_CRED_FILE_PATH);
+ appender.setLogDestinationProjectId(OVERRIDDEN_PROJECT_ID);
+ assertThat(appender.getLoggingOptions().getProjectId()).isEqualTo(OVERRIDDEN_PROJECT_ID);
+ }
+
+ private LoggingEvent createLoggingEvent(Level level, long timestamp) {
+ LoggingEvent loggingEvent = new LoggingEvent();
+ loggingEvent.setMessage("this is a test");
+ loggingEvent.setLevel(level);
+ loggingEvent.setTimeStamp(timestamp);
+ loggingEvent.setLoggerName(this.getClass().getName());
+ return loggingEvent;
+ }
+
+ @Test
+ public void testMdcValuesAreConvertedToLabelsWithPassingNullValues() {
+ MDC.put("mdc1", "value1");
+ MDC.put("mdc2", null);
+ MDC.put("mdc3", "value3");
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingAppender.start();
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ MDC.remove("mdc1");
+ MDC.remove("mdc3");
+ Map capturedArgumentMap =
+ capturedArgument.getValue().iterator().next().getLabels();
+ assertThat(capturedArgumentMap.get("mdc1")).isEqualTo("value1");
+ assertThat(capturedArgumentMap.get("mdc2")).isNull();
+ assertThat(capturedArgumentMap.get("mdc3")).isEqualTo("value3");
+ }
+
+ @Test
+ public void testAddCustomLoggingEventEnhancers() {
+ MDC.put("mdc1", "value1");
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingAppender.addLoggingEventEnhancer(CustomLoggingEventEnhancer.class.getName());
+ loggingAppender.start();
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ MDC.remove("mdc1");
+ Map capturedArgumentMap =
+ capturedArgument.getValue().iterator().next().getLabels();
+ assertThat(capturedArgumentMap.get("mdc1")).isNull();
+ assertThat(capturedArgumentMap.get("foo")).isEqualTo("bar");
+ }
+
+ @Test
+ public void testAddCustomLoggingEnhancer() {
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+ loggingAppender.addEnhancer(CustomLoggingEnhancer.class.getName());
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.WARN, timestamp.getSeconds());
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ Map capturedArgumentMap =
+ capturedArgument.getValue().iterator().next().getLabels();
+ assertThat(capturedArgumentMap.get("foo")).isEqualTo("bar");
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ public void testFlush() {
+ logging.write(
+ EasyMock.>anyObject(),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().times(2);
+ logging.flush();
+ replay(logging);
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent firstLoggingEvent = createLoggingEvent(Level.WARN, timestamp.getSeconds());
+ LoggingEvent secondLoggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingAppender.doAppend(firstLoggingEvent);
+ loggingAppender.doAppend(secondLoggingEvent);
+ loggingAppender.flush();
+ verify(logging);
+ }
+
+ @Test
+ public void testAutoPopulationEnabled() {
+ Capture> capturedLogEntries = Capture.newInstance();
+ EasyMock.expect(
+ logging.populateMetadata(
+ capture(capturedLogEntries),
+ EasyMock.eq(DEFAULT_RESOURCE),
+ EasyMock.eq("com.google.cloud.logging"),
+ EasyMock.eq("jdk"),
+ EasyMock.eq("sun"),
+ EasyMock.eq("java"),
+ EasyMock.eq("ch.qos.logback")))
+ .andReturn(ImmutableList.of(INFO_ENTRY))
+ .once();
+ // it is impossible to define expectation for varargs using a single anyObject() matcher
+ // see the EasyMock bug https://github.com/easymock/easymock/issues/130.
+ // the following mock uses the known fact that the method pass two WriteOption arguments
+ // the arguments should be replaced with a single anyObject() matchers when the bug is fixed
+ logging.write(
+ EasyMock.>anyObject(),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ expectLastCall().once();
+ replay(logging);
+
+ loggingAppender.setupMonitoredResource(DEFAULT_RESOURCE);
+ loggingAppender.setAutoPopulateMetadata(true);
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingEvent.setMDCPropertyMap(ImmutableMap.of("mdc1", "value1", "mdc2", "value2"));
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ LogEntry testLogEntry = capturedLogEntries.getValue().iterator().next();
+ assertThat(testLogEntry).isEqualTo(INFO_ENTRY);
+ }
+
+ @Test
+ public void testRedirectToStdoutEnabled() {
+ EasyMock.expect(
+ logging.populateMetadata(
+ EasyMock.>anyObject(),
+ EasyMock.anyObject(MonitoredResource.class),
+ EasyMock.anyString(),
+ EasyMock.anyString(),
+ EasyMock.anyString(),
+ EasyMock.anyString(),
+ EasyMock.anyString()))
+ .andReturn(ImmutableList.of(INFO_ENTRY))
+ .once();
+ replay(logging);
+
+ ByteArrayOutputStream bout = new ByteArrayOutputStream();
+ PrintStream out = new PrintStream(bout);
+ System.setOut(out);
+ loggingAppender.setupMonitoredResource(DEFAULT_RESOURCE);
+ loggingAppender.setAutoPopulateMetadata(true);
+ loggingAppender.setRedirectToStdout(true);
+ loggingAppender.start();
+ Timestamp timestamp = Timestamp.ofTimeSecondsAndNanos(100000, 0);
+ LoggingEvent loggingEvent = createLoggingEvent(Level.INFO, timestamp.getSeconds());
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ assertThat(Strings.isNullOrEmpty(bout.toString())).isFalse();
+ System.setOut(null);
+ }
+
+ @Test
+ public void testRedirectToStdoutDisabled() {
+ ByteArrayOutputStream bout = new ByteArrayOutputStream();
+ PrintStream out = new PrintStream(bout);
+ System.setOut(out);
+
+ testAutoPopulationEnabled();
+
+ assertThat(Strings.isNullOrEmpty(bout.toString())).isTrue();
+ System.setOut(null);
+ }
+
+ @Test
+ public void testFDiagnosticInfoAdded() {
+ LoggingAppender.setInstrumentationStatus(false);
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ replay(logging);
+ LoggingEvent loggingEvent =
+ createLoggingEvent(Level.ERROR, Timestamp.ofTimeSecondsAndNanos(100000, 0).getSeconds());
+ loggingAppender.start();
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ int count = 0;
+ int diagnosticRecordCount = 0;
+ for (LogEntry entry : capturedArgument.getValue()) {
+ count++;
+ if (entry.getPayload().getType() == Type.JSON) {
+ JsonPayload payload = entry.getPayload();
+ if (!payload.getData().containsFields(Instrumentation.DIAGNOSTIC_INFO_KEY)) continue;
+ ListValue infoList =
+ payload
+ .getData()
+ .getFieldsOrThrow(Instrumentation.DIAGNOSTIC_INFO_KEY)
+ .getStructValue()
+ .getFieldsOrThrow(Instrumentation.INSTRUMENTATION_SOURCE_KEY)
+ .getListValue();
+ for (Value val : infoList.getValuesList()) {
+ String name =
+ val.getStructValue()
+ .getFieldsOrThrow(Instrumentation.INSTRUMENTATION_NAME_KEY)
+ .getStringValue();
+ assertThat(name.startsWith(Instrumentation.JAVA_LIBRARY_NAME_PREFIX)).isTrue();
+ if (name.equals(LoggingAppender.JAVA_LOGBACK_LIBRARY_NAME)) {
+ diagnosticRecordCount++;
+ }
+ }
+ }
+ }
+ assertEquals(count, 2);
+ assertEquals(diagnosticRecordCount, 1);
+ }
+
+ @Test
+ public void testFDiagnosticInfoNotAdded() {
+ Capture> capturedArgument = Capture.newInstance();
+ logging.write(
+ capture(capturedArgument),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class),
+ anyObject(WriteOption.class));
+ replay(logging);
+ LoggingEvent loggingEvent =
+ createLoggingEvent(Level.WARN, Timestamp.ofTimeSecondsAndNanos(100000, 0).getSeconds());
+ loggingAppender.start();
+ loggingAppender.doAppend(loggingEvent);
+ verify(logging);
+ int count = 0;
+ for (LogEntry entry : capturedArgument.getValue()) {
+ count++;
+ if (entry.getPayload().getType() == Type.JSON) {
+ JsonPayload payload = entry.getPayload();
+ assertThat(payload.getData().containsFields(Instrumentation.DIAGNOSTIC_INFO_KEY)).isFalse();
+ }
+ }
+ assertEquals(count, 1);
+ }
+}
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/MDCEventEnhancerTest.java b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/MDCEventEnhancerTest.java
new file mode 100644
index 000000000000..c588003c2b15
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/MDCEventEnhancerTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import ch.qos.logback.classic.spi.LoggingEvent;
+import com.google.cloud.logging.LogEntry;
+import com.google.cloud.logging.Payload.StringPayload;
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MDCEventEnhancerTest {
+ private MDCEventEnhancer classUnderTest;
+
+ @Before
+ public void setUp() {
+ classUnderTest = new MDCEventEnhancer();
+ }
+
+ @Test
+ public void testEnhanceLogEntry() {
+ LoggingEvent loggingEvent = new LoggingEvent();
+ loggingEvent.setMessage("this is a test");
+ loggingEvent.setMDCPropertyMap(Collections.singletonMap("foo", "bar"));
+ LogEntry.Builder builder = LogEntry.newBuilder(StringPayload.of("this is a test"));
+
+ classUnderTest.enhanceLogEntry(builder, loggingEvent);
+ LogEntry logEntry = builder.build();
+
+ assertThat(logEntry.getLabels().get("foo")).isEqualTo("bar");
+ }
+}
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/StackTraceTest.java b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/StackTraceTest.java
new file mode 100644
index 000000000000..dfec67104240
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/StackTraceTest.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import ch.qos.logback.classic.spi.ThrowableProxy;
+import org.junit.Test;
+
+public class StackTraceTest {
+ @Test
+ public void testStack() {
+ Exception ex = new UnsupportedOperationException("foo");
+ ex = new IllegalStateException("bar", ex);
+
+ StringBuilder stackBuilder = new StringBuilder();
+ LoggingAppender.writeStack(new ThrowableProxy(ex), "", stackBuilder);
+ String stack = stackBuilder.toString();
+
+ assertThat(stack).contains("java.lang.IllegalStateException: bar");
+ assertThat(stack).contains("caused by: java.lang.UnsupportedOperationException: foo");
+ assertThat(stack).contains("common frames elided");
+ }
+}
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancerTest.java b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancerTest.java
new file mode 100644
index 000000000000..0c69a49bfd97
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/TraceLoggingEventEnhancerTest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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 com.google.cloud.logging.logback;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import ch.qos.logback.classic.spi.LoggingEvent;
+import com.google.cloud.logging.LogEntry;
+import com.google.cloud.logging.Payload.StringPayload;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TraceLoggingEventEnhancerTest {
+ private TraceLoggingEventEnhancer classUnderTest;
+
+ @Before
+ public void setUp() {
+ classUnderTest = new TraceLoggingEventEnhancer();
+ }
+
+ @After
+ public void tearDown() {
+ TraceLoggingEventEnhancer.clearTraceId();
+ }
+
+ @Test
+ public void testEnhanceLogEntry() {
+ // setup
+ String traceId = "abc";
+ TraceLoggingEventEnhancer.setCurrentTraceId(traceId);
+ LoggingEvent loggingEvent = new LoggingEvent();
+ loggingEvent.setMessage("this is a test");
+ LogEntry.Builder builder = LogEntry.newBuilder(StringPayload.of("this is a test"));
+
+ // act
+ classUnderTest.enhanceLogEntry(builder, loggingEvent);
+ LogEntry logEntry = builder.build();
+
+ // assert - Trace Id should be recorded as explicit Trace field, not as a label
+ assertThat(traceId.equalsIgnoreCase(logEntry.getTrace()));
+ }
+
+ @Test
+ public void testGetCurrentTraceId() {
+ // setup
+ String traceId = "abc";
+ TraceLoggingEventEnhancer.setCurrentTraceId(traceId);
+
+ // act
+ String currentTraceId = TraceLoggingEventEnhancer.getCurrentTraceId();
+
+ // assert
+ assertThat(traceId.equalsIgnoreCase(currentTraceId));
+ }
+}
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/dummy-credentials.json b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/dummy-credentials.json
new file mode 100644
index 000000000000..c99e8764e24d
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/dummy-credentials.json
@@ -0,0 +1,12 @@
+{
+ "type": "service_account",
+ "project_id": "project-12345",
+ "private_key_id": "12345",
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKhPSTDs4cpKfnMc\np86fCkpnuER7bGc+mGkhkw6bE+BnROfrDCFBSjrENLS5JcsenANQ1kYGt9iVW2fd\nZAWUdDoj+t7g6+fDpzY1BzPSUls421Dmu7joDPY8jSdMzFCeg7Lyj0I36bJJ7ooD\nVPW6Q0XQcb8FfBiFPAKuY4elj/YDAgMBAAECgYBo2GMWmCmbM0aL/KjH/KiTawMN\nnfkMY6DbtK9/5LjADHSPKAt5V8ueygSvI7rYSiwToLKqEptJztiO3gnls/GmFzj1\nV/QEvFs6Ux3b0hD2SGpGy1m6NWWoAFlMISRkNiAxo+AMdCi4I1hpk4+bHr9VO2Bv\nV0zKFxmgn1R8qAR+4QJBANqKxJ/qJ5+lyPuDYf5s+gkZWjCLTC7hPxIJQByDLICw\niEnqcn0n9Gslk5ngJIGQcKBXIp5i0jWSdKN/hLxwgHECQQDFKGmo8niLzEJ5sa1r\nspww8Hc2aJM0pBwceshT8ZgVPnpgmITU1ENsKpJ+y1RTjZD6N0aj9gS9UB/UXdTr\nHBezAkEAqkDRTYOtusH9AXQpM3zSjaQijw72Gs9/wx1RxOSsFtVwV6U97CLkV1S+\n2HG1/vn3w/IeFiYGfZXLKFR/pA5BAQJAbFeu6IaGM9yFUzaOZDZ8mnAqMp349t6Q\nDB5045xJxLLWsSpfJE2Y12H1qvO1XUzYNIgXq5ZQOHBFbYA6txBy/QJBAKDRQN47\n6YClq9652X+1lYIY/h8MxKiXpVZVncXRgY6pbj4pmWEAM88jra9Wq6R77ocyECzi\nXCqi18A/sl6ymWc=\n-----END PRIVATE KEY-----\n",
+ "client_email": "project-12345@appspot.gserviceaccount.com",
+ "client_id": "123456789012345678901",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/project-12345%40appspot.gserviceaccount.com"
+ }
diff --git a/java-logging-logback/src/test/java/com/google/cloud/logging/logback/logback.xml b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/logback.xml
new file mode 100644
index 000000000000..66e86e4e7d4d
--- /dev/null
+++ b/java-logging-logback/src/test/java/com/google/cloud/logging/logback/logback.xml
@@ -0,0 +1,57 @@
+
+
+
+
+ INFO
+
+
+
+ application.log
+
+
+ WARN
+
+
+ SYNC
+
+
+ false
+
+
+ true
+
+
+ global
+
+
+ src/test/java/com/google/cloud/logging/logback/dummy-credentials.json
+
+
+ String
+
+
+
+
+
+ true
+
+
+
+ 100
+ 1000
+ 500
+ 10000
+ 100000
+ Ignore
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
index d61b4c18e4c6..f261e559d9ec 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,6 +131,7 @@
java-licensemanager
java-life-sciences
java-locationfinder
+ java-logging-logback
java-lustre
java-maintenance
java-managed-identities
diff --git a/versions.txt b/versions.txt
index 9cbe2eb77f5d..058ea3cec162 100644
--- a/versions.txt
+++ b/versions.txt
@@ -934,3 +934,4 @@ grpc-google-cloud-maintenance-v1:0.15.0:0.16.0-SNAPSHOT
google-cloud-gkerecommender:0.1.0:0.2.0-SNAPSHOT
proto-google-cloud-gkerecommender-v1:0.1.0:0.2.0-SNAPSHOT
grpc-google-cloud-gkerecommender-v1:0.1.0:0.2.0-SNAPSHOT
+google-cloud-logging-logback:0.132.20-alpha:0.132.21-alpha-SNAPSHOT