Skip to content

Add java.util.logging backend#741

Draft
mihaimitrea-db wants to merge 2 commits intomainfrom
mihaimitrea-db/stack/logging-jul
Draft

Add java.util.logging backend#741
mihaimitrea-db wants to merge 2 commits intomainfrom
mihaimitrea-db/stack/logging-jul

Conversation

@mihaimitrea-db
Copy link
Copy Markdown
Contributor

@mihaimitrea-db mihaimitrea-db commented Mar 26, 2026

🥞 Stacked PR

Use this link to review incremental changes.


Summary

Adds a java.util.logging (JUL) backend for the logging abstraction introduced in PR #740. Users who cannot or prefer not to use SLF4J can switch to JUL with a single line before creating any SDK client.

Why

SLF4J is the right default for most users, but some environments (BI tools, embedded runtimes, minimal deployments) either don't ship an SLF4J binding or make it difficult to configure one. In those cases, the JDK's built-in java.util.logging is the only logging framework guaranteed to be available.

Without a JUL backend, users in these environments would get silent NOP logging (if SLF4J has no binding) or would have to shim their own adapter. Providing a first-party JUL backend that they can activate with LoggerFactory.setDefault(JulLoggerFactory.INSTANCE) removes that friction.

What changed

Interface changes

  • JulLoggerFactory — new public concrete LoggerFactory subclass with a singleton INSTANCE. Activating JUL is one line: LoggerFactory.setDefault(JulLoggerFactory.INSTANCE).

Behavioral changes

None. The default backend is still SLF4J. JUL is only used when explicitly opted into.

Internal changes

  • JulLogger — package-private class that delegates to a java.util.logging.Logger. Key implementation details:
    • SLF4J-parity formatting: {} placeholders are substituted following the same semantics as SLF4J's MessageFormatter.arrayFormat — trailing Throwables are unconditionally extracted and attached to the LogRecord, escaped \{} is rendered as literal {}, array arguments use Arrays.deepToString, and null format strings return null.
    • Caller inference: All log calls go through a single log(Level, String, Object[]) method that constructs a LogRecord and walks the stack to set the correct source class/method, since JUL's automatic caller inference would otherwise attribute every record to JulLogger.
    • Level mapping: debugFINE, infoINFO, warnWARNING, errorSEVERE.
  • LoggerFactory Javadoc — updated the usage example to reference JulLoggerFactory.
  • LoggerFactoryTest — added setDefaultSwitchesToJul and getLoggerByNameWorksWithJul tests.

How is this tested?

  • JulLoggerTest — 12 tests covering placeholder formatting, trailing Throwable extraction, null/empty args, and end-to-end level mapping through all log methods.
  • LoggingParityTest — 8 tests that compare JulLogger.formatMessage and JulLogger.extractThrowable directly against org.slf4j.helpers.MessageFormatter.arrayFormat for the same inputs, covering: single Throwable arg, trailing Throwable beyond placeholders, no-placeholder Throwable, non-Throwable args, multi-arg with Throwable, array rendering, escaped placeholders, and null format strings.
  • LoggerFactoryTest — 2 additional tests for JUL factory switching via setDefault.
  • Full test suite (1140 tests) passes.

@mihaimitrea-db mihaimitrea-db self-assigned this Mar 26, 2026
@mihaimitrea-db mihaimitrea-db force-pushed the mihaimitrea-db/stack/logging-jul branch 2 times, most recently from 35cb224 to 80ecee4 Compare March 30, 2026 08:16
@mihaimitrea-db
Copy link
Copy Markdown
Contributor Author

Range-diff: stack/logging-abstraction (35cb224 -> 80ecee4)
databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/JulLoggerTest.java
@@ -6,10 +6,21 @@
 +
 +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[] {}));
@@ -72,18 +83,139 @@
 +    assertNull(JulLogger.extractThrowable("msg", new Object[] {}));
 +  }
 +
++  // ---- End-to-end capturing tests ----
++
++  static Stream<Arguments> 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 = JulLogger.create(JulLoggerTest.class);
++      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 julLoggerLevelMapping() {
-+    Logger logger = JulLogger.create(JulLoggerTest.class);
-+    assertNotNull(logger);
-+    logger.debug("debug msg");
-+    logger.debug("debug {} {}", "a", "b");
-+    logger.info("info msg");
-+    logger.info("info {}", "val");
-+    logger.warn("warn msg");
-+    logger.warn("warn {}", "val");
-+    logger.error("error msg");
-+    logger.error("error {}", "val");
-+    logger.error("error {} failed", "op", new RuntimeException("cause"));
++  void isDebugEnabledReflectsJulLevel() {
++    java.util.logging.Logger julLogger =
++        java.util.logging.Logger.getLogger("isDebugTest");
++    Logger logger = JulLogger.create("isDebugTest");
++
++    julLogger.setLevel(Level.FINE);
++    assertTrue(logger.isDebugEnabled());
++
++    julLogger.setLevel(Level.INFO);
++    assertFalse(logger.isDebugEnabled());
++  }
++
++  @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 = JulLogger.create(JulLoggerTest.class);
++      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<LogRecord> records = new ArrayList<>();
++
++    @Override
++    public void publish(LogRecord record) {
++      records.add(record);
++    }
++
++    @Override
++    public void flush() {}
++
++    @Override
++    public void close() {}
 +  }
 +}
\ No newline at end of file

Reproduce locally: git range-diff 99b473a..35cb224 d550d5e..80ecee4 | Disable: git config gitstack.push-range-diff false

@mihaimitrea-db mihaimitrea-db force-pushed the mihaimitrea-db/stack/logging-jul branch from 80ecee4 to 02b6cb5 Compare March 30, 2026 08:33
@mihaimitrea-db
Copy link
Copy Markdown
Contributor Author

Range-diff: stack/logging-abstraction (80ecee4 -> 02b6cb5)
NEXT_CHANGELOG.md
@@ -0,0 +1,10 @@
+diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md
+--- a/NEXT_CHANGELOG.md
++++ b/NEXT_CHANGELOG.md
+ 
+ ### Internal Changes
+ * Introduced a logging abstraction (`com.databricks.sdk.core.logging`) that decouples the SDK from SLF4J. Users can now provide their own logging backend by extending `LoggerFactory` and calling `LoggerFactory.setDefault()` before creating any SDK client. SLF4J remains the default.
++* 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.
\ No newline at end of file
databricks-sdk-java/src/main/java/com/databricks/sdk/core/logging/LoggerFactory.java
@@ -1,7 +1,7 @@
 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
 --- 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
-  * before creating any SDK client:
+  * creating any SDK client:
   *
   * <pre>{@code
 - * LoggerFactory.setDefault(myCustomFactory);
databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/JulLoggerTest.java
@@ -92,7 +92,8 @@
 +        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", "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),
@@ -130,8 +131,7 @@
 +
 +  @Test
 +  void isDebugEnabledReflectsJulLevel() {
-+    java.util.logging.Logger julLogger =
-+        java.util.logging.Logger.getLogger("isDebugTest");
++    java.util.logging.Logger julLogger = java.util.logging.Logger.getLogger("isDebugTest");
 +    Logger logger = JulLogger.create("isDebugTest");
 +
 +    julLogger.setLevel(Level.FINE);
databricks-sdk-java/src/test/java/com/databricks/sdk/core/logging/LoggingParityTest.java
@@ -7,8 +7,8 @@
 +import static org.junit.jupiter.api.Assertions.*;
 +
 +import org.junit.jupiter.api.Test;
++import org.slf4j.helpers.FormattingTuple;
 +import org.slf4j.helpers.MessageFormatter;
-+import org.slf4j.helpers.FormattingTuple;
 +
 +/**
 + * Verifies that JulLogger's placeholder formatting and Throwable extraction produce the same

Reproduce locally: git range-diff d550d5e..80ecee4 9597522..02b6cb5 | Disable: git config gitstack.push-range-diff false

@github-actions
Copy link
Copy Markdown

If integration tests don't run automatically, an authorized user can run them manually by following the instructions below:

Trigger:
go/deco-tests-run/sdk-java

Inputs:

  • PR number: 741
  • Commit SHA: 02b6cb5580fb39a092f5195e8c883fc282c78469

Checks will be approved automatically on success.

@mihaimitrea-db mihaimitrea-db deployed to test-trigger-is March 30, 2026 08:35 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant