Skip to content

Commit 89297b1

Browse files
Add logging abstraction with SLF4J backend (#740)
## 🥞 Stacked PR Use this [link](https://github.com/databricks/databricks-sdk-java/pull/740/files) to review incremental changes. - [**stack/logging-abstraction**](#740) [[Files changed](https://github.com/databricks/databricks-sdk-java/pull/740/files)] - [stack/logging-jul](#741) [[Files changed](https://github.com/databricks/databricks-sdk-java/pull/741/files/5156924f..c04a3808)] - [stack/logging-migration](#742) [[Files changed](https://github.com/databricks/databricks-sdk-java/pull/742/files/c04a3808..d5c03d4f)] --------- ## Summary Introduces a logging abstraction layer (`com.databricks.sdk.core.logging`) that decouples the SDK's internal logging from any specific backend. SLF4J remains the default. This PR contains only the abstraction and the SLF4J backend; the JUL backend and the call-site migration follow in stacked PRs. ## Why The SDK currently imports `org.slf4j.Logger` and `org.slf4j.LoggerFactory` directly in every class that logs. This hard coupling means users who embed the SDK in environments where SLF4J is impractical (e.g. BI tools with constrained classpaths) have no way to switch to an alternative logging backend like `java.util.logging`. We need an indirection layer that lets users swap the logging backend programmatically while keeping SLF4J as the zero-configuration default so that existing users don't have to change anything. The design follows the pattern established by SLF4J itself and [Netty's `InternalLoggerFactory`](https://netty.io/4.1/api/io/netty/util/internal/logging/InternalLoggerFactory.html): an `ILoggerFactory` interface that backends implement, a `LoggerFactory` utility class with static `getLogger` / `setDefault` methods, and a separate `Logger` abstract class that serves as a clean extension point for custom implementations. ## What changed ### Interface changes - **`Logger`** — new abstract class in `com.databricks.sdk.core.logging`. Defines the logging contract: `debug`, `info`, `warn`, `error` (each with plain-string, varargs, and `Supplier<String>` overloads). Users extend this to build custom loggers. - **`ILoggerFactory`** — new interface. Backends implement `createLogger(Class<?>)` and `createLogger(String)` to produce `Logger` instances. Users implement this to provide a fully custom logging backend. - **`LoggerFactory`** — new `final` utility class. Static methods `getLogger(Class<?>)` and `getLogger(String)` return loggers from the current default factory. `setDefault(ILoggerFactory)` overrides the backend — must be called before creating any SDK client. - **`Slf4jLoggerFactory`** — public concrete `ILoggerFactory` implementation with a singleton `INSTANCE`. This is the default. ### Behavioral changes None. SLF4J is the default backend and all logging calls pass through to `org.slf4j.Logger` exactly as before. Existing users see no difference. ### Internal changes - **`Slf4jLogger`** — package-private class that delegates all calls to an `org.slf4j.Logger`. Fully qualifies `org.slf4j.LoggerFactory` references to avoid collision with the SDK's `LoggerFactory`. - All new classes live in `com.databricks.sdk.core.logging`. ## How is this tested? - `LoggerFactoryTest` — verifies the default factory is SLF4J, that `setDefault(null)` is rejected, and that `getLogger(String)` works. - `Slf4jLoggerTest` — verifies `LoggerFactory.getLogger` returns the correct type, exercises all logging methods including varargs and trailing Throwable via a capturing Log4j appender that asserts on message content, level, and attached throwable. - Full test suite passes.
1 parent 9df8048 commit 89297b1

File tree

8 files changed

+358
-0
lines changed

8 files changed

+358
-0
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
### Documentation
1515

1616
### Internal Changes
17+
* Introduced a logging abstraction (`com.databricks.sdk.core.logging`) to decouple the SDK from a specific logging backend.
1718

1819
### API Changes
1920
* Add `createCatalog()`, `createSyncedTable()`, `deleteCatalog()`, `deleteSyncedTable()`, `getCatalog()` and `getSyncedTable()` methods for `workspaceClient.postgres()` service.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
/**
4+
* Provides {@link Logger} instances for a specific logging backend.
5+
*
6+
* <p>Implement this interface to provide a custom logging backend, then register it via {@link
7+
* LoggerFactory#setDefault(ILoggerFactory)}.
8+
*/
9+
public interface ILoggerFactory {
10+
11+
/** Returns a logger for the given class. */
12+
Logger getLogger(Class<?> type);
13+
14+
/** Returns a logger with the given name. */
15+
Logger getLogger(String name);
16+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
import java.util.function.Supplier;
4+
5+
/**
6+
* Logging contract used throughout the SDK.
7+
*
8+
* <p>Extend this class to provide a custom logging implementation, then register it via a custom
9+
* {@link ILoggerFactory} and {@link LoggerFactory#setDefault}.
10+
*/
11+
public abstract class Logger {
12+
13+
public abstract void debug(String msg);
14+
15+
public abstract void debug(String format, Object... args);
16+
17+
public abstract void debug(Supplier<String> msgSupplier);
18+
19+
public abstract void info(String msg);
20+
21+
public abstract void info(String format, Object... args);
22+
23+
public abstract void info(Supplier<String> msgSupplier);
24+
25+
public abstract void warn(String msg);
26+
27+
public abstract void warn(String format, Object... args);
28+
29+
public abstract void warn(Supplier<String> msgSupplier);
30+
31+
public abstract void error(String msg);
32+
33+
public abstract void error(String format, Object... args);
34+
35+
public abstract void error(Supplier<String> msgSupplier);
36+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
import java.util.concurrent.atomic.AtomicReference;
4+
5+
/**
6+
* Static entry point for obtaining {@link Logger} instances.
7+
*
8+
* <p>By default, logging goes through SLF4J. Users can override the backend programmatically before
9+
* creating any SDK client:
10+
*
11+
* <pre>{@code
12+
* LoggerFactory.setDefault(myCustomFactory);
13+
* WorkspaceClient ws = new WorkspaceClient();
14+
* }</pre>
15+
*
16+
* <p>Implement {@link ILoggerFactory} to provide a fully custom logging backend.
17+
*/
18+
public final class LoggerFactory {
19+
20+
private static final AtomicReference<ILoggerFactory> defaultFactory = new AtomicReference<>();
21+
22+
private LoggerFactory() {}
23+
24+
/** Returns a logger for the given class, using the current default factory. */
25+
public static Logger getLogger(Class<?> type) {
26+
return getDefault().getLogger(type);
27+
}
28+
29+
/** Returns a logger with the given name, using the current default factory. */
30+
public static Logger getLogger(String name) {
31+
return getDefault().getLogger(name);
32+
}
33+
34+
/**
35+
* Overrides the logging backend used by the SDK.
36+
*
37+
* <p>Must be called before creating any SDK client or calling {@link #getLogger}. Loggers already
38+
* obtained will not be affected by subsequent calls.
39+
*/
40+
public static void setDefault(ILoggerFactory factory) {
41+
if (factory == null) {
42+
throw new IllegalArgumentException("ILoggerFactory must not be null");
43+
}
44+
defaultFactory.set(factory);
45+
}
46+
47+
static ILoggerFactory getDefault() {
48+
ILoggerFactory f = defaultFactory.get();
49+
if (f != null) {
50+
return f;
51+
}
52+
defaultFactory.compareAndSet(null, Slf4jLoggerFactory.INSTANCE);
53+
return defaultFactory.get();
54+
}
55+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
import java.util.function.Supplier;
4+
5+
/** Delegates all logging calls to an SLF4J {@code Logger}. */
6+
class Slf4jLogger extends Logger {
7+
8+
private final org.slf4j.Logger delegate;
9+
10+
Slf4jLogger(org.slf4j.Logger delegate) {
11+
this.delegate = delegate;
12+
}
13+
14+
@Override
15+
public void debug(String msg) {
16+
delegate.debug(msg);
17+
}
18+
19+
@Override
20+
public void debug(String format, Object... args) {
21+
delegate.debug(format, args);
22+
}
23+
24+
@Override
25+
public void debug(Supplier<String> msgSupplier) {
26+
if (delegate.isDebugEnabled()) {
27+
delegate.debug(msgSupplier.get());
28+
}
29+
}
30+
31+
@Override
32+
public void info(String msg) {
33+
delegate.info(msg);
34+
}
35+
36+
@Override
37+
public void info(String format, Object... args) {
38+
delegate.info(format, args);
39+
}
40+
41+
@Override
42+
public void info(Supplier<String> msgSupplier) {
43+
if (delegate.isInfoEnabled()) {
44+
delegate.info(msgSupplier.get());
45+
}
46+
}
47+
48+
@Override
49+
public void warn(String msg) {
50+
delegate.warn(msg);
51+
}
52+
53+
@Override
54+
public void warn(String format, Object... args) {
55+
delegate.warn(format, args);
56+
}
57+
58+
@Override
59+
public void warn(Supplier<String> msgSupplier) {
60+
if (delegate.isWarnEnabled()) {
61+
delegate.warn(msgSupplier.get());
62+
}
63+
}
64+
65+
@Override
66+
public void error(String msg) {
67+
delegate.error(msg);
68+
}
69+
70+
@Override
71+
public void error(String format, Object... args) {
72+
delegate.error(format, args);
73+
}
74+
75+
@Override
76+
public void error(Supplier<String> msgSupplier) {
77+
if (delegate.isErrorEnabled()) {
78+
delegate.error(msgSupplier.get());
79+
}
80+
}
81+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
/** An {@link ILoggerFactory} backed by SLF4J. */
4+
public class Slf4jLoggerFactory implements ILoggerFactory {
5+
6+
public static final Slf4jLoggerFactory INSTANCE = new Slf4jLoggerFactory();
7+
8+
@Override
9+
public Logger getLogger(Class<?> type) {
10+
return new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(type));
11+
}
12+
13+
@Override
14+
public Logger getLogger(String name) {
15+
return new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(name));
16+
}
17+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.AfterEach;
6+
import org.junit.jupiter.api.Test;
7+
8+
public class LoggerFactoryTest {
9+
10+
@AfterEach
11+
void resetFactory() {
12+
LoggerFactory.setDefault(Slf4jLoggerFactory.INSTANCE);
13+
}
14+
15+
@Test
16+
void defaultFactoryIsSLF4J() {
17+
Logger logger = LoggerFactory.getLogger(LoggerFactoryTest.class);
18+
assertNotNull(logger);
19+
logger.info("LoggerFactory defaultFactoryIsSLF4J test message");
20+
}
21+
22+
@Test
23+
void setDefaultRejectsNull() {
24+
assertThrows(IllegalArgumentException.class, () -> LoggerFactory.setDefault(null));
25+
}
26+
27+
@Test
28+
void getLoggerByNameWorks() {
29+
Logger logger = LoggerFactory.getLogger("com.example.Test");
30+
assertNotNull(logger);
31+
logger.info("getLoggerByNameWorks test message");
32+
}
33+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.databricks.sdk.core.logging;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.stream.Stream;
8+
import org.apache.log4j.AppenderSkeleton;
9+
import org.apache.log4j.spi.LoggingEvent;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.params.ParameterizedTest;
12+
import org.junit.jupiter.params.provider.Arguments;
13+
import org.junit.jupiter.params.provider.MethodSource;
14+
15+
public class Slf4jLoggerTest {
16+
17+
@Test
18+
void getLoggerReturnsSlf4jLogger() {
19+
Logger logger = LoggerFactory.getLogger(Slf4jLoggerTest.class);
20+
assertNotNull(logger);
21+
assertTrue(logger instanceof Slf4jLogger);
22+
}
23+
24+
static Stream<Arguments> logCalls() {
25+
RuntimeException ex = new RuntimeException("boom");
26+
return Stream.of(
27+
Arguments.of("debug", "hello", null, "hello", null),
28+
Arguments.of("info", "hello", null, "hello", null),
29+
Arguments.of("warn", "hello", null, "hello", null),
30+
Arguments.of("error", "hello", null, "hello", null),
31+
Arguments.of(
32+
"info", "user {} logged in", new Object[] {"alice"}, "user alice logged in", null),
33+
Arguments.of("info", "a={}, b={}", new Object[] {1, 2}, "a=1, b=2", null),
34+
Arguments.of("error", "failed: {}", new Object[] {"op", ex}, "failed: op", ex),
35+
Arguments.of("error", "Error: {}", new Object[] {ex}, "Error: {}", ex),
36+
Arguments.of("error", "Something broke", new Object[] {ex}, "Something broke", ex));
37+
}
38+
39+
@ParameterizedTest(name = "[{index}] {0}(\"{1}\")")
40+
@MethodSource("logCalls")
41+
void deliversCorrectOutput(
42+
String level, String format, Object[] args, String expectedMsg, Throwable expectedThrown) {
43+
CapturingAppender appender = new CapturingAppender();
44+
org.apache.log4j.Logger log4jLogger = org.apache.log4j.Logger.getLogger(Slf4jLoggerTest.class);
45+
log4jLogger.addAppender(appender);
46+
try {
47+
Logger logger = new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(Slf4jLoggerTest.class));
48+
dispatch(logger, level, format, args);
49+
50+
assertEquals(1, appender.events.size(), "Expected exactly one log event");
51+
LoggingEvent event = appender.events.get(0);
52+
assertEquals(expectedMsg, event.getRenderedMessage());
53+
assertEquals(toLog4jLevel(level), event.getLevel());
54+
if (expectedThrown != null) {
55+
assertNotNull(event.getThrowableInformation(), "Expected throwable to be attached");
56+
assertSame(expectedThrown, event.getThrowableInformation().getThrowable());
57+
} else {
58+
assertNull(event.getThrowableInformation(), "Expected no throwable");
59+
}
60+
} finally {
61+
log4jLogger.removeAppender(appender);
62+
}
63+
}
64+
65+
private static void dispatch(Logger logger, String level, String format, Object[] args) {
66+
switch (level) {
67+
case "debug":
68+
if (args != null) logger.debug(format, args);
69+
else logger.debug(format);
70+
break;
71+
case "info":
72+
if (args != null) logger.info(format, args);
73+
else logger.info(format);
74+
break;
75+
case "warn":
76+
if (args != null) logger.warn(format, args);
77+
else logger.warn(format);
78+
break;
79+
case "error":
80+
if (args != null) logger.error(format, args);
81+
else logger.error(format);
82+
break;
83+
default:
84+
throw new IllegalArgumentException("Unknown level: " + level);
85+
}
86+
}
87+
88+
private static org.apache.log4j.Level toLog4jLevel(String level) {
89+
switch (level) {
90+
case "debug":
91+
return org.apache.log4j.Level.DEBUG;
92+
case "info":
93+
return org.apache.log4j.Level.INFO;
94+
case "warn":
95+
return org.apache.log4j.Level.WARN;
96+
case "error":
97+
return org.apache.log4j.Level.ERROR;
98+
default:
99+
throw new IllegalArgumentException("Unknown level: " + level);
100+
}
101+
}
102+
103+
static class CapturingAppender extends AppenderSkeleton {
104+
final List<LoggingEvent> events = new ArrayList<>();
105+
106+
@Override
107+
protected void append(LoggingEvent event) {
108+
events.add(event);
109+
}
110+
111+
@Override
112+
public void close() {}
113+
114+
@Override
115+
public boolean requiresLayout() {
116+
return false;
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)