diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 0299e1a80..8e1d6e5a3 100755 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -12,6 +12,8 @@ ### Documentation ### Internal Changes +* Introduced a logging abstraction (`com.databricks.sdk.core.logging`) to decouple the SDK from a specific logging backend. +* Added `java.util.logging` as a supported alternative logging backend. Activate it with `LoggerFactory.setDefault(JulLoggerFactory.INSTANCE)`. ### API Changes * Add `createCatalog()`, `createSyncedTable()`, `deleteCatalog()`, `deleteSyncedTable()`, `getCatalog()` and `getSyncedTable()` methods for `workspaceClient.postgres()` service. diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLogger.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLogger.java new file mode 100644 index 000000000..bfc814b2a --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLogger.java @@ -0,0 +1,208 @@ +package com.databricks.sdk.core.logging; + +import java.util.Arrays; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +/** Delegates logging calls to a {@code java.util.logging.Logger}, translating SLF4J conventions. */ +class JulLogger extends Logger { + + private static final String LOGGING_PACKAGE = "com.databricks.sdk.core.logging."; + + private final java.util.logging.Logger delegate; + + JulLogger(java.util.logging.Logger delegate) { + this.delegate = delegate; + } + + @Override + public void debug(String msg) { + log(Level.FINE, msg, null); + } + + @Override + public void debug(String format, Object... args) { + log(Level.FINE, format, args); + } + + @Override + public void debug(Supplier msgSupplier) { + if (delegate.isLoggable(Level.FINE)) { + log(Level.FINE, msgSupplier.get(), null); + } + } + + @Override + public void info(String msg) { + log(Level.INFO, msg, null); + } + + @Override + public void info(String format, Object... args) { + log(Level.INFO, format, args); + } + + @Override + public void info(Supplier msgSupplier) { + if (delegate.isLoggable(Level.INFO)) { + log(Level.INFO, msgSupplier.get(), null); + } + } + + @Override + public void warn(String msg) { + log(Level.WARNING, msg, null); + } + + @Override + public void warn(String format, Object... args) { + log(Level.WARNING, format, args); + } + + @Override + public void warn(Supplier msgSupplier) { + if (delegate.isLoggable(Level.WARNING)) { + log(Level.WARNING, msgSupplier.get(), null); + } + } + + @Override + public void error(String msg) { + log(Level.SEVERE, msg, null); + } + + @Override + public void error(String format, Object... args) { + log(Level.SEVERE, format, args); + } + + @Override + public void error(Supplier msgSupplier) { + if (delegate.isLoggable(Level.SEVERE)) { + log(Level.SEVERE, msgSupplier.get(), null); + } + } + + private void log(Level level, String format, Object[] args) { + if (!delegate.isLoggable(level)) { + return; + } + Throwable thrown = (args != null) ? extractThrowable(format, args) : null; + String message = (args != null) ? formatMessage(format, args) : format; + LogRecord record = new LogRecord(level, message); + record.setLoggerName(delegate.getName()); + if (thrown != null) { + record.setThrown(thrown); + } + inferCaller(record); + delegate.log(record); + } + + /** + * Sets the source class and method on a {@link LogRecord} by walking the call stack to find the + * first frame outside this logging package. + * + *

JUL normally infers caller information automatically by scanning the stack for the first + * frame after its own {@code java.util.logging.Logger} methods. Because {@code JulLogger} wraps + * the JUL logger, that automatic inference stops at {@code JulLogger} or its helper methods + * instead of reaching the actual SDK class that initiated the log call. Without this correction, + * every log record would be attributed to {@code JulLogger}, making JUL output useless for + * identifying the real call site. + */ + private static void inferCaller(LogRecord record) { + StackTraceElement[] stack = new Throwable().getStackTrace(); + for (StackTraceElement frame : stack) { + if (!frame.getClassName().startsWith(LOGGING_PACKAGE)) { + record.setSourceClassName(frame.getClassName()); + record.setSourceMethodName(frame.getMethodName()); + return; + } + } + } + + /** + * Replaces SLF4J-style {@code {}} placeholders with argument values, matching the semantics of + * SLF4J's {@code MessageFormatter.arrayFormat}: + * + *

    + *
  • A trailing {@link Throwable} is unconditionally excluded from formatting. + *
  • A backslash before {@code {}} escapes it as a literal {@code {}}. + *
  • Array arguments are rendered with {@link Arrays#deepToString}. + *
  • A {@code null} format string returns {@code null}. + *
+ */ + static String formatMessage(String format, Object[] args) { + if (format == null) { + return null; + } + if (args == null || args.length == 0) { + return format; + } + int usableArgs = args.length; + if (args[usableArgs - 1] instanceof Throwable) { + usableArgs--; + } + StringBuilder sb = new StringBuilder(format.length() + 32); + int argIdx = 0; + int i = 0; + while (i < format.length()) { + if (i + 1 < format.length() && format.charAt(i) == '{' && format.charAt(i + 1) == '}') { + if (i > 0 && format.charAt(i - 1) == '\\') { + sb.setLength(sb.length() - 1); + sb.append("{}"); + } else if (argIdx < usableArgs) { + sb.append(renderArg(args[argIdx++])); + } else { + sb.append("{}"); + } + i += 2; + } else { + sb.append(format.charAt(i)); + i++; + } + } + return sb.toString(); + } + + private static String renderArg(Object arg) { + if (arg == null) { + return "null"; + } + if (arg instanceof Object[]) { + return Arrays.deepToString((Object[]) arg); + } + if (arg.getClass().isArray()) { + return primitiveArrayToString(arg); + } + return arg.toString(); + } + + private static String primitiveArrayToString(Object array) { + if (array instanceof boolean[]) return Arrays.toString((boolean[]) array); + if (array instanceof byte[]) return Arrays.toString((byte[]) array); + if (array instanceof char[]) return Arrays.toString((char[]) array); + if (array instanceof short[]) return Arrays.toString((short[]) array); + if (array instanceof int[]) return Arrays.toString((int[]) array); + if (array instanceof long[]) return Arrays.toString((long[]) array); + if (array instanceof float[]) return Arrays.toString((float[]) array); + if (array instanceof double[]) return Arrays.toString((double[]) array); + return Arrays.deepToString(new Object[] {array}); + } + + /** + * Returns the last argument if it is a {@link Throwable}, unconditionally. This matches SLF4J's + * {@code NormalizedParameters.getThrowableCandidate}, which always extracts a trailing Throwable + * regardless of how many {@code {}} placeholders the format string contains. + */ + static Throwable extractThrowable(String format, Object[] args) { + if (args == null || args.length == 0) { + return null; + } + Object last = args[args.length - 1]; + if (last instanceof Throwable) { + return (Throwable) last; + } + return null; + } +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLoggerFactory.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLoggerFactory.java new file mode 100644 index 000000000..0dbd7dfc1 --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/JulLoggerFactory.java @@ -0,0 +1,25 @@ +package com.databricks.sdk.core.logging; + +/** + * A {@link LoggerFactory} backed by {@code java.util.logging}. Always available on any JRE. + * + *

Use this when SLF4J is not desirable: + * + *

{@code
+ * LoggerFactory.setDefault(JulLoggerFactory.INSTANCE);
+ * }
+ */ +public class JulLoggerFactory extends LoggerFactory { + + public static final JulLoggerFactory INSTANCE = new JulLoggerFactory(); + + @Override + protected Logger createLogger(Class type) { + return new JulLogger(java.util.logging.Logger.getLogger(type.getName())); + } + + @Override + protected Logger createLogger(String name) { + return new JulLogger(java.util.logging.Logger.getLogger(name)); + } +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Logger.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Logger.java new file mode 100644 index 000000000..3041bc4da --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Logger.java @@ -0,0 +1,36 @@ +package com.databricks.sdk.core.logging; + +import java.util.function.Supplier; + +/** + * Logging contract used throughout the SDK. + * + *

Extend this class to provide a custom logging implementation, then register it via a custom + * {@link LoggerFactory} subclass and {@link LoggerFactory#setDefault}. + */ +public abstract class Logger { + + public abstract void debug(String msg); + + public abstract void debug(String format, Object... args); + + public abstract void debug(Supplier msgSupplier); + + public abstract void info(String msg); + + public abstract void info(String format, Object... args); + + public abstract void info(Supplier msgSupplier); + + public abstract void warn(String msg); + + public abstract void warn(String format, Object... args); + + public abstract void warn(Supplier msgSupplier); + + public abstract void error(String msg); + + public abstract void error(String format, Object... args); + + public abstract void error(Supplier msgSupplier); +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/LoggerFactory.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/LoggerFactory.java new file mode 100644 index 000000000..229d90205 --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/LoggerFactory.java @@ -0,0 +1,65 @@ +package com.databricks.sdk.core.logging; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Creates and configures {@link Logger} instances for the SDK. + * + *

By default, logging goes through SLF4J. Users can override the backend programmatically before + * creating any SDK client: + * + *

{@code
+ * LoggerFactory.setDefault(JulLoggerFactory.INSTANCE);
+ * WorkspaceClient ws = new WorkspaceClient();
+ * }
+ * + *

Extend this class to provide a fully custom logging backend. + */ +public abstract class LoggerFactory { + + private static final AtomicReference defaultFactory = new AtomicReference<>(); + + /** Returns a logger for the given class, using the current default factory. */ + public static Logger getLogger(Class type) { + return getDefault().createLogger(type); + } + + /** Returns a logger with the given name, using the current default factory. */ + public static Logger getLogger(String name) { + return getDefault().createLogger(name); + } + + /** + * Overrides the logging backend used by the SDK. + * + *

Must be called before creating any SDK client or calling {@link #getLogger}. Loggers already + * obtained will not be affected by subsequent calls. + */ + public static void setDefault(LoggerFactory factory) { + if (factory == null) { + throw new IllegalArgumentException("LoggerFactory must not be null"); + } + defaultFactory.set(factory); + } + + static LoggerFactory getDefault() { + LoggerFactory f = defaultFactory.get(); + if (f != null) { + return f; + } + defaultFactory.compareAndSet(null, Slf4jLoggerFactory.INSTANCE); + return defaultFactory.get(); + } + + /** + * Creates a {@link Logger} for the given class. Subclasses obtain the backend logger (e.g. SLF4J) + * and return an adapter. + */ + protected abstract Logger createLogger(Class type); + + /** + * Creates a {@link Logger} for the given name. Subclasses obtain the backend logger and return an + * adapter. + */ + protected abstract Logger createLogger(String name); +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLogger.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLogger.java new file mode 100644 index 000000000..5c52d8c93 --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLogger.java @@ -0,0 +1,85 @@ +package com.databricks.sdk.core.logging; + +import java.util.function.Supplier; + +/** Delegates all logging calls to an SLF4J {@code Logger}. */ +class Slf4jLogger extends Logger { + + private final org.slf4j.Logger delegate; + + Slf4jLogger(org.slf4j.Logger delegate) { + this.delegate = delegate; + } + + @Override + public void debug(String msg) { + if (delegate.isDebugEnabled()) { + delegate.debug(msg); + } + } + + @Override + public void debug(String format, Object... args) { + if (delegate.isDebugEnabled()) { + delegate.debug(format, args); + } + } + + @Override + public void debug(Supplier msgSupplier) { + if (delegate.isDebugEnabled()) { + delegate.debug(msgSupplier.get()); + } + } + + @Override + public void info(String msg) { + delegate.info(msg); + } + + @Override + public void info(String format, Object... args) { + delegate.info(format, args); + } + + @Override + public void info(Supplier msgSupplier) { + if (delegate.isInfoEnabled()) { + delegate.info(msgSupplier.get()); + } + } + + @Override + public void warn(String msg) { + delegate.warn(msg); + } + + @Override + public void warn(String format, Object... args) { + delegate.warn(format, args); + } + + @Override + public void warn(Supplier msgSupplier) { + if (delegate.isWarnEnabled()) { + delegate.warn(msgSupplier.get()); + } + } + + @Override + public void error(String msg) { + delegate.error(msg); + } + + @Override + public void error(String format, Object... args) { + delegate.error(format, args); + } + + @Override + public void error(Supplier msgSupplier) { + if (delegate.isErrorEnabled()) { + delegate.error(msgSupplier.get()); + } + } +} diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLoggerFactory.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLoggerFactory.java new file mode 100644 index 000000000..4687a169f --- /dev/null +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/Slf4jLoggerFactory.java @@ -0,0 +1,17 @@ +package com.databricks.sdk.core.logging; + +/** A {@link LoggerFactory} backed by SLF4J. */ +public class Slf4jLoggerFactory extends LoggerFactory { + + public static final Slf4jLoggerFactory INSTANCE = new Slf4jLoggerFactory(); + + @Override + protected Logger createLogger(Class type) { + return new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(type)); + } + + @Override + protected Logger createLogger(String name) { + return new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(name)); + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/JulLoggerTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/JulLoggerTest.java new file mode 100644 index 000000000..566b4b6fe --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/JulLoggerTest.java @@ -0,0 +1,207 @@ +package com.databricks.sdk.core.logging; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class JulLoggerTest { + + // ---- Formatter unit tests ---- + + @Test + void formatMessageNoPlaceholders() { + assertEquals("hello world", JulLogger.formatMessage("hello world", new Object[] {})); + } + + @Test + void formatMessageNullArgs() { + assertEquals("hello", JulLogger.formatMessage("hello", (Object[]) null)); + } + + @Test + void formatMessageSinglePlaceholder() { + assertEquals("hello world", JulLogger.formatMessage("hello {}", new Object[] {"world"})); + } + + @Test + void formatMessageMultiplePlaceholders() { + assertEquals("a=1, b=2", JulLogger.formatMessage("a={}, b={}", new Object[] {"1", "2"})); + } + + @Test + void formatMessageTrailingThrowableExcluded() { + Exception ex = new RuntimeException("boom"); + String result = JulLogger.formatMessage("Failed at {}: {}", new Object[] {"host", "msg", ex}); + assertEquals("Failed at host: msg", result); + } + + @Test + void formatMessageThrowableIsAlwaysExcluded() { + Exception ex = new RuntimeException("boom"); + String result = JulLogger.formatMessage("Error: {}", new Object[] {ex}); + assertEquals("Error: {}", result); + } + + @Test + void extractThrowableWhenTrailing() { + Exception ex = new RuntimeException("boom"); + Throwable result = JulLogger.extractThrowable("Failed: {}", new Object[] {"msg", ex}); + assertSame(ex, result); + } + + @Test + void extractThrowableNullWhenNotTrailing() { + assertNull(JulLogger.extractThrowable("a={}, b={}", new Object[] {"1", "2"})); + } + + @Test + void extractThrowableAlwaysWhenLastArgIsThrowable() { + Exception ex = new RuntimeException("boom"); + assertSame(ex, JulLogger.extractThrowable("Error: {}", new Object[] {ex})); + } + + @Test + void extractThrowableNullArgs() { + assertNull(JulLogger.extractThrowable("msg", (Object[]) null)); + } + + @Test + void extractThrowableEmptyArgs() { + assertNull(JulLogger.extractThrowable("msg", new Object[] {})); + } + + // ---- End-to-end capturing tests ---- + + static Stream logCalls() { + RuntimeException ex = new RuntimeException("boom"); + return Stream.of( + Arguments.of("debug", "hello", null, "hello", null), + Arguments.of("info", "hello", null, "hello", null), + Arguments.of("warn", "hello", null, "hello", null), + Arguments.of("error", "hello", null, "hello", null), + Arguments.of( + "info", "user {} logged in", new Object[] {"alice"}, "user alice logged in", null), + Arguments.of("info", "a={}, b={}", new Object[] {1, 2}, "a=1, b=2", null), + Arguments.of("error", "failed: {}", new Object[] {"op", ex}, "failed: op", ex), + Arguments.of("error", "Error: {}", new Object[] {ex}, "Error: {}", ex), + Arguments.of("error", "Something broke", new Object[] {ex}, "Something broke", ex)); + } + + @ParameterizedTest(name = "[{index}] {0}(\"{1}\")") + @MethodSource("logCalls") + void deliversCorrectOutput( + String level, String format, Object[] args, String expectedMsg, Throwable expectedThrown) { + java.util.logging.Logger julLogger = + java.util.logging.Logger.getLogger(JulLoggerTest.class.getName()); + Level originalLevel = julLogger.getLevel(); + julLogger.setLevel(Level.ALL); + CapturingHandler handler = new CapturingHandler(); + julLogger.addHandler(handler); + try { + Logger logger = + new JulLogger(java.util.logging.Logger.getLogger(JulLoggerTest.class.getName())); + dispatch(logger, level, format, args); + + assertEquals(1, handler.records.size(), "Expected exactly one log record"); + LogRecord record = handler.records.get(0); + assertEquals(expectedMsg, record.getMessage()); + assertEquals(toJulLevel(level), record.getLevel()); + if (expectedThrown != null) { + assertSame(expectedThrown, record.getThrown()); + } else { + assertNull(record.getThrown(), "Expected no throwable"); + } + } finally { + julLogger.removeHandler(handler); + julLogger.setLevel(originalLevel); + } + } + + @Test + void callerInferenceSkipsLoggingPackage() { + java.util.logging.Logger julLogger = + java.util.logging.Logger.getLogger(JulLoggerTest.class.getName()); + Level originalLevel = julLogger.getLevel(); + julLogger.setLevel(Level.ALL); + CapturingHandler handler = new CapturingHandler(); + julLogger.addHandler(handler); + try { + Logger logger = + new JulLogger(java.util.logging.Logger.getLogger(JulLoggerTest.class.getName())); + logger.info("test"); + + assertEquals(1, handler.records.size()); + String sourceClass = handler.records.get(0).getSourceClassName(); + assertFalse( + sourceClass.startsWith("com.databricks.sdk.core.logging."), + "Source class should not be in the logging package, but was: " + sourceClass); + } finally { + julLogger.removeHandler(handler); + julLogger.setLevel(originalLevel); + } + } + + // ---- Helpers ---- + + private static void dispatch(Logger logger, String level, String format, Object[] args) { + switch (level) { + case "debug": + if (args != null) logger.debug(format, args); + else logger.debug(format); + break; + case "info": + if (args != null) logger.info(format, args); + else logger.info(format); + break; + case "warn": + if (args != null) logger.warn(format, args); + else logger.warn(format); + break; + case "error": + if (args != null) logger.error(format, args); + else logger.error(format); + break; + default: + throw new IllegalArgumentException("Unknown level: " + level); + } + } + + private static Level toJulLevel(String level) { + switch (level) { + case "debug": + return Level.FINE; + case "info": + return Level.INFO; + case "warn": + return Level.WARNING; + case "error": + return Level.SEVERE; + default: + throw new IllegalArgumentException("Unknown level: " + level); + } + } + + static class CapturingHandler extends Handler { + final List records = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() {} + + @Override + public void close() {} + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggerFactoryTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggerFactoryTest.java new file mode 100644 index 000000000..652c26d7e --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggerFactoryTest.java @@ -0,0 +1,49 @@ +package com.databricks.sdk.core.logging; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class LoggerFactoryTest { + + @AfterEach + void resetFactory() { + LoggerFactory.setDefault(Slf4jLoggerFactory.INSTANCE); + } + + @Test + void defaultFactoryIsSLF4J() { + Logger logger = LoggerFactory.getLogger(LoggerFactoryTest.class); + assertNotNull(logger); + logger.info("LoggerFactory defaultFactoryIsSLF4J test message"); + } + + @Test + void setDefaultRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> LoggerFactory.setDefault(null)); + } + + @Test + void setDefaultSwitchesToJul() { + LoggerFactory.setDefault(JulLoggerFactory.INSTANCE); + Logger logger = LoggerFactory.getLogger(LoggerFactoryTest.class); + assertNotNull(logger); + logger.info("setDefaultSwitchesToJul test message via JUL"); + } + + @Test + void getLoggerByNameWorks() { + Logger logger = LoggerFactory.getLogger("com.example.Test"); + assertNotNull(logger); + logger.info("getLoggerByNameWorks test message"); + } + + @Test + void getLoggerByNameWorksWithJul() { + LoggerFactory.setDefault(JulLoggerFactory.INSTANCE); + Logger logger = LoggerFactory.getLogger("com.example.Test"); + assertNotNull(logger); + logger.info("getLoggerByNameWorksWithJul test message"); + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggingParityTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggingParityTest.java new file mode 100644 index 000000000..e57e2051b --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggingParityTest.java @@ -0,0 +1,88 @@ +package com.databricks.sdk.core.logging; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.slf4j.helpers.FormattingTuple; +import org.slf4j.helpers.MessageFormatter; + +/** + * Verifies that JulLogger's placeholder formatting and Throwable extraction produce the same + * results as SLF4J's {@link MessageFormatter#arrayFormat}, so the two backends behave identically + * for any given Logger call. + */ +public class LoggingParityTest { + + @Test + void singleThrowableArgIsExtractedNotFormatted() { + Exception ex = new RuntimeException("boom"); + Object[] args = {ex}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("Error: {}", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("Error: {}", args)); + assertEquals(slf4j.getThrowable(), JulLogger.extractThrowable("Error: {}", args)); + } + + @Test + void trailingThrowableBeyondPlaceholders() { + Exception ex = new RuntimeException("boom"); + Object[] args = {"op", ex}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("Error: {} failed", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("Error: {} failed", args)); + assertEquals(slf4j.getThrowable(), JulLogger.extractThrowable("Error: {} failed", args)); + } + + @Test + void noPlaceholdersTrailingThrowable() { + Exception ex = new RuntimeException("boom"); + Object[] args = {ex}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("Something broke", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("Something broke", args)); + assertEquals(slf4j.getThrowable(), JulLogger.extractThrowable("Something broke", args)); + } + + @Test + void nonThrowableArgsNoExtraction() { + Object[] args = {"a", "b"}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("{} {}", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("{} {}", args)); + assertEquals(slf4j.getThrowable(), JulLogger.extractThrowable("{} {}", args)); + } + + @Test + void multipleArgsWithTrailingThrowable() { + Exception ex = new RuntimeException("boom"); + Object[] args = {"host", 8080, ex}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("Connect to {}:{}", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("Connect to {}:{}", args)); + assertEquals(slf4j.getThrowable(), JulLogger.extractThrowable("Connect to {}:{}", args)); + } + + @Test + void arrayArgumentRenderedLikeSlf4j() { + Object[] args = {new String[] {"a", "b"}}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("arr {}", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("arr {}", args)); + } + + @Test + void escapedPlaceholderRenderedLikeSlf4j() { + Object[] args = {"x"}; + FormattingTuple slf4j = MessageFormatter.arrayFormat("escaped \\{} {}", args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage("escaped \\{} {}", args)); + } + + @Test + void nullFormatRenderedLikeSlf4j() { + Object[] args = {"x"}; + FormattingTuple slf4j = MessageFormatter.arrayFormat(null, args); + + assertEquals(slf4j.getMessage(), JulLogger.formatMessage(null, args)); + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/Slf4jLoggerTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/Slf4jLoggerTest.java new file mode 100644 index 000000000..54582b184 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/Slf4jLoggerTest.java @@ -0,0 +1,119 @@ +package com.databricks.sdk.core.logging; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.log4j.AppenderSkeleton; +import org.apache.log4j.spi.LoggingEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class Slf4jLoggerTest { + + @Test + void getLoggerReturnsSlf4jLogger() { + Logger logger = LoggerFactory.getLogger(Slf4jLoggerTest.class); + assertNotNull(logger); + assertTrue(logger instanceof Slf4jLogger); + } + + static Stream logCalls() { + RuntimeException ex = new RuntimeException("boom"); + return Stream.of( + Arguments.of("debug", "hello", null, "hello", null), + Arguments.of("info", "hello", null, "hello", null), + Arguments.of("warn", "hello", null, "hello", null), + Arguments.of("error", "hello", null, "hello", null), + Arguments.of( + "info", "user {} logged in", new Object[] {"alice"}, "user alice logged in", null), + Arguments.of("info", "a={}, b={}", new Object[] {1, 2}, "a=1, b=2", null), + Arguments.of("error", "failed: {}", new Object[] {"op", ex}, "failed: op", ex), + Arguments.of("error", "Error: {}", new Object[] {ex}, "Error: {}", ex), + Arguments.of("error", "Something broke", new Object[] {ex}, "Something broke", ex)); + } + + @ParameterizedTest(name = "[{index}] {0}(\"{1}\")") + @MethodSource("logCalls") + void deliversCorrectOutput( + String level, String format, Object[] args, String expectedMsg, Throwable expectedThrown) { + CapturingAppender appender = new CapturingAppender(); + org.apache.log4j.Logger log4jLogger = org.apache.log4j.Logger.getLogger(Slf4jLoggerTest.class); + log4jLogger.addAppender(appender); + try { + Logger logger = new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(Slf4jLoggerTest.class)); + dispatch(logger, level, format, args); + + assertEquals(1, appender.events.size(), "Expected exactly one log event"); + LoggingEvent event = appender.events.get(0); + assertEquals(expectedMsg, event.getRenderedMessage()); + assertEquals(toLog4jLevel(level), event.getLevel()); + if (expectedThrown != null) { + assertNotNull(event.getThrowableInformation(), "Expected throwable to be attached"); + assertSame(expectedThrown, event.getThrowableInformation().getThrowable()); + } else { + assertNull(event.getThrowableInformation(), "Expected no throwable"); + } + } finally { + log4jLogger.removeAppender(appender); + } + } + + private static void dispatch(Logger logger, String level, String format, Object[] args) { + switch (level) { + case "debug": + if (args != null) logger.debug(format, args); + else logger.debug(format); + break; + case "info": + if (args != null) logger.info(format, args); + else logger.info(format); + break; + case "warn": + if (args != null) logger.warn(format, args); + else logger.warn(format); + break; + case "error": + if (args != null) logger.error(format, args); + else logger.error(format); + break; + default: + throw new IllegalArgumentException("Unknown level: " + level); + } + } + + private static org.apache.log4j.Level toLog4jLevel(String level) { + switch (level) { + case "debug": + return org.apache.log4j.Level.DEBUG; + case "info": + return org.apache.log4j.Level.INFO; + case "warn": + return org.apache.log4j.Level.WARN; + case "error": + return org.apache.log4j.Level.ERROR; + default: + throw new IllegalArgumentException("Unknown level: " + level); + } + } + + static class CapturingAppender extends AppenderSkeleton { + final List events = new ArrayList<>(); + + @Override + protected void append(LoggingEvent event) { + events.add(event); + } + + @Override + public void close() {} + + @Override + public boolean requiresLayout() { + return false; + } + } +}