diff --git a/log4j2/README.md b/log4j2/README.md new file mode 100644 index 0000000..6f45b36 --- /dev/null +++ b/log4j2/README.md @@ -0,0 +1,270 @@ +# Reforge SDK Log4j2 Integration + +This module provides seamless integration between the Reforge SDK and Apache Log4j2, enabling dynamic log level management for your application. + +## Overview + +The Log4j2 integration uses an AbstractFilter to intercept logging calls and dynamically determine whether they should be logged based on log level configuration from Reforge. This allows you to: + +- **Centrally manage log levels** - Control logging across your entire application from the Reforge dashboard +- **Real-time updates** - Change log levels without restarting your application +- **Context-aware logging** - Different log levels for different loggers based on runtime context +- **Performance** - Efficient filtering happens before log message construction + +## Installation + +Add the dependency to your `pom.xml`: + +```xml + + com.reforge + sdk-log4j2 + 1.0.3 + +``` + +### Compatibility + +This module is compatible with: +- **Log4j2 2.x** - The module uses only stable Log4j2 Core APIs (AbstractFilter, LoggerContext) that have been stable across Log4j2 2.x versions +- **SLF4J 1.7.x and 2.0.x** (both work with Log4j2) + +**Note:** Log4j2 and SLF4J are marked as `provided` dependencies - your application's versions will be used automatically. We compile against Log4j2 2.19.0, but the APIs used are stable across the 2.x line. Log4j2 works with both SLF4J 1.7.x and 2.0.x. + +## Usage + +### Basic Setup (Programmatic) + +The simplest approach is to install the filter programmatically during application startup: + +```java +import com.reforge.sdk.Sdk; +import com.reforge.sdk.Options; +import com.reforge.sdk.log4j2.ReforgeLog4j2Filter; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class MyApplication { + private static final Logger log = LogManager.getLogger(MyApplication.class); + + public static void main(String[] args) { + // Initialize the Reforge SDK + Sdk sdk = new Sdk(new Options()); + + // Install the Log4j2 filter + ReforgeLog4j2Filter.install(sdk.loggerClient()); + + // Now all your logging will respect Reforge log levels + log.info("Application started with dynamic log levels"); + } +} +``` + +**Important:** +- Install the filter as early as possible in your application startup, ideally right after initializing the Reforge SDK +- Install after Log4j2 is initialized +- Any dynamic reconfiguration of Log4j2 (e.g., via JMX or programmatic changes) will remove the filter and it will need to be reinstalled + +### Configuration + +By default, the integration looks for a config key named `log-levels.default` of type `LOG_LEVEL_V2`. The configuration is evaluated with the following context: + +- `reforge-sdk-logging.lang`: `"java"` +- `reforge-sdk-logging.logger-path`: The name of the logger (e.g., `"com.example.MyClass"`) + +You can customize the config key: + +```java +Options options = new Options().setLoggerKey("my.custom.log.config"); +Sdk sdk = new Sdk(options); +ReforgeLog4j2Filter.install(sdk.loggerClient()); +``` + +### Example Reforge Configuration + +In your Reforge dashboard, create a `LOG_LEVEL_V2` config with key `log-levels.default`: + +```yaml +# Default to INFO for all loggers +default: INFO + +# Set specific packages to DEBUG +rules: + - criteria: + logger-path: + starts-with: "com.example.services" + value: DEBUG + + # Only log errors in noisy third-party library + - criteria: + logger-path: + starts-with: "com.thirdparty.noisy" + value: ERROR +``` + +## How It Works + +Once installed, the filter intercepts **all** logging calls across your entire application: + +- Works with **all loggers** (no need to configure individual loggers) +- Works with **all appenders** (console, file, rolling, syslog, async, etc.) +- Filters happen **before** log messages are formatted (performance benefit) +- No modification of your existing Log4j2 configuration needed + +## Performance Considerations + +- **Efficient filtering**: Log level checks happen before expensive message construction +- **Recursion protection**: Built-in guard prevents performance issues from recursive logging +- **Caching**: The Reforge SDK caches configuration data to minimize network calls + +## Thread Safety + +The integration is fully thread-safe and uses Log4j2's built-in filter infrastructure, which is designed for concurrent access. + +## FAQ + +### Do I need to configure individual loggers? + +**No!** The filter works at the LoggerContext level and automatically intercepts all logging calls across your entire application without needing to configure individual loggers. + +### Do I need to modify my existing log4j2.xml? + +**No!** The filter works alongside your existing Log4j2 configuration. You don't need to change your appenders, layouts, or logger settings. + +### Does this work with all Log4j2 appenders? + +**Yes!** The filter intercepts logging decisions before they reach any appenders. It works with: +- Console appenders +- File appenders +- Rolling file appenders +- Syslog appenders +- Async appenders +- JDBC appenders +- Custom appenders + +### What's the performance impact? + +Minimal! The filter: +- Runs before log message formatting (avoiding expensive string operations for filtered logs) +- Uses a ThreadLocal recursion guard to prevent infinite loops +- Only makes one SDK call per log statement +- The SDK caches configuration data to minimize network overhead + +### Can I use this in Spring Boot applications? + +**Yes!** Just install the filter in your main application class or a `@PostConstruct` method: + +```java +@SpringBootApplication +public class MyApplication { + @Autowired + private Sdk reforgeSDK; + + @PostConstruct + public void setupLogging() { + ReforgeLog4j2Filter.install(reforgeSDK.loggerClient()); + } + + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} +``` + +### What happens if Log4j2 is reconfigured? + +If Log4j2 is dynamically reconfigured (via JMX, programmatic reconfiguration, or automatic file watching), the filter will be removed. You'll need to reinstall it after reconfiguration. Consider adding a reconfiguration listener if your application uses dynamic reconfiguration. + +## Troubleshooting + +### Filter not installing + +If you see errors during filter installation, ensure that: + +1. Log4j2 is actually on your classpath +2. You're using Log4j2 (not Log4j 1.x or another logging implementation) +3. The filter installation happens after Log4j2 initialization + +To verify Log4j2 is being used: +```java +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; + +try { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + System.out.println("Using Log4j2: " + context.getClass().getName()); +} catch (ClassCastException e) { + System.out.println("NOT using Log4j2 Core"); +} +``` + +### Log levels not changing + +If log levels aren't being respected: + +1. Verify the config key exists in Reforge (default: `log-levels.default`) +2. Check that it's of type `LOG_LEVEL_V2` +3. Ensure the Reforge SDK is initialized and ready +4. Check the SDK logs for any errors during config retrieval +5. Verify the filter is still installed (it may have been removed by reconfiguration) + +### Existing Log4j2 configuration overriding dynamic levels + +The filter runs as part of Log4j2's filter chain. However, if you have: +- Appender-level filters with thresholds +- Very restrictive context-wide filters +- Logger-level settings that are more restrictive + +These may still apply. The filter controls whether a log event is created at all, but downstream filters and appender settings can still block events. + +### Filter removed after reconfiguration + +If your application uses Log4j2's configuration file watching or programmatic reconfiguration, the filter will be removed. Options: + +1. **Disable automatic reconfiguration** if not needed +2. **Add a reconfiguration listener** that reinstalls the filter +3. **Install via configuration** (though this requires static SDK access) + +## Example Application + +```java +package com.example; + +import com.reforge.sdk.Sdk; +import com.reforge.sdk.Options; +import com.reforge.sdk.log4j2.ReforgeLog4j2Filter; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class MyApplication { + private static final Logger log = LogManager.getLogger(MyApplication.class); + + public static void main(String[] args) { + // Initialize Reforge SDK + Sdk sdk = new Sdk(new Options()); + + // Install Log4j2 integration + ReforgeLog4j2Filter.install(sdk.loggerClient()); + + log.trace("This is a trace message"); // Filtered if level > TRACE + log.debug("This is a debug message"); // Filtered if level > DEBUG + log.info("This is an info message"); // Filtered if level > INFO + log.warn("This is a warning"); // Filtered if level > WARN + log.error("This is an error"); // Filtered if level > ERROR + log.fatal("This is fatal"); // Filtered if level > FATAL + } +} +``` + +## Differences from Logback Integration + +The Log4j2 integration differs from Logback in a few ways: + +| Feature | Log4j2 | Logback | +|---------|--------|---------| +| Filter type | AbstractFilter | TurboFilter | +| Filter scope | LoggerContext-wide | Framework-wide | +| Reconfiguration | Filter removed on reconfig | Filter persists | +| FATAL level | Native support | Maps to ERROR | + +Both integrations provide the same dynamic log level functionality with similar performance characteristics. diff --git a/log4j2/pom.xml b/log4j2/pom.xml new file mode 100644 index 0000000..43aca28 --- /dev/null +++ b/log4j2/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + com.reforge + sdk-parent + 1.1.0 + + + sdk-log4j2 + jar + Reforge SDK Log4j2 Integration + Log4j2 integration for Reforge SDK dynamic log level management + + + + + com.reforge + sdk + ${project.version} + + + + + org.apache.logging.log4j + log4j-api + provided + + + + org.apache.logging.log4j + log4j-core + provided + + + + + org.slf4j + slf4j-api + provided + + + + + org.assertj + assertj-core + test + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-core + test + + + diff --git a/log4j2/src/main/java/com/reforge/sdk/log4j2/Log4jLevelMapper.java b/log4j2/src/main/java/com/reforge/sdk/log4j2/Log4jLevelMapper.java new file mode 100644 index 0000000..1e47ed0 --- /dev/null +++ b/log4j2/src/main/java/com/reforge/sdk/log4j2/Log4jLevelMapper.java @@ -0,0 +1,26 @@ +package com.reforge.sdk.log4j2; + +import com.reforge.sdk.LogLevel; +import org.apache.logging.log4j.Level; + +class Log4jLevelMapper { + + static Level toLog4jLevel(LogLevel reforgeLevel) { + switch (reforgeLevel) { + case FATAL: + return Level.FATAL; + case ERROR: + return Level.ERROR; + case WARN: + return Level.WARN; + case INFO: + return Level.INFO; + case DEBUG: + return Level.DEBUG; + case TRACE: + return Level.TRACE; + default: + return Level.DEBUG; + } + } +} diff --git a/log4j2/src/main/java/com/reforge/sdk/log4j2/ReforgeLog4j2Filter.java b/log4j2/src/main/java/com/reforge/sdk/log4j2/ReforgeLog4j2Filter.java new file mode 100644 index 0000000..8b4da21 --- /dev/null +++ b/log4j2/src/main/java/com/reforge/sdk/log4j2/ReforgeLog4j2Filter.java @@ -0,0 +1,284 @@ +package com.reforge.sdk.log4j2; + +import com.reforge.sdk.LogLevel; +import com.reforge.sdk.LoggerClient; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.filter.AbstractFilter; +import org.apache.logging.log4j.message.Message; + +/** + * Log4j2 filter that retrieves log levels from Reforge configuration. + * + *

This filter intercepts logging calls and dynamically determines whether they should + * be logged based on the log level configuration from Reforge, allowing centralized + * and real-time control over application logging levels. + * + *

To install this filter, call: + *

{@code
+ * Sdk sdk = new Sdk(new Options());
+ * ReforgeLog4j2Filter.install(sdk.loggerClient());
+ * }
+ * + *

Important: Install the filter after Log4j2 is initialized. Any dynamic + * reconfiguration of Log4j2 will remove this filter and it will need to be reinstalled. + */ +public class ReforgeLog4j2Filter extends AbstractFilter { + + private final ThreadLocal recursionCheck = ThreadLocal.withInitial(() -> false + ); + private final LoggerClient loggerClient; + + /** + * Installs ReforgeLog4j2Filter at the LoggerContext level. + * Call only after Log4j2 is initialized. + * + *

Note: Any dynamic reconfiguration of Log4j2 will remove this filter. + * + * @param loggerClient the LoggerClient to use for retrieving log levels + * @throws IllegalStateException if Log4j2 Core is not being used + */ + public static void install(LoggerClient loggerClient) { + org.apache.logging.log4j.spi.LoggerContext ctx = LogManager.getContext(false); + + if (!(ctx instanceof LoggerContext)) { + throw new IllegalStateException( + "Cannot install ReforgeLog4j2Filter - LoggerContext is not Log4j2 Core. " + + "Found: " + + ctx.getClass().getName() + + ". " + + "Make sure log4j-core is on your classpath and properly configured." + ); + } + + LoggerContext loggerContext = (LoggerContext) ctx; + loggerContext.addFilter(new ReforgeLog4j2Filter(loggerClient)); + loggerContext.updateLoggers(); + } + + public ReforgeLog4j2Filter(final LoggerClient loggerClient) { + this.loggerClient = loggerClient; + } + + Result decide(final String loggerName, final Level level) { + if (recursionCheck.get()) { + return Result.NEUTRAL; + } + + try { + recursionCheck.set(true); + + LogLevel reforgeLogLevel = loggerClient.getLogLevel(loggerName); + Level calculatedMinLogLevelToAccept = Log4jLevelMapper.toLog4jLevel( + reforgeLogLevel + ); + + if (level.isMoreSpecificThan(calculatedMinLogLevelToAccept)) { + return Result.ACCEPT; + } + return Result.DENY; + } catch (Exception e) { + // If there's any error, fall back to neutral to avoid breaking logging + return Result.NEUTRAL; + } finally { + recursionCheck.set(false); + } + } + + @Override + public Result filter(final LogEvent event) { + return decide(event.getLoggerName(), event.getLevel()); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final Message msg, + final Throwable t + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final Object msg, + final Throwable t + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object... params + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4, + final Object p5 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4, + final Object p5, + final Object p6 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4, + final Object p5, + final Object p6, + final Object p7 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4, + final Object p5, + final Object p6, + final Object p7, + final Object p8 + ) { + return decide(logger.getName(), level); + } + + @Override + public Result filter( + final Logger logger, + final Level level, + final Marker marker, + final String msg, + final Object p0, + final Object p1, + final Object p2, + final Object p3, + final Object p4, + final Object p5, + final Object p6, + final Object p7, + final Object p8, + final Object p9 + ) { + return decide(logger.getName(), level); + } +} diff --git a/logback/README.md b/logback/README.md new file mode 100644 index 0000000..5e64dc1 --- /dev/null +++ b/logback/README.md @@ -0,0 +1,275 @@ +# Reforge SDK Logback Integration + +This module provides seamless integration between the Reforge SDK and Logback, enabling dynamic log level management for your application. + +## Overview + +The Logback integration uses a Logback TurboFilter to intercept logging calls and dynamically determine whether they should be logged based on log level configuration from Reforge. This allows you to: + +- **Centrally manage log levels** - Control logging across your entire application from the Reforge dashboard +- **Real-time updates** - Change log levels without restarting your application +- **Context-aware logging** - Different log levels for different loggers based on runtime context +- **Performance** - Efficient filtering happens before log message construction + +## Installation + +Add the dependency to your `pom.xml`: + +```xml + + com.reforge + sdk-logback + 1.0.3 + +``` + +### Compatibility + +This module is compatible with: +- **Logback 1.2.x** (with SLF4J 1.7.x) +- **Logback 1.3.x** (with SLF4J 2.0.x) +- **Logback 1.4.x** (with SLF4J 2.0.x) +- **Logback 1.5.x** (with SLF4J 2.0.x) + +The module uses only stable Logback APIs that haven't changed across these versions. + +**Note:** Logback and SLF4J are marked as `provided` dependencies - your application's versions will be used automatically. Make sure your Logback and SLF4J versions are compatible with each other (Logback 1.3+ requires SLF4J 2.0+, Logback 1.2.x requires SLF4J 1.7.x). + +## Usage + +### Basic Setup (Programmatic) + +The simplest approach is to install the filter programmatically during application startup: + +```java +import com.reforge.sdk.Sdk; +import com.reforge.sdk.Options; +import com.reforge.sdk.logback.ReforgeLogbackTurboFilter; + +public class MyApplication { + public static void main(String[] args) { + // Initialize the Reforge SDK + Sdk sdk = new Sdk(new Options()); + + // Install the Logback turbo filter + ReforgeLogbackTurboFilter.install(sdk.loggerClient()); + + // Now all your logging will respect Reforge log levels + Logger log = LoggerFactory.getLogger(MyApplication.class); + log.info("Application started with dynamic log levels"); + } +} +``` + +**Important:** Install the filter as early as possible in your application startup, ideally right after initializing the Reforge SDK. + +### Alternative: XML Configuration + +You can also configure the filter in your `logback.xml`, though this requires you to make the SDK instance available statically: + +```xml + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + +``` + +**Note:** The programmatic approach is recommended because it's simpler and doesn't require static SDK access. + +### How It Works + +Once installed, the TurboFilter intercepts **all** logging calls across your entire application: + +- Works with **all loggers** (no need to configure individual loggers) +- Works with **all appenders** (console, file, syslog, etc.) +- Filters happen **before** log messages are formatted (performance benefit) +- No modification of your existing Logback configuration needed + +### Configuration + +By default, the integration looks for a config key named `log-levels.default` of type `LOG_LEVEL_V2`. The configuration is evaluated with the following context: + +- `reforge-sdk-logging.lang`: `"java"` +- `reforge-sdk-logging.logger-path`: The name of the logger (e.g., `"com.example.MyClass"`) + +You can customize the config key: + +```java +Options options = new Options().setLoggerKey("my.custom.log.config"); +Sdk sdk = new Sdk(options); +ReforgeLogbackTurboFilter.install(sdk.loggerClient()); +``` + +### Example Reforge Configuration + +In your Reforge dashboard, create a `LOG_LEVEL_V2` config with key `log-levels.default`: + +```yaml +# Default to INFO for all loggers +default: INFO + +# Set specific packages to DEBUG +rules: + - criteria: + logger-path: + starts-with: "com.example.services" + value: DEBUG + + # Only log errors in noisy third-party library + - criteria: + logger-path: + starts-with: "com.thirdparty.noisy" + value: ERROR +``` + +## How It Works + +1. When a log statement is called, Logback's TurboFilter mechanism intercepts it before the log message is constructed +2. The filter calls `loggerClient.getLogLevel(loggerName)` to retrieve the configured log level from Reforge +3. The retrieved level is compared against the log statement's level +4. If the statement's level is high enough, it's allowed through; otherwise, it's filtered out +5. A ThreadLocal recursion guard prevents infinite loops if logging occurs during config lookup + +## Performance Considerations + +- **Efficient filtering**: Log level checks happen before expensive message construction +- **Recursion protection**: Built-in guard prevents performance issues from recursive logging +- **Caching**: The Reforge SDK caches configuration data to minimize network calls + +## Thread Safety + +The integration is fully thread-safe and uses Logback's built-in TurboFilter infrastructure, which is designed for concurrent access. + +## FAQ + +### Do I need to configure individual loggers? + +**No!** Unlike some logging frameworks, Logback's TurboFilter mechanism works globally. Once installed, the filter automatically intercepts all logging calls across your entire application without needing to touch individual logger configurations. + +### Do I need to modify my existing logback.xml? + +**No!** The TurboFilter works alongside your existing Logback configuration. You don't need to change your appenders, encoders, or root logger settings. + +### How does this differ from Log4j integration? + +The Logback integration is simpler because: +- **Logback**: Uses TurboFilters which work at the framework level +- **Log4j**: Requires traversing and updating individual Logger objects + +With Logback, there's no need to maintain or update logger hierarchies. + +### Does this work with all Logback appenders? + +**Yes!** The TurboFilter intercepts logging decisions before they reach any appenders. It works with: +- Console appenders +- File appenders +- Rolling file appenders +- Syslog appenders +- Async appenders +- Custom appenders + +### What's the performance impact? + +Minimal! The TurboFilter: +- Runs before log message formatting (avoiding expensive string operations for filtered logs) +- Uses a ThreadLocal recursion guard to prevent infinite loops +- Only makes one SDK call per log statement +- The SDK caches configuration data to minimize network overhead + +### Can I use this in Spring Boot applications? + +**Yes!** Just install the filter in your main application class or a `@PostConstruct` method: + +```java +@SpringBootApplication +public class MyApplication { + @Autowired + private Sdk reforgeSDK; + + @PostConstruct + public void setupLogging() { + ReforgeLogbackTurboFilter.install(reforgeSDK.loggerClient()); + } + + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} +``` + +## Troubleshooting + +### Filter not installing + +If you see an error message like "Unable to install ReforgeLogbackTurboFilter", ensure that: + +1. Logback is actually on your classpath +2. SLF4J is bound to Logback (not another logging implementation like Log4j or java.util.logging) +3. The filter installation happens after Logback initialization + +To verify Logback is being used: +```java +import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.LoggerContext; + +ILoggerFactory factory = LoggerFactory.getILoggerFactory(); +if (factory instanceof LoggerContext) { + System.out.println("Using Logback!"); +} else { + System.out.println("NOT using Logback: " + factory.getClass().getName()); +} +``` + +### Log levels not changing + +If log levels aren't being respected: + +1. Verify the config key exists in Reforge (default: `log-levels.default`) +2. Check that it's of type `LOG_LEVEL_V2` +3. Ensure the Reforge SDK is initialized and ready +4. Check the SDK logs for any errors during config retrieval + +### Existing Logback configuration overriding dynamic levels + +The TurboFilter runs before Logback's standard level checks. However, if you have very restrictive appender-level filters or threshold settings in your logback.xml, those may still apply. The TurboFilter controls whether a log event is created, but appenders can still apply their own filtering. + +## Example Application + +```java +package com.example; + +import com.reforge.sdk.Sdk; +import com.reforge.sdk.Options; +import com.reforge.sdk.logback.ReforgeLogbackTurboFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MyApplication { + private static final Logger LOG = LoggerFactory.getLogger(MyApplication.class); + + public static void main(String[] args) { + // Initialize Reforge SDK + Sdk sdk = new Sdk(new Options()); + + // Install Logback integration + ReforgeLogbackTurboFilter.install(sdk.loggerClient()); + + LOG.trace("This is a trace message"); // Filtered if level > TRACE + LOG.debug("This is a debug message"); // Filtered if level > DEBUG + LOG.info("This is an info message"); // Filtered if level > INFO + LOG.warn("This is a warning"); // Filtered if level > WARN + LOG.error("This is an error"); // Filtered if level > ERROR + } +} +``` diff --git a/logback/pom.xml b/logback/pom.xml new file mode 100644 index 0000000..7705858 --- /dev/null +++ b/logback/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + com.reforge + sdk-parent + 1.1.0 + + + sdk-logback + jar + Reforge SDK Logback Integration + Logback integration for Reforge SDK dynamic log level management + + + + + com.reforge + sdk + ${project.version} + + + + + ch.qos.logback + logback-classic + provided + + + + + org.slf4j + slf4j-api + provided + + + + + org.assertj + assertj-core + test + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-core + test + + + diff --git a/logback/src/main/java/com/reforge/sdk/logback/BaseTurboFilter.java b/logback/src/main/java/com/reforge/sdk/logback/BaseTurboFilter.java new file mode 100644 index 0000000..783ce5c --- /dev/null +++ b/logback/src/main/java/com/reforge/sdk/logback/BaseTurboFilter.java @@ -0,0 +1,54 @@ +package com.reforge.sdk.logback; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; +import com.reforge.sdk.LogLevel; +import com.reforge.sdk.LoggerClient; +import org.slf4j.Marker; + +public abstract class BaseTurboFilter extends TurboFilter { + + protected final LoggerClient loggerClient; + private final ThreadLocal recursionCheck = ThreadLocal.withInitial(() -> false + ); + + BaseTurboFilter(LoggerClient loggerClient) { + this.loggerClient = loggerClient; + } + + abstract LogLevel getLogLevel(Logger logger, Level level); + + @Override + public FilterReply decide( + Marker marker, + Logger logger, + Level level, + String s, + Object[] objects, + Throwable throwable + ) { + if (recursionCheck.get()) { + return FilterReply.NEUTRAL; + } + + try { + recursionCheck.set(true); + LogLevel reforgeLogLevel = getLogLevel(logger, level); + Level calculatedMinLogLevelToAccept = LogbackLevelMapper.toLogbackLevel( + reforgeLogLevel + ); + + if (level.isGreaterOrEqual(calculatedMinLogLevelToAccept)) { + return FilterReply.ACCEPT; + } + return FilterReply.DENY; + } catch (Exception e) { + // If there's any error, fall back to neutral to avoid breaking logging + return FilterReply.NEUTRAL; + } finally { + recursionCheck.set(false); + } + } +} diff --git a/logback/src/main/java/com/reforge/sdk/logback/LogbackLevelMapper.java b/logback/src/main/java/com/reforge/sdk/logback/LogbackLevelMapper.java new file mode 100644 index 0000000..0b4575c --- /dev/null +++ b/logback/src/main/java/com/reforge/sdk/logback/LogbackLevelMapper.java @@ -0,0 +1,26 @@ +package com.reforge.sdk.logback; + +import ch.qos.logback.classic.Level; +import com.reforge.sdk.LogLevel; + +class LogbackLevelMapper { + + static Level toLogbackLevel(LogLevel reforgeLevel) { + switch (reforgeLevel) { + case FATAL: + return Level.ERROR; // Logback doesn't have FATAL, map to ERROR + case ERROR: + return Level.ERROR; + case WARN: + return Level.WARN; + case INFO: + return Level.INFO; + case DEBUG: + return Level.DEBUG; + case TRACE: + return Level.TRACE; + default: + return Level.DEBUG; + } + } +} diff --git a/logback/src/main/java/com/reforge/sdk/logback/LogbackUtils.java b/logback/src/main/java/com/reforge/sdk/logback/LogbackUtils.java new file mode 100644 index 0000000..c394994 --- /dev/null +++ b/logback/src/main/java/com/reforge/sdk/logback/LogbackUtils.java @@ -0,0 +1,28 @@ +package com.reforge.sdk.logback; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.turbo.TurboFilter; +import org.slf4j.ILoggerFactory; +import org.slf4j.LoggerFactory; + +public class LogbackUtils { + + static void installTurboFilter(TurboFilter turboFilter) { + ILoggerFactory iLoggerFactory = LoggerFactory.getILoggerFactory(); + + if (!(iLoggerFactory instanceof LoggerContext)) { + throw new IllegalStateException( + "Cannot install " + + turboFilter.getClass().getSimpleName() + + " - LoggerFactory is not a Logback LoggerContext. " + + "Found: " + + iLoggerFactory.getClass().getName() + + ". " + + "Make sure Logback is on your classpath and SLF4J is bound to Logback." + ); + } + + LoggerContext loggerContext = (LoggerContext) iLoggerFactory; + loggerContext.addTurboFilter(turboFilter); + } +} diff --git a/logback/src/main/java/com/reforge/sdk/logback/ReforgeLogbackTurboFilter.java b/logback/src/main/java/com/reforge/sdk/logback/ReforgeLogbackTurboFilter.java new file mode 100644 index 0000000..1552f50 --- /dev/null +++ b/logback/src/main/java/com/reforge/sdk/logback/ReforgeLogbackTurboFilter.java @@ -0,0 +1,41 @@ +package com.reforge.sdk.logback; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import com.reforge.sdk.LogLevel; +import com.reforge.sdk.LoggerClient; + +/** + * Logback TurboFilter that retrieves log levels from Reforge configuration. + * + * This filter intercepts logging calls and dynamically determines whether they should + * be logged based on the log level configuration from Reforge, allowing centralized + * and real-time control over application logging levels. + * + *

To install this filter, call: + *

{@code
+ * Sdk sdk = new Sdk(new Options());
+ * ReforgeLogbackTurboFilter.install(sdk.loggerClient());
+ * }
+ */ +public class ReforgeLogbackTurboFilter extends BaseTurboFilter { + + ReforgeLogbackTurboFilter(LoggerClient loggerClient) { + super(loggerClient); + } + + /** + * Installs the Reforge turbo filter into the Logback logging system. + * + * @param loggerClient the LoggerClient to use for retrieving log levels + * @throws IllegalStateException if Logback is not being used as the SLF4J implementation + */ + public static void install(LoggerClient loggerClient) { + LogbackUtils.installTurboFilter(new ReforgeLogbackTurboFilter(loggerClient)); + } + + @Override + LogLevel getLogLevel(Logger logger, Level level) { + return loggerClient.getLogLevel(logger.getName()); + } +} diff --git a/micronaut/pom.xml b/micronaut/pom.xml index 1855df7..e7732bd 100644 --- a/micronaut/pom.xml +++ b/micronaut/pom.xml @@ -4,7 +4,7 @@ com.reforge sdk-parent - 1.0.3 + 1.1.0 sdk-micronaut-extension diff --git a/pom.xml b/pom.xml index 9f1b014..c329ad6 100644 --- a/pom.xml +++ b/pom.xml @@ -11,12 +11,14 @@ com.reforge sdk-parent - 1.0.3 + 1.1.0 pom Reforge SDK Parent POM Parent POM for Reforge SDK modules providing feature flags, configuration management, and A/B testing capabilities + log4j2 + logback micronaut sdk @@ -39,6 +41,16 @@ + + ch.qos.logback + logback-classic + 1.4.12 + + + ch.qos.logback + logback-core + 1.4.12 + com.fasterxml.jackson.core jackson-annotations @@ -89,6 +101,16 @@ javax.annotation-api 1.3.2 + + org.apache.logging.log4j + log4j-api + 2.19.0 + + + org.apache.logging.log4j + log4j-core + 2.19.0 + org.assertj assertj-core diff --git a/sdk/pom.xml b/sdk/pom.xml index d3889c4..9d73c2f 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -4,7 +4,7 @@ com.reforge sdk-parent - 1.0.3 + 1.1.0 sdk @@ -60,6 +60,12 @@ [1.4.12,) test + + ch.qos.logback + logback-core + [1.4.12,) + test + com.fasterxml.jackson.core jackson-annotations diff --git a/sdk/src/main/java/com/reforge/sdk/LogLevel.java b/sdk/src/main/java/com/reforge/sdk/LogLevel.java new file mode 100644 index 0000000..4335ae7 --- /dev/null +++ b/sdk/src/main/java/com/reforge/sdk/LogLevel.java @@ -0,0 +1,55 @@ +package com.reforge.sdk; + +import cloud.prefab.domain.Prefab; + +/** + * Log level enum that wraps the underlying Prefab.LogLevel protobuf enum. + * Used to configure logging levels for different loggers in your application. + */ +public enum LogLevel { + TRACE(Prefab.LogLevel.TRACE), + DEBUG(Prefab.LogLevel.DEBUG), + INFO(Prefab.LogLevel.INFO), + WARN(Prefab.LogLevel.WARN), + ERROR(Prefab.LogLevel.ERROR), + FATAL(Prefab.LogLevel.FATAL); + + private final Prefab.LogLevel protobufLogLevel; + + LogLevel(Prefab.LogLevel protobufLogLevel) { + this.protobufLogLevel = protobufLogLevel; + } + + /** + * @return the underlying protobuf LogLevel + */ + public Prefab.LogLevel toProtobuf() { + return protobufLogLevel; + } + + /** + * Converts a protobuf LogLevel to a Java LogLevel enum + * @param protobufLogLevel the protobuf log level + * @return the corresponding Java LogLevel, or DEBUG if the protobuf value is NOT_SET_LOG_LEVEL or unrecognized + */ + public static LogLevel fromProtobuf(Prefab.LogLevel protobufLogLevel) { + switch (protobufLogLevel) { + case TRACE: + return TRACE; + case DEBUG: + return DEBUG; + case INFO: + return INFO; + case WARN: + return WARN; + case ERROR: + return ERROR; + case FATAL: + return FATAL; + case NOT_SET_LOG_LEVEL: + case UNRECOGNIZED: + default: + return DEBUG; + } + } +} diff --git a/sdk/src/main/java/com/reforge/sdk/LoggerClient.java b/sdk/src/main/java/com/reforge/sdk/LoggerClient.java new file mode 100644 index 0000000..1b87909 --- /dev/null +++ b/sdk/src/main/java/com/reforge/sdk/LoggerClient.java @@ -0,0 +1,21 @@ +package com.reforge.sdk; + +/** + * Client for retrieving dynamically configured log levels from Reforge. + * Log levels can be configured per logger path with context-aware targeting. + */ +public interface LoggerClient { + /** + * Get the configured log level for a specific logger. + * + * This evaluates the configured logger key (from Options) with a context containing: + * - "reforge-sdk-logging.lang": "java" + * - "reforge-sdk-logging.logger-path": the provided loggerName + * + * The config must be of type LOG_LEVEL_V2. + * + * @param loggerName the name or path of the logger (e.g., "com.example.MyClass" or "com.example") + * @return the configured LogLevel for this logger, or DEBUG if no configuration is found + */ + LogLevel getLogLevel(String loggerName); +} diff --git a/sdk/src/main/java/com/reforge/sdk/Options.java b/sdk/src/main/java/com/reforge/sdk/Options.java index 0d32e07..6fcf917 100644 --- a/sdk/src/main/java/com/reforge/sdk/Options.java +++ b/sdk/src/main/java/com/reforge/sdk/Options.java @@ -71,6 +71,9 @@ public enum CollectContextMode { @Nullable private ContextSetReadable globalContext; + @Nullable + private String loggerKey = "log-levels.default"; + public Options() { setSdkKey( Optional @@ -287,6 +290,22 @@ public Options setGlobalContext(@Nullable ContextSetReadable globalContext) { return this; } + public Optional getLoggerKey() { + return Optional.ofNullable(loggerKey); + } + + /** + * Sets the config key to use for log level configuration. + * This key should point to a LOG_LEVEL_V2 config that will be evaluated + * with context containing logger path information. + * @param loggerKey the config key for log level configuration + * @return Options + */ + public Options setLoggerKey(@Nullable String loggerKey) { + this.loggerKey = loggerKey; + return this; + } + private String prefixAndValidate(String uri) { String prefixed = httpsPrefix(uri); try { diff --git a/sdk/src/main/java/com/reforge/sdk/Sdk.java b/sdk/src/main/java/com/reforge/sdk/Sdk.java index 2327a00..70c450d 100644 --- a/sdk/src/main/java/com/reforge/sdk/Sdk.java +++ b/sdk/src/main/java/com/reforge/sdk/Sdk.java @@ -2,6 +2,7 @@ import com.reforge.sdk.internal.ConfigClientImpl; import com.reforge.sdk.internal.FeatureFlagClientImpl; +import com.reforge.sdk.internal.LoggerClientImpl; import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +14,7 @@ public class Sdk implements AutoCloseable { private final Options options; private ConfigClientImpl configClient; private FeatureFlagClient featureFlagClient; + private LoggerClient loggerClient; private final AtomicBoolean closed; public Sdk(Options options) { @@ -58,6 +60,18 @@ public FeatureFlagClient featureFlagClient() { return featureFlagClient; } + public LoggerClient loggerClient() { + if (loggerClient == null) { + synchronized (this) { + if (loggerClient == null) { + loggerClient = + new LoggerClientImpl(configClientImpl(), options.getLoggerKey().orElse(null)); + } + } + } + return loggerClient; + } + public Options getOptions() { return options; } diff --git a/sdk/src/main/java/com/reforge/sdk/internal/LoggerClientImpl.java b/sdk/src/main/java/com/reforge/sdk/internal/LoggerClientImpl.java new file mode 100644 index 0000000..c22c365 --- /dev/null +++ b/sdk/src/main/java/com/reforge/sdk/internal/LoggerClientImpl.java @@ -0,0 +1,89 @@ +package com.reforge.sdk.internal; + +import cloud.prefab.domain.Prefab; +import com.reforge.sdk.ConfigClient; +import com.reforge.sdk.LogLevel; +import com.reforge.sdk.LoggerClient; +import com.reforge.sdk.context.Context; +import java.util.Optional; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoggerClientImpl implements LoggerClient { + + private static final Logger LOG = LoggerFactory.getLogger(LoggerClientImpl.class); + private static final String LOGGING_CONTEXT_NAME = "reforge-sdk-logging"; + private static final String LANG_KEY = "lang"; + private static final String LOGGER_PATH_KEY = "logger-path"; + private static final String JAVA_VALUE = "java"; + + private final ConfigClient configClient; + private final String loggerKey; + + public LoggerClientImpl(ConfigClient configClient, @Nullable String loggerKey) { + this.configClient = configClient; + this.loggerKey = loggerKey; + } + + @Override + public LogLevel getLogLevel(String loggerName) { + if (loggerKey == null || loggerKey.isEmpty()) { + LOG.debug("No logger key configured, returning default level DEBUG"); + return LogLevel.DEBUG; + } + + try { + Context loggingContext = Context + .newBuilder(LOGGING_CONTEXT_NAME) + .put(LANG_KEY, JAVA_VALUE) + .put(LOGGER_PATH_KEY, loggerName) + .build(); + + Optional configValueMaybe = configClient.get( + loggerKey, + loggingContext + ); + + if (!configValueMaybe.isPresent()) { + LOG.debug( + "No log level configuration found for key '{}' and logger '{}', returning default level DEBUG", + loggerKey, + loggerName + ); + return LogLevel.DEBUG; + } + + Prefab.ConfigValue configValue = configValueMaybe.get(); + + if (!configValue.hasLogLevel()) { + LOG.warn( + "Config value for key '{}' is not a log level (type: {}), returning default level DEBUG", + loggerKey, + configValue.getTypeCase() + ); + return LogLevel.DEBUG; + } + + Prefab.LogLevel protobufLogLevel = configValue.getLogLevel(); + LogLevel result = LogLevel.fromProtobuf(protobufLogLevel); + + LOG.debug( + "Retrieved log level {} for logger '{}' from key '{}'", + result, + loggerName, + loggerKey + ); + + return result; + } catch (Exception e) { + LOG.debug( + "Error retrieving log level for logger '{}' from key '{}', returning default level DEBUG", + loggerName, + loggerKey, + e + ); + return LogLevel.DEBUG; + } + } +} diff --git a/sdk/src/test/java/com/reforge/sdk/internal/LoggerClientImplTest.java b/sdk/src/test/java/com/reforge/sdk/internal/LoggerClientImplTest.java new file mode 100644 index 0000000..8fcddb4 --- /dev/null +++ b/sdk/src/test/java/com/reforge/sdk/internal/LoggerClientImplTest.java @@ -0,0 +1,246 @@ +package com.reforge.sdk.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cloud.prefab.domain.Prefab; +import com.reforge.sdk.ConfigClient; +import com.reforge.sdk.LogLevel; +import com.reforge.sdk.config.ConfigValueUtils; +import com.reforge.sdk.context.Context; +import com.reforge.sdk.context.ContextSetReadable; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoggerClientImplTest { + + @Mock + ConfigClient configClient; + + @Nested + class WithLoggerKey { + + @Test + void getLogLevelReturnsConfiguredLevel() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.INFO))); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.INFO); + } + + @Test + void getLogLevelReturnsDebugWhenNoConfigFound() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.empty()); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + + @Test + void getLogLevelReturnsDebugWhenConfigIsWrongType() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from("not a log level"))); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + + @Test + void getLogLevelPassesCorrectContext() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass( + ContextSetReadable.class + ); + when(configClient.get(eq("log-levels.default"), contextCaptor.capture())) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.WARN))); + + loggerClient.getLogLevel("com.example.MyClass"); + + ContextSetReadable capturedContext = contextCaptor.getValue(); + Optional loggingContext = capturedContext.getByName("reforge-sdk-logging"); + + assertThat(loggingContext).isPresent(); + assertThat(loggingContext.get().getProperties()) + .containsEntry("lang", ConfigValueUtils.from("java")) + .containsEntry("logger-path", ConfigValueUtils.from("com.example.MyClass")); + } + + @Test + void getLogLevelHandlesAllLogLevels() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + // Test TRACE + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.TRACE))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.TRACE); + + // Test DEBUG + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.DEBUG))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.DEBUG); + + // Test INFO + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.INFO))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.INFO); + + // Test WARN + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.WARN))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.WARN); + + // Test ERROR + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.ERROR))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.ERROR); + + // Test FATAL + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.FATAL))); + assertThat(loggerClient.getLogLevel("test")).isEqualTo(LogLevel.FATAL); + } + + @Test + void getLogLevelHandlesNotSetLogLevel() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenReturn( + Optional.of(ConfigValueUtils.from(Prefab.LogLevel.NOT_SET_LOG_LEVEL)) + ); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + + @Test + void getLogLevelHandlesCustomLoggerKey() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "custom.logger.config" + ); + + when(configClient.get(eq("custom.logger.config"), any(ContextSetReadable.class))) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.ERROR))); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.ERROR); + verify(configClient).get(eq("custom.logger.config"), any(ContextSetReadable.class)); + } + } + + @Nested + class WithoutLoggerKey { + + @Test + void getLogLevelReturnsDebugWhenLoggerKeyIsNull() { + LoggerClientImpl loggerClient = new LoggerClientImpl(configClient, null); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + + @Test + void getLogLevelReturnsDebugWhenLoggerKeyIsEmpty() { + LoggerClientImpl loggerClient = new LoggerClientImpl(configClient, ""); + + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + } + + @Nested + class EdgeCases { + + @Test + void getLogLevelHandlesExceptionFromConfigClient() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + when(configClient.get(eq("log-levels.default"), any(ContextSetReadable.class))) + .thenThrow(new RuntimeException("Config error")); + + // Should not throw, should return default + LogLevel result = loggerClient.getLogLevel("com.example.MyClass"); + + assertThat(result).isEqualTo(LogLevel.DEBUG); + } + + @Test + void getLogLevelHandlesDifferentLoggerPaths() { + LoggerClientImpl loggerClient = new LoggerClientImpl( + configClient, + "log-levels.default" + ); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass( + ContextSetReadable.class + ); + when(configClient.get(eq("log-levels.default"), contextCaptor.capture())) + .thenReturn(Optional.of(ConfigValueUtils.from(Prefab.LogLevel.INFO))); + + // Test package-level logger + loggerClient.getLogLevel("com.example"); + ContextSetReadable context1 = contextCaptor.getValue(); + assertThat(context1.getByName("reforge-sdk-logging").get().getProperties()) + .containsEntry("logger-path", ConfigValueUtils.from("com.example")); + + // Test class-level logger + loggerClient.getLogLevel("com.example.MyClass"); + ContextSetReadable context2 = contextCaptor.getValue(); + assertThat(context2.getByName("reforge-sdk-logging").get().getProperties()) + .containsEntry("logger-path", ConfigValueUtils.from("com.example.MyClass")); + + // Test root logger + loggerClient.getLogLevel("ROOT"); + ContextSetReadable context3 = contextCaptor.getValue(); + assertThat(context3.getByName("reforge-sdk-logging").get().getProperties()) + .containsEntry("logger-path", ConfigValueUtils.from("ROOT")); + } + } +}