diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java index 43b9254041..8d48bda4a1 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java @@ -31,15 +31,34 @@ package com.google.api.gax.logging; import com.google.api.core.InternalApi; +import com.google.common.annotations.VisibleForTesting; import java.util.Map; @InternalApi public class LoggingUtils { - private static boolean loggingEnabled = isLoggingEnabled(); + private static boolean loggingEnabled = checkLoggingEnabled(); static final String GOOGLE_SDK_JAVA_LOGGING = "GOOGLE_SDK_JAVA_LOGGING"; - static boolean isLoggingEnabled() { + /** + * Returns whether client-side logging is enabled. + * + * @return true if logging is enabled, false otherwise. + */ + public static boolean isLoggingEnabled() { + return loggingEnabled; + } + + /** + * Sets whether client-side logging is enabled. Visible for testing. + * + * @param enabled true to enable logging, false to disable. + */ + public static void setLoggingEnabled(boolean enabled) { + loggingEnabled = enabled; + } + + private static boolean checkLoggingEnabled() { String enableLogging = System.getenv(GOOGLE_SDK_JAVA_LOGGING); return "true".equalsIgnoreCase(enableLogging); } @@ -126,6 +145,27 @@ public static void logRequest( } } + /** + * Logs an actionable error message with structured context. + * + * @param logContext A map containing the structured logging context (e.g., RPC service, method, + * error details). + * @param loggerProvider The provider used to obtain the logger. + * @param message The human-readable error message. + */ + public static void logActionableError( + Map logContext, LoggerProvider loggerProvider, String message) { + if (loggingEnabled) { + org.slf4j.Logger logger = loggerProvider.getLogger(); + // Actionable errors are logged at the INFO level because transport errors + // might be retryable and self-healing. Logging at ERROR would trigger + // unintended production alerts for transient issues. + if (logger.isInfoEnabled()) { + Slf4jUtils.log(logger, org.slf4j.event.Level.INFO, logContext, message); + } + } + } + public static void executeWithTryCatch(ThrowingRunnable action) { try { action.run(); diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java index 9e3099e929..e69e3a5689 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java @@ -33,11 +33,20 @@ 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.google.api.gax.logging.LoggingUtils.ThrowingRunnable; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.slf4j.Logger; class LoggingUtilsTest { @@ -77,4 +86,53 @@ void testExecuteWithTryCatch_WithNoSuchMethodError() throws Throwable { // Verify that the action was executed (despite the error) verify(action).run(); } + + @AfterEach + void tearDown() { + LoggingUtils.setLoggingEnabled(false); + } + + @Test + void testLogActionableError_loggingDisabled() { + LoggingUtils.setLoggingEnabled(false); + LoggerProvider loggerProvider = mock(LoggerProvider.class); + + LoggingUtils.logActionableError(Collections.emptyMap(), loggerProvider, "message"); + + verify(loggerProvider, never()).getLogger(); + } + + @Test + void testLogActionableError_infoDisabled() { + LoggingUtils.setLoggingEnabled(true); + LoggerProvider loggerProvider = mock(LoggerProvider.class); + Logger logger = mock(Logger.class); + when(loggerProvider.getLogger()).thenReturn(logger); + when(logger.isInfoEnabled()).thenReturn(false); + + LoggingUtils.logActionableError(Collections.emptyMap(), loggerProvider, "message"); + + verify(loggerProvider).getLogger(); + verify(logger).isInfoEnabled(); + verify(logger, never()).info(anyString()); + } + + @Test + void testLogActionableError_success() { + LoggingUtils.setLoggingEnabled(true); + LoggerProvider loggerProvider = mock(LoggerProvider.class); + Logger logger = mock(Logger.class); + when(loggerProvider.getLogger()).thenReturn(logger); + when(logger.isInfoEnabled()).thenReturn(true); + + org.slf4j.spi.LoggingEventBuilder eventBuilder = mock(org.slf4j.spi.LoggingEventBuilder.class); + when(logger.atInfo()).thenReturn(eventBuilder); + when(eventBuilder.addKeyValue(anyString(), any())).thenReturn(eventBuilder); + + Map context = Collections.singletonMap("key", "value"); + LoggingUtils.logActionableError(context, loggerProvider, "message"); + + verify(loggerProvider).getLogger(); + verify(logger).isInfoEnabled(); + } }