diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index bc9c1e6b..1af1f6d2 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "log" -version = "2.16.1" +version = "2.17.0" authors = ["Ballerina"] keywords = ["level", "format"] repository = "https://github.com/ballerina-platform/module-ballerina-log" @@ -15,18 +15,18 @@ graalvmCompatible = true [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-native" -version = "2.16.1" -path = "../native/build/libs/log-native-2.16.1.jar" +version = "2.17.0" +path = "../native/build/libs/log-native-2.17.0-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-compiler-plugin" -version = "2.16.1" -path = "../compiler-plugin/build/libs/log-compiler-plugin-2.16.1.jar" +version = "2.17.0" +path = "../compiler-plugin/build/libs/log-compiler-plugin-2.17.0-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-test-utils" -version = "2.16.1" -path = "../test-utils/build/libs/log-test-utils-2.16.1.jar" +version = "2.17.0" +path = "../test-utils/build/libs/log-test-utils-2.17.0-SNAPSHOT.jar" scope = "testOnly" diff --git a/ballerina/CompilerPlugin.toml b/ballerina/CompilerPlugin.toml index 03ca43d1..8cd55774 100644 --- a/ballerina/CompilerPlugin.toml +++ b/ballerina/CompilerPlugin.toml @@ -3,4 +3,4 @@ id = "log-compiler-plugin" class = "io.ballerina.stdlib.log.compiler.LogCompilerPlugin" [[dependency]] -path = "../compiler-plugin/build/libs/log-compiler-plugin-2.16.1.jar" +path = "../compiler-plugin/build/libs/log-compiler-plugin-2.17.0-SNAPSHOT.jar" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index 15bb01ff..5f8ff2ec 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -88,7 +88,7 @@ modules = [ [[package]] org = "ballerina" name = "log" -version = "2.16.1" +version = "2.17.0" dependencies = [ {org = "ballerina", name = "io"}, {org = "ballerina", name = "jballerina.java"}, diff --git a/ballerina/README.md b/ballerina/README.md index 26c6936f..a815da84 100644 --- a/ballerina/README.md +++ b/ballerina/README.md @@ -151,6 +151,41 @@ The log module supports contextual logging, allowing you to create loggers with For more details and advanced usage, see the module specification and API documentation. +### Runtime Log Level Modification + +The log module supports modifying log levels at runtime without restarting the application. All loggers created via `fromConfig` are registered in a logger registry with a unique ID and can be discovered and updated at runtime. + +```ballerina +// Create a logger with an explicit ID +log:Logger paymentLogger = check log:fromConfig(id = "payment-service", level = log:INFO); + +// Change the level at runtime +check paymentLogger.setLevel(log:DEBUG); +log:Level current = paymentLogger.getLevel(); // DEBUG +``` + +The logger registry provides a way to discover and manage all registered loggers: + +```ballerina +log:LoggerRegistry registry = log:getLoggerRegistry(); + +// List all registered logger IDs +string[] ids = registry.getIds(); +// e.g., ["root", "myorg/payment:payment-service", "myorg/payment:init"] + +// Look up a logger by ID and update its level +log:Logger? logger = registry.getById("myorg/payment:payment-service"); +if logger is log:Logger { + check logger.setLevel(log:DEBUG); +} +``` + +The registry contains: +- `"root"` — the global root logger +- All loggers created via `fromConfig` (module-prefixed user IDs or auto-generated IDs) + +> **Note:** Per-module log levels configured via `[[ballerina.log.modules]]` in `Config.toml` are static — they apply at startup and cannot be changed at runtime through the registry. Child loggers (created via `withContext`) are also not registered and always inherit their level from the parent. + ### Sensitive Data Masking The log module provides capabilities to mask sensitive data in log messages to maintain data privacy and security when dealing with personally identifiable information (PII) or other sensitive data. diff --git a/ballerina/init.bal b/ballerina/init.bal index 14cf1e7b..77aed416 100644 --- a/ballerina/init.bal +++ b/ballerina/init.bal @@ -21,6 +21,18 @@ function init() returns error? { rootLogger = new RootLogger(); check validateDestinations(destinations); setModule(); + + // Register the global root logger in the registry + lock { + loggerRegistry["root"] = rootLogger; + } + + // Populate the Java-side ConcurrentHashMap with per-module level overrides so that + // RootLogger.print() can do a lock-free level lookup on the hot logging path. + // Module loggers are intentionally NOT registered in the Ballerina-side LoggerRegistry. + foreach Module mod in modules { + setModuleLevelNative(mod.name, mod.level); + } } isolated function validateDestinations(OutputDestination[] destinations) returns Error? { diff --git a/ballerina/logger.bal b/ballerina/logger.bal index 7f1441c6..b1df648a 100644 --- a/ballerina/logger.bal +++ b/ballerina/logger.bal @@ -53,4 +53,20 @@ public type Logger isolated object { # + keyValues - The key-value pairs to be added to the logger context # + return - A new Logger instance with the given key-values added to its context public isolated function withContext(*KeyValues keyValues) returns Logger|error; + + # Returns the effective log level of this logger. + # For root and custom loggers, returns the explicitly set level. + # For child loggers (created via `withContext`), returns the inherited level from the parent logger. + # + # + return - The effective log level + public isolated function getLevel() returns Level; + + # Sets the log level of this logger at runtime. + # This is supported on root loggers, module loggers, and loggers created via `fromConfig`. + # Child loggers (created via `withContext`) do not support this operation and will return an error. + # To change a child logger's effective level, set the level on its parent logger instead. + # + # + level - The new log level to set + # + return - An error if the operation is not supported, nil on success + public isolated function setLevel(Level level) returns error?; }; diff --git a/ballerina/natives.bal b/ballerina/natives.bal index 440c19b3..5b2895e1 100644 --- a/ballerina/natives.bal +++ b/ballerina/natives.bal @@ -468,14 +468,17 @@ isolated function replaceString(handle receiver, handle target, handle replaceme paramTypes: ["java.lang.CharSequence", "java.lang.CharSequence"] } external; -isolated function isLogLevelEnabled(string loggerLogLevel, string logLevel, string moduleName) returns boolean { - string moduleLogLevel = loggerLogLevel; - if modules.length() > 0 { - if modules.hasKey(moduleName) { - moduleLogLevel = modules.get(moduleName).level; - } - } - return logLevelWeight.get(logLevel) >= logLevelWeight.get(moduleLogLevel); +final readonly & map LOG_LEVEL_WEIGHT = { + "ERROR": 1000, + "WARN": 900, + "INFO": 800, + "DEBUG": 700 +}; + +isolated function isLevelEnabled(string effectiveLevel, string logLevel) returns boolean { + int requestedWeight = LOG_LEVEL_WEIGHT[logLevel] ?: 0; + int effectiveWeight = LOG_LEVEL_WEIGHT[effectiveLevel] ?: 800; + return requestedWeight >= effectiveWeight; } isolated function getModuleName(KeyValues keyValues, int offset = 2) returns string { @@ -492,3 +495,20 @@ isolated function getCurrentFileSize(string filePath) returns int = @java:Method isolated function getTimeSinceLastRotation(string filePath, string policy, int maxFileSize, int maxAgeInMillis, int maxBackupFiles) returns int = @java:Method {'class: "io.ballerina.stdlib.log.Utils"} external; isolated function rotateLog(string filePath, string policy, int maxFileSize, int maxAgeInMillis, int maxBackupFiles) returns error? = @java:Method {'class: "io.ballerina.stdlib.log.Utils"} external; + +// ========== Internal native function declarations for runtime log configuration ========== + +isolated function generateLoggerIdNative(int stackOffset) returns string = @java:Method { + 'class: "io.ballerina.stdlib.log.LogConfigManager", + name: "generateLoggerId" +} external; + +isolated function getModuleLevelNative(string moduleName) returns Level? = @java:Method { + 'class: "io.ballerina.stdlib.log.LogConfigManager", + name: "getModuleLevel" +} external; + +isolated function setModuleLevelNative(string moduleName, Level level) = @java:Method { + 'class: "io.ballerina.stdlib.log.LogConfigManager", + name: "setModuleLevel" +} external; diff --git a/ballerina/root_logger.bal b/ballerina/root_logger.bal index 9324ee12..1d5c14a5 100644 --- a/ballerina/root_logger.bal +++ b/ballerina/root_logger.bal @@ -19,6 +19,11 @@ import ballerina/observe; # Configuration for the Ballerina logger public type Config record {| + # Optional unique identifier for this logger. + # All loggers are registered and can be discovered via `getLoggerRegistry()`. + # If provided, this value is used as the logger's runtime ID (prefixed with the module name); + # otherwise, an ID is auto-generated from the calling context. + string id?; # Log format to use. Default is the logger format configured in the module level LogFormat format = format; # Log level to use. Default is the logger level configured in the module level @@ -43,6 +48,47 @@ final string ICP_RUNTIME_ID_KEY = "icp.runtimeId"; final RootLogger rootLogger; +// Ballerina-side logger registry: loggerId -> Logger +isolated map loggerRegistry = {}; + + +# Provides access to the logger registry for discovering and managing registered loggers. +public type LoggerRegistry isolated object { + # Returns the IDs of all registered loggers. + # + # + return - An array of logger IDs + public isolated function getIds() returns string[]; + + # Returns a logger by its registered ID. + # + # + id - The logger ID to look up + # + return - The Logger instance if found, nil otherwise + public isolated function getById(string id) returns Logger?; +}; + +isolated class LoggerRegistryImpl { + *LoggerRegistry; + + public isolated function getIds() returns string[] { + lock { + return loggerRegistry.keys().clone(); + } + } + + public isolated function getById(string id) returns Logger? { + lock { + return loggerRegistry[id]; + } + } +} + +final LoggerRegistry loggerRegistryInstance = new LoggerRegistryImpl(); + +# Returns the logger registry for discovering and managing registered loggers. +# +# + return - The LoggerRegistry instance +public isolated function getLoggerRegistry() returns LoggerRegistry => loggerRegistryInstance; + # Returns the root logger instance. # # + return - The root logger instance @@ -60,31 +106,64 @@ public isolated function fromConfig(*Config config) returns Logger|Error { foreach [string, Value] [k, v] in config.keyValues.entries() { newKeyValues[k] = v; } - Config newConfig = { + + readonly & ConfigInternal newConfig = { format: config.format, level: config.level, destinations: config.destinations, keyValues: newKeyValues.cloneReadOnly(), enableSensitiveDataMasking: config.enableSensitiveDataMasking }; - return new RootLogger(newConfig); + + if config.id is string { + // Explicit user ID — module-prefix it: :. + // getInvokedModuleName(3): skip getInvokedModuleName -> fromConfig -> caller + string moduleName = getInvokedModuleName(3); + string loggerId = moduleName.length() > 0 ? moduleName + ":" + config.id : config.id; + RootLogger logger = new RootLogger(newConfig, loggerId); + lock { + if loggerRegistry.hasKey(loggerId) { + return error Error("Logger with ID '" + loggerId + "' already exists"); + } + loggerRegistry[loggerId] = logger; + } + return logger; + } + + // No user ID — auto-generate a readable ID and guarantee uniqueness inside the lock. + // Stack offset 4: skip generateLoggerId (Java) -> generateLoggerIdNative -> fromConfig -> caller + string baseId = generateLoggerIdNative(4); + lock { + string loggerId = baseId; + int suffix = 2; + while loggerRegistry.hasKey(loggerId) { + loggerId = baseId + "-" + suffix.toString(); + suffix += 1; + } + RootLogger logger = new RootLogger(newConfig, loggerId); + loggerRegistry[loggerId] = logger; + return logger; + } } isolated class RootLogger { *Logger; private final LogFormat format; - private final Level level; + private Level currentLevel; private final readonly & OutputDestination[] destinations; private final readonly & KeyValues keyValues; private final boolean enableSensitiveDataMasking; + // Unique ID for loggers registered with LogConfigManager + private final string? loggerId; - public isolated function init(Config|ConfigInternal config = {}) { + public isolated function init(Config|ConfigInternal config = {}, string? loggerId = ()) { self.format = config.format; - self.level = config.level; + self.currentLevel = config.level; self.destinations = config.destinations; self.keyValues = config.keyValues; self.enableSensitiveDataMasking = config.enableSensitiveDataMasking; + self.loggerId = loggerId; } public isolated function printDebug(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { @@ -112,86 +191,184 @@ isolated class RootLogger { foreach [string, Value] [k, v] in keyValues.entries() { newKeyValues[k] = v; } - ConfigInternal config = { - format: self.format, - level: self.level, - destinations: self.destinations, - keyValues: newKeyValues.cloneReadOnly(), - enableSensitiveDataMasking: self.enableSensitiveDataMasking - }; - return new RootLogger(config); + return new ChildLogger(self, newKeyValues.cloneReadOnly()); + } + + public isolated function getLevel() returns Level { + lock { + return self.currentLevel; + } + } + + public isolated function setLevel(Level level) returns error? { + lock { + self.currentLevel = level; + } } isolated function print(string logLevel, string moduleName, string|PrintableRawTemplate msg, error? err = (), error:StackFrame[]? stackTrace = (), *KeyValues keyValues) { - if !isLogLevelEnabled(self.level, logLevel, moduleName) { + Level effectiveLevel = self.getLevel(); + if moduleName.length() > 0 { + Level? moduleLevel = getModuleLevelNative(moduleName); + if moduleLevel is Level { + effectiveLevel = moduleLevel; + } + } + if !isLevelEnabled(effectiveLevel, logLevel) { return; } - LogRecord logRecord = { - time: getCurrentTime(), - level: logLevel, - module: moduleName, - message: processMessage(msg, self.enableSensitiveDataMasking) - }; - if err is error { - logRecord.'error = getFullErrorDetails(err); + printLog(logLevel, moduleName, msg, self.format, self.destinations, self.keyValues, + self.enableSensitiveDataMasking, err, stackTrace, keyValues); + } +} + +isolated class ChildLogger { + *Logger; + + private final Logger parent; + private final readonly & KeyValues keyValues; + + public isolated function init(Logger parent, readonly & KeyValues keyValues) { + self.parent = parent; + self.keyValues = keyValues; + } + + public isolated function printDebug(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { + KeyValues merged = self.mergeKeyValues(keyValues); + if !merged.hasKey("module") { + merged["module"] = getInvokedModuleName(2); } - if stackTrace is error:StackFrame[] { - logRecord["stackTrace"] = from var element in stackTrace - select element.toString(); + self.parent.printDebug(msg, 'error, stackTrace, merged); + } + + public isolated function printError(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { + KeyValues merged = self.mergeKeyValues(keyValues); + if !merged.hasKey("module") { + merged["module"] = getInvokedModuleName(2); + } + self.parent.printError(msg, 'error, stackTrace, merged); + } + + public isolated function printInfo(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { + KeyValues merged = self.mergeKeyValues(keyValues); + if !merged.hasKey("module") { + merged["module"] = getInvokedModuleName(2); } + self.parent.printInfo(msg, 'error, stackTrace, merged); + } + + public isolated function printWarn(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { + KeyValues merged = self.mergeKeyValues(keyValues); + if !merged.hasKey("module") { + merged["module"] = getInvokedModuleName(2); + } + self.parent.printWarn(msg, 'error, stackTrace, merged); + } + + public isolated function withContext(*KeyValues keyValues) returns Logger { + KeyValues newKeyValues = {...self.keyValues}; foreach [string, Value] [k, v] in keyValues.entries() { - logRecord[k] = v is Valuer ? v() : - (v is PrintableRawTemplate ? evaluateTemplate(v, self.enableSensitiveDataMasking) : v); + newKeyValues[k] = v; } - if observe:isTracingEnabled() { - map spanContext = observe:getSpanContext(); - foreach [string, string] [k, v] in spanContext.entries() { - logRecord[k] = v; + return new ChildLogger(self, newKeyValues.cloneReadOnly()); + } + + public isolated function getLevel() returns Level { + return self.parent.getLevel(); + } + + public isolated function setLevel(Level level) returns error? { + return error Error("Unsupported operation: cannot set log level on a child logger. " + + "Child loggers inherit their level from the parent logger."); + } + + private isolated function mergeKeyValues(KeyValues callSiteKeyValues) returns KeyValues { + KeyValues merged = {}; + foreach [string, Value] [k, v] in callSiteKeyValues.entries() { + merged[k] = v; + } + foreach [string, Value] [k, v] in self.keyValues.entries() { + if !merged.hasKey(k) { + merged[k] = v; } } string? runtimeId = observe:getTagValue(ICP_RUNTIME_ID_KEY); if runtimeId is string { - logRecord[ICP_RUNTIME_ID_KEY] = runtimeId; + merged[ICP_RUNTIME_ID_KEY] = runtimeId; } + return merged; + } +} - foreach [string, Value] [k, v] in self.keyValues.entries() { - logRecord[k] = v is Valuer ? v() : - (v is PrintableRawTemplate ? evaluateTemplate(v, self.enableSensitiveDataMasking) : v); +isolated function printLog(string logLevel, string moduleName, string|PrintableRawTemplate msg, + LogFormat format, readonly & OutputDestination[] destinations, readonly & KeyValues contextKeyValues, + boolean enableSensitiveDataMasking, error? err = (), error:StackFrame[]? stackTrace = (), + KeyValues callSiteKeyValues = {}) { + LogRecord logRecord = { + time: getCurrentTime(), + level: logLevel, + module: moduleName, + message: processMessage(msg, enableSensitiveDataMasking) + }; + if err is error { + logRecord.'error = getFullErrorDetails(err); + } + if stackTrace is error:StackFrame[] { + logRecord["stackTrace"] = from var element in stackTrace + select element.toString(); + } + // Apply in ascending priority order: context < call-site < tracing/observe. + // For child loggers, contextKeyValues is the parent's own context; callSiteKeyValues + // carries the already-merged {child context + call-site} result, so child wins over parent. + foreach [string, Value] [k, v] in contextKeyValues.entries() { + logRecord[k] = v is Valuer ? v() : + (v is PrintableRawTemplate ? evaluateTemplate(v, enableSensitiveDataMasking) : v); + } + foreach [string, Value] [k, v] in callSiteKeyValues.entries() { + logRecord[k] = v is Valuer ? v() : + (v is PrintableRawTemplate ? evaluateTemplate(v, enableSensitiveDataMasking) : v); + } + if observe:isTracingEnabled() { + map spanContext = observe:getSpanContext(); + foreach [string, string] [k, v] in spanContext.entries() { + logRecord[k] = v; } + } + if observe:isObservabilityEnabled() { + string? runtimeId = observe:getTagValue(ICP_RUNTIME_ID_KEY); + if runtimeId is string { + logRecord[ICP_RUNTIME_ID_KEY] = runtimeId; + } + } - string logOutput = self.format == JSON_FORMAT ? - (self.enableSensitiveDataMasking ? toMaskedString(logRecord) : logRecord.toJsonString()) : - printLogFmt(logRecord, self.enableSensitiveDataMasking); + string logOutput = format == JSON_FORMAT ? + (enableSensitiveDataMasking ? toMaskedString(logRecord) : logRecord.toJsonString()) : + printLogFmt(logRecord, enableSensitiveDataMasking); - lock { - if outputFilePath is string { - fileWrite(logOutput); - } + lock { + if outputFilePath is string { + fileWrite(logOutput); } + } - foreach OutputDestination destination in self.destinations { - if destination is StandardDestination { - if destination.'type == STDERR { - io:fprintln(io:stderr, logOutput); - } else { - io:fprintln(io:stdout, logOutput); - } + foreach OutputDestination destination in destinations { + if destination is StandardDestination { + if destination.'type == STDERR { + io:fprintln(io:stderr, logOutput); } else { - // File destination - RotationConfig? rotationConfig = destination.rotation; - if rotationConfig is () { - // No rotation configured, write directly without lock - writeLogToFile(destination.path, logOutput); - } else { - // Rotation configured, use lock to ensure thread-safe rotation and writing - // This prevents writes from happening during rotation - lock { - error? rotationResult = checkAndPerformRotation(destination.path, rotationConfig); - if rotationResult is error { - io:fprintln(io:stderr, string `warning: log rotation failed: ${rotationResult.message()}`); - } - writeLogToFile(destination.path, logOutput); + io:fprintln(io:stdout, logOutput); + } + } else { + RotationConfig? rotationConfig = destination.rotation; + if rotationConfig is () { + writeLogToFile(destination.path, logOutput); + } else { + lock { + error? rotationResult = checkAndPerformRotation(destination.path, rotationConfig); + if rotationResult is error { + io:fprintln(io:stderr, string `warning: log rotation failed: ${rotationResult.message()}`); } + writeLogToFile(destination.path, logOutput); } } } diff --git a/ballerina/tests/log_config_test.bal b/ballerina/tests/log_config_test.bal new file mode 100644 index 00000000..b29c7b6c --- /dev/null +++ b/ballerina/tests/log_config_test.bal @@ -0,0 +1,603 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/test; + +// ========== Tests for runtime log configuration ========== + +@test:Config { + groups: ["logConfig"] +} +function testGetGlobalLogLevel() { + Level currentLevel = root().getLevel(); + // The default level should be one of the valid levels + test:assertTrue(currentLevel == DEBUG || currentLevel == INFO || + currentLevel == WARN || currentLevel == ERROR, + "Global log level should be a valid level"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testGetGlobalLogLevel] +} +function testSetGlobalLogLevel() returns error? { + Logger rootLog = root(); + // Get current level to restore later + Level originalLevel = rootLog.getLevel(); + + // Set to DEBUG + check rootLog.setLevel(DEBUG); + test:assertEquals(rootLog.getLevel(), DEBUG, "Global log level should be DEBUG"); + + // Set to ERROR + check rootLog.setLevel(ERROR); + test:assertEquals(rootLog.getLevel(), ERROR, "Global log level should be ERROR"); + + // Set to WARN + check rootLog.setLevel(WARN); + test:assertEquals(rootLog.getLevel(), WARN, "Global log level should be WARN"); + + // Set to INFO + check rootLog.setLevel(INFO); + test:assertEquals(rootLog.getLevel(), INFO, "Global log level should be INFO"); + + // Restore original level + check rootLog.setLevel(originalLevel); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testSetGlobalLogLevel] +} +function testCustomLoggerWithId() returns error? { + // Get initial count of loggers in registry + int initialCount = getLoggerRegistry().getIds().length(); + + // Create a logger with explicit ID - ID will be module-prefixed + _ = check fromConfig(id = "test-named-logger", level = DEBUG); + + // Verify it's visible (with module prefix) + string[] ids = getLoggerRegistry().getIds(); + boolean found = false; + foreach string id in ids { + if id.endsWith(":test-named-logger") || id == "test-named-logger" { + found = true; + break; + } + } + test:assertTrue(found, "Logger with ID should be in registry"); + + // Verify count increased + int newCount = getLoggerRegistry().getIds().length(); + test:assertEquals(newCount, initialCount + 1, "Logger count should increase by 1"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testCustomLoggerWithId] +} +function testCustomLoggerWithoutId() returns error? { + // Get initial count of loggers + int initialCount = getLoggerRegistry().getIds().length(); + + // Create a logger without ID - should be visible with auto-generated ID + _ = check fromConfig(level = DEBUG); + + // Verify count increased (all loggers are now visible) + int newCount = getLoggerRegistry().getIds().length(); + test:assertEquals(newCount, initialCount + 1, "Logger count should increase for auto-ID logger"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testCustomLoggerWithoutId] +} +function testSetCustomLoggerLevel() returns error? { + // Create a logger with explicit ID + Logger logger = check fromConfig(id = "test-level-change-logger", level = INFO); + + // Find the actual module-prefixed ID + string[] ids = getLoggerRegistry().getIds(); + string loggerId = ""; + foreach string id in ids { + if id.endsWith(":test-level-change-logger") || id == "test-level-change-logger" { + loggerId = id; + break; + } + } + + // Verify initial level + Logger? retrieved = getLoggerRegistry().getById(loggerId); + test:assertTrue(retrieved is Logger, "Logger should be in registry"); + + // Change level to DEBUG via Ballerina API + check logger.setLevel(DEBUG); + test:assertEquals(logger.getLevel(), DEBUG, "Logger level should be DEBUG after setLevel"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testSetCustomLoggerLevel] +} +function testDuplicateLoggerId() returns error? { + // Create a logger with a known ID + _ = check fromConfig(id = "test-dup-logger", level = WARN); + + // Try to create another logger with the same ID + Logger|Error result = fromConfig(id = "test-dup-logger", level = WARN); + test:assertTrue(result is Error, "Creating logger with duplicate ID should return error"); + if result is Error { + test:assertTrue(result.message().includes("already exists"), + "Error message should mention logger already exists"); + } +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testDuplicateLoggerId] +} +function testChildLoggerInheritsLevel() returns error? { + // Create a parent logger with ID + Logger parentLogger = check fromConfig(id = "parent-logger-for-child-test", level = INFO); + + // Create a child logger + Logger childLogger = check parentLogger.withContext(childKey = "childValue"); + + // Child should inherit parent's level + test:assertEquals(childLogger.getLevel(), INFO, "Child should inherit INFO"); + + // Change parent's level + check parentLogger.setLevel(DEBUG); + + // Child should follow parent (delegates to parent.getLevel()) + test:assertEquals(childLogger.getLevel(), DEBUG, "Child should follow parent level change to DEBUG"); +} + +// ========== Tests for getLevel, setLevel ========== + +@test:Config { + groups: ["logConfig"], + dependsOn: [testChildLoggerInheritsLevel] +} +function testGetLevel() returns error? { + // Create a logger with INFO level + Logger testLogger = check fromConfig(id = "test-get-level", level = INFO); + test:assertEquals(testLogger.getLevel(), INFO, "Logger should return its configured level"); + + // Create a logger with DEBUG level + Logger debugLogger = check fromConfig(id = "test-get-level-debug", level = DEBUG); + test:assertEquals(debugLogger.getLevel(), DEBUG, "Logger should return DEBUG level"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testGetLevel] +} +function testSetLevel() returns error? { + Logger testLogger = check fromConfig(id = "test-set-level", level = INFO); + test:assertEquals(testLogger.getLevel(), INFO, "Initial level should be INFO"); + + // Set to DEBUG + check testLogger.setLevel(DEBUG); + test:assertEquals(testLogger.getLevel(), DEBUG, "Level should be DEBUG after setLevel"); + + // Set to ERROR + check testLogger.setLevel(ERROR); + test:assertEquals(testLogger.getLevel(), ERROR, "Level should be ERROR after setLevel"); + + // Set to WARN + check testLogger.setLevel(WARN); + test:assertEquals(testLogger.getLevel(), WARN, "Level should be WARN after setLevel"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testSetLevel] +} +function testChildLoggerInheritsParentChange() returns error? { + // Create parent with INFO + Logger parentLogger = check fromConfig(id = "test-inherit-change-parent", level = INFO); + + // Create child (inherits, no explicit level) + Logger childLogger = check parentLogger.withContext(childKey = "val"); + test:assertEquals(childLogger.getLevel(), INFO, "Child should inherit INFO"); + + // Change parent's level to DEBUG + check parentLogger.setLevel(DEBUG); + test:assertEquals(childLogger.getLevel(), DEBUG, "Child should follow parent's level change to DEBUG"); + + // Change parent's level to ERROR + check parentLogger.setLevel(ERROR); + test:assertEquals(childLogger.getLevel(), ERROR, "Child should follow parent's level change to ERROR"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testChildLoggerInheritsParentChange] +} +function testGetLoggerById() returns error? { + // Create a logger with known ID + string userId = "test-get-by-id-logger"; + _ = check fromConfig(id = userId, level = WARN); + + // Find the actual prefixed ID + string[] ids = getLoggerRegistry().getIds(); + string actualId = ""; + foreach string id in ids { + if id.endsWith(":" + userId) || id == userId { + actualId = id; + break; + } + } + test:assertTrue(actualId.length() > 0, "Should find logger ID in registry"); + + // Retrieve it by ID + Logger? retrieved = getLoggerRegistry().getById(actualId); + test:assertTrue(retrieved is Logger, "Should find logger by ID"); + + // Verify it's the same logger by checking level + if retrieved is Logger { + test:assertEquals(retrieved.getLevel(), WARN, "Retrieved logger should have WARN level"); + } + + // Try non-existent ID + Logger? notFound = getLoggerRegistry().getById("non-existent-id"); + test:assertTrue(notFound is (), "Non-existent ID should return nil"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testGetLoggerById] +} +function testAutoGeneratedId() returns error? { + // Create a logger without explicit ID + _ = check fromConfig(level = DEBUG); + + // It should appear in the registry + string[] ids = getLoggerRegistry().getIds(); + + // Verify there are auto-generated IDs that contain ":" (module:function format) + boolean foundAutoId = false; + foreach string id in ids { + if id.includes(":") && !id.includes(":test-") && !id.includes(":parent-") && !id.includes(":test_") { + foundAutoId = true; + break; + } + } + test:assertTrue(foundAutoId, "Should find at least one auto-generated ID with module:function format"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testAutoGeneratedId] +} +function testGrandchildInheritance() returns error? { + // parent -> child -> grandchild: grandchild should inherit through the chain + Logger parent = check fromConfig(id = "test-grandchild-parent", level = WARN); + Logger child = check parent.withContext(childKey = "c1"); + Logger grandchild = check child.withContext(childKey = "c2"); + + // All should report WARN (inherited) + test:assertEquals(parent.getLevel(), WARN, "Parent should be WARN"); + test:assertEquals(child.getLevel(), WARN, "Child should inherit WARN"); + test:assertEquals(grandchild.getLevel(), WARN, "Grandchild should inherit WARN"); + + // Change parent to DEBUG - entire chain should follow + check parent.setLevel(DEBUG); + test:assertEquals(child.getLevel(), DEBUG, "Child should follow parent to DEBUG"); + test:assertEquals(grandchild.getLevel(), DEBUG, "Grandchild should follow parent to DEBUG"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testGrandchildInheritance] +} +function testChildInheritsParentLevelChanges() returns error? { + // Child without explicit level should track parent changes + Logger parent = check fromConfig(id = "test-child-tracks-parent", level = INFO); + Logger child = check parent.withContext(childKey = "val"); + + test:assertEquals(child.getLevel(), INFO, "Child should inherit INFO"); + + // Change parent - child should follow + check parent.setLevel(WARN); + test:assertEquals(child.getLevel(), WARN, "Child should follow parent change to WARN"); + + check parent.setLevel(DEBUG); + test:assertEquals(child.getLevel(), DEBUG, "Child should follow parent change to DEBUG"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testChildInheritsParentLevelChanges] +} +function testRootLoggerGetAndSetLevel() returns error? { + Logger rootLog = root(); + + // Root logger should have a valid level + Level rootLevel = rootLog.getLevel(); + test:assertTrue(rootLevel == DEBUG || rootLevel == INFO || + rootLevel == WARN || rootLevel == ERROR, + "Root logger should have a valid level"); + + // Save original and set to DEBUG + Level original = rootLog.getLevel(); + check rootLog.setLevel(DEBUG); + test:assertEquals(rootLog.getLevel(), DEBUG, "Root logger level should be DEBUG after setLevel"); + + // Set to ERROR + check rootLog.setLevel(ERROR); + test:assertEquals(rootLog.getLevel(), ERROR, "Root logger level should be ERROR after setLevel"); + + // Restore original + check rootLog.setLevel(original); + test:assertEquals(rootLog.getLevel(), original, "Root logger level should be restored"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testRootLoggerGetAndSetLevel] +} +function testGetLoggerByIdSetLevelRoundTrip() returns error? { + // Create logger, retrieve from registry, change level via retrieved reference + string userId = "test-roundtrip-logger"; + Logger original = check fromConfig(id = userId, level = INFO); + + // Find actual prefixed ID + string[] ids = getLoggerRegistry().getIds(); + string actualId = ""; + foreach string id in ids { + if id.endsWith(":" + userId) || id == userId { + actualId = id; + break; + } + } + + Logger? retrieved = getLoggerRegistry().getById(actualId); + test:assertTrue(retrieved is Logger, "Should find logger by ID"); + + if retrieved is Logger { + // Change level via retrieved logger + check retrieved.setLevel(DEBUG); + + // Verify both references see the change (they are the same object) + test:assertEquals(retrieved.getLevel(), DEBUG, "Retrieved logger should be DEBUG"); + test:assertEquals(original.getLevel(), DEBUG, "Original logger should also be DEBUG"); + } +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testGetLoggerByIdSetLevelRoundTrip] +} +function testMultipleChildrenInherit() returns error? { + // Multiple children from same parent: all inherit from parent + Logger parent = check fromConfig(id = "test-multi-children-parent", level = INFO); + Logger child1 = check parent.withContext(childKey = "c1"); + Logger child2 = check parent.withContext(childKey = "c2"); + Logger child3 = check parent.withContext(childKey = "c3"); + + // All inherit INFO + test:assertEquals(child1.getLevel(), INFO, "Child1 should inherit INFO"); + test:assertEquals(child2.getLevel(), INFO, "Child2 should inherit INFO"); + test:assertEquals(child3.getLevel(), INFO, "Child3 should inherit INFO"); + + // Change parent to WARN - all children should follow + check parent.setLevel(WARN); + test:assertEquals(child1.getLevel(), WARN, "Child1 should follow parent to WARN"); + test:assertEquals(child2.getLevel(), WARN, "Child2 should follow parent to WARN"); + test:assertEquals(child3.getLevel(), WARN, "Child3 should follow parent to WARN"); +} + +// ========== Tests for level-checking logic ========== + +@test:Config { + groups: ["logConfig"], + dependsOn: [testMultipleChildrenInherit] +} +function testIsLevelEnabled() { + // INFO-level logger: DEBUG should be disabled, INFO/WARN/ERROR enabled + test:assertFalse(isLevelEnabled(INFO, DEBUG), "DEBUG should not be enabled for INFO-level logger"); + test:assertTrue(isLevelEnabled(INFO, INFO), "INFO should be enabled for INFO-level logger"); + test:assertTrue(isLevelEnabled(INFO, WARN), "WARN should be enabled for INFO-level logger"); + test:assertTrue(isLevelEnabled(INFO, ERROR), "ERROR should be enabled for INFO-level logger"); + + // DEBUG-level logger: all should be enabled + test:assertTrue(isLevelEnabled(DEBUG, DEBUG), "DEBUG should be enabled for DEBUG-level logger"); + test:assertTrue(isLevelEnabled(DEBUG, INFO), "INFO should be enabled for DEBUG-level logger"); + + // ERROR-level logger: only ERROR enabled + test:assertFalse(isLevelEnabled(ERROR, DEBUG), "DEBUG should not be enabled for ERROR-level logger"); + test:assertFalse(isLevelEnabled(ERROR, INFO), "INFO should not be enabled for ERROR-level logger"); + test:assertFalse(isLevelEnabled(ERROR, WARN), "WARN should not be enabled for ERROR-level logger"); + test:assertTrue(isLevelEnabled(ERROR, ERROR), "ERROR should be enabled for ERROR-level logger"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testIsLevelEnabled] +} +function testInheritedLevelFiltersCorrectly() returns error? { + // When a child inherits DEBUG from parent via setLevel(), + // the level check must also use the inherited level. + Logger parent = check fromConfig(id = "test-inherited-filter-parent", level = INFO); + Logger child = check parent.withContext(childKey = "val"); + + // Initially both at INFO - DEBUG should be disabled + test:assertEquals(child.getLevel(), INFO, "Child should inherit INFO"); + test:assertFalse(isLevelEnabled(child.getLevel(), DEBUG), + "DEBUG should not be enabled when child inherits INFO"); + + // Change parent to DEBUG - child should inherit and DEBUG should now be enabled + check parent.setLevel(DEBUG); + test:assertEquals(child.getLevel(), DEBUG, "Child should inherit DEBUG from parent"); + test:assertTrue(isLevelEnabled(child.getLevel(), DEBUG), + "DEBUG should be enabled when child inherits DEBUG from parent"); +} + +// ========== Tests for registry and ID generation ========== + +@test:Config { + groups: ["logConfig"], + dependsOn: [testInheritedLevelFiltersCorrectly] +} +function testRootLoggerInRegistry() returns error? { + // The global root logger should be registered with well-known ID "root" + LoggerRegistry registry = getLoggerRegistry(); + string[] ids = registry.getIds(); + + boolean found = false; + foreach string id in ids { + if id == "root" { + found = true; + break; + } + } + test:assertTrue(found, "Root logger should be in the registry with ID 'root'"); + + // Root logger should be retrievable by ID + Logger? rootLog = registry.getById("root"); + test:assertTrue(rootLog is Logger, "Root logger should be retrievable via getById('root')"); + + // The root logger from registry should be the same as log:root() + if rootLog is Logger { + test:assertEquals(rootLog.getLevel(), root().getLevel(), + "Registry root logger level should match log:root() level"); + } +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testRootLoggerInRegistry] +} +function testModulePrefixedId() returns error? { + // Verify that user-provided IDs get module-prefixed + _ = check fromConfig(id = "my-custom-id", level = INFO); + + string[] ids = getLoggerRegistry().getIds(); + boolean found = false; + foreach string id in ids { + // Should be prefixed with module name + if id.endsWith(":my-custom-id") { + found = true; + break; + } + } + // ID should either be module-prefixed or bare (if module name is empty) + boolean foundBare = false; + foreach string id in ids { + if id == "my-custom-id" { + foundBare = true; + break; + } + } + test:assertTrue(found || foundBare, "User-provided ID should be in registry (possibly module-prefixed)"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testModulePrefixedId] +} +function testAutoIdFirstNoSuffix() returns error? { + // The first auto-generated ID for a function should not have a counter suffix. + // Capture the registry state before creating a new logger with an auto-generated ID. + string[] idsBefore = getLoggerRegistry().getIds(); + int sizeBefore = idsBefore.length(); + + // Create a logger with an auto-generated ID (no explicit id provided). + Logger _ = check fromConfig(level = DEBUG); + + // Capture the registry state after creating the logger. + string[] idsAfter = getLoggerRegistry().getIds(); + int sizeAfter = idsAfter.length(); + + // Exactly one new entry should be added to the registry. + test:assertEquals(sizeAfter, sizeBefore + 1, + "Creating a logger with auto-ID should add exactly one registry entry"); + + // Compute the set of new IDs added to the registry. + string[] newIds = []; + foreach string id in idsAfter { + boolean found = false; + foreach string beforeId in idsBefore { + if id == beforeId { + found = true; + break; + } + } + if !found { + newIds.push(id); + } + } + + // At least one new auto-generated ID should be present. + test:assertTrue(newIds.length() >= 1, "At least one new auto-generated ID should be present"); + + // Auto-generated IDs have format "module:function" (no suffix for first). + // Verify that at least one new ID contains ":" and does not end with "-N" (where N is a digit). + boolean foundMatch = false; + foreach string id in newIds { + if id.includes(":") && !id.matches(re `.*-\d+$`) { + foundMatch = true; + break; + } + } + + test:assertTrue(foundMatch, + "First auto-generated ID for a logger should not have a numeric suffix (e.g., -1)"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testAutoIdFirstNoSuffix] +} +function testChildSetLevelReturnsError() returns error? { + // Child loggers should return an error when setLevel is called + Logger parent = check fromConfig(id = "test-child-setlevel-err", level = INFO); + Logger child = check parent.withContext(childKey = "val"); + + error? result = child.setLevel(DEBUG); + test:assertTrue(result is error, "setLevel on child logger should return error"); + if result is error { + test:assertTrue(result.message().includes("child logger"), + "Error message should mention child logger"); + } + + // Verify child still inherits parent level (setLevel had no effect) + test:assertEquals(child.getLevel(), INFO, "Child should still inherit INFO from parent"); +} + +@test:Config { + groups: ["logConfig"], + dependsOn: [testChildSetLevelReturnsError] +} +function testChildNotInRegistry() returns error? { + // Child loggers should NOT be registered in the Ballerina-side registry + string[] idsBefore = getLoggerRegistry().getIds(); + int sizeBefore = idsBefore.length(); + + // Create parent logger + Logger parentLogger = check fromConfig(id = "test-child-not-registered", level = INFO); + + // Create child via withContext + _ = check parentLogger.withContext(childKey = "childValue"); + + // Registry should have only 1 more entry (parent only, not child) + string[] idsAfter = getLoggerRegistry().getIds(); + int sizeAfter = idsAfter.length(); + test:assertEquals(sizeAfter, sizeBefore + 1, "Registry should have only 1 more entry (parent only)"); +} + diff --git a/ballerina/tests/log_masking.bal b/ballerina/tests/log_masking.bal index e58b5fe3..e29c718e 100644 --- a/ballerina/tests/log_masking.bal +++ b/ballerina/tests/log_masking.bal @@ -45,27 +45,27 @@ isolated function getUser() returns User => user; function testLogMasking() returns error? { test:when(mock_fprintln).call("addMaskedLogs"); maskerJsonLogger.printInfo("user logged in", user = user); - string expectedLog = string `"message":"user logged in","user":{"name":"John Doe","password":"*****","mail":"joh**************com"},"env":"test"`; + string expectedLog = string `"message":"user logged in","env":"test","user":{"name":"John Doe","password":"*****","mail":"joh**************com"}`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); maskerLogger.printInfo("user logged in", user = user); - expectedLog = string `message="user logged in" user={"name":"John Doe","password":"*****","mail":"joh**************com"} env="test"`; + expectedLog = string `message="user logged in" env="test" user={"name":"John Doe","password":"*****","mail":"joh**************com"}`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); map userTmp = user; maskerJsonLogger.printInfo("user logged in", user = userTmp); - expectedLog = string `"message":"user logged in","user":{"name":"John Doe","password":"*****","mail":"joh**************com"},"env":"test"`; + expectedLog = string `"message":"user logged in","env":"test","user":{"name":"John Doe","password":"*****","mail":"joh**************com"}`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); userTmp = check user.cloneWithType(); maskerJsonLogger.printInfo("user logged in", user = userTmp); - expectedLog = string `"message":"user logged in","user":{"name":"John Doe","ssn":"123-45-6789","password":"password123","mail":"john.doe@example.com","creditCard":"4111-1111-1111-1111"},"env":"test"`; + expectedLog = string `"message":"user logged in","env":"test","user":{"name":"John Doe","ssn":"123-45-6789","password":"password123","mail":"john.doe@example.com","creditCard":"4111-1111-1111-1111"}`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); @@ -77,13 +77,13 @@ function testLogMasking() returns error? { maskedLogs.removeAll(); maskerLogger.printWarn("user login attempt failed", user = getUser); - expectedLog = string `message="user login attempt failed" user={"name":"John Doe","password":"*****","mail":"joh**************com"} env="test"`; + expectedLog = string `message="user login attempt failed" env="test" user={"name":"John Doe","password":"*****","mail":"joh**************com"}`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); maskerLogger.printInfo("basic types", str = "my string", num = 12345, flag = true, val = (), xmlVal = xml `bar`); - expectedLog = string `message="basic types" str="my string" num=12345 flag=true val=null xmlVal=bar env="test"`; + expectedLog = string `message="basic types" env="test" str="my string" num=12345 flag=true val=null xmlVal=bar`; test:assertEquals(maskedLogs.length(), 1); test:assertTrue(maskedLogs[0].includes(expectedLog)); maskedLogs.removeAll(); diff --git a/ballerina/tests/log_rotation_test.bal b/ballerina/tests/log_rotation_test.bal index 2d226a14..751bbac0 100644 --- a/ballerina/tests/log_rotation_test.bal +++ b/ballerina/tests/log_rotation_test.bal @@ -933,7 +933,7 @@ function testRotationWithKeyValues() returns error? { function testEmptyFileNoRotation() returns error? { string logFilePath = ROTATION_TEST_DIR + "empty_file_test.log"; - Logger logger = check fromConfig( + _ = check fromConfig( destinations = [ { 'type: FILE, diff --git a/ballerina/tests/log_test.bal b/ballerina/tests/log_test.bal index cefddd20..a55bca4c 100644 --- a/ballerina/tests/log_test.bal +++ b/ballerina/tests/log_test.bal @@ -135,3 +135,75 @@ function testInvalidDestination() returns error? { } test:assertEquals(result.message(), "The given file destination path: 'foo' is not valid. File destination path should be a valid file with .log extension."); } + +@test:Config {} +function testEmptyDestinationValidation() returns error? { + Error? result = validateDestinations([]); + test:assertTrue(result is Error, "Should return an error for empty destinations"); + if result is Error { + test:assertEquals(result.message(), "At least one log destination must be specified."); + } +} + +@test:Config {} +function testValidateRotationConfigErrors() returns error? { + // maxFileSize <= 0 with SIZE_BASED policy + Error? result = validateRotationConfig({policy: SIZE_BASED, maxFileSize: 0, maxAge: 3600, maxBackupFiles: 5}); + test:assertTrue(result is Error, "Should error when maxFileSize <= 0 with SIZE_BASED"); + if result is Error { + test:assertTrue(result.message().includes("maxFileSize must be positive")); + } + + // maxAge <= 0 with TIME_BASED policy + result = validateRotationConfig({policy: TIME_BASED, maxFileSize: 1000, maxAge: 0, maxBackupFiles: 5}); + test:assertTrue(result is Error, "Should error when maxAge <= 0 with TIME_BASED"); + if result is Error { + test:assertTrue(result.message().includes("maxAge must be positive")); + } + + // maxBackupFiles < 0 + result = validateRotationConfig({policy: SIZE_BASED, maxFileSize: 1000, maxAge: 3600, maxBackupFiles: -1}); + test:assertTrue(result is Error, "Should error when maxBackupFiles < 0"); + if result is Error { + test:assertTrue(result.message().includes("maxBackupFiles cannot be negative")); + } +} + +@test:Config {} +function testProcessTemplateDeprecated() { + // Test with a plain string insertion + string name = "world"; + string result = processTemplate(`Hello ${name}!`); + test:assertEquals(result, "Hello world!"); + + // Test with a Valuer (function) insertion + string result2 = processTemplate(`count: ${isolated function() returns int => 42}`); + test:assertEquals(result2, "count: 42"); + + // Test with a nested PrintableRawTemplate insertion + string inner = "inner"; + string result3 = processTemplate(`outer: ${`nested-${inner}`}`); + test:assertEquals(result3, "outer: nested-inner"); +} + +@test:Config { + dependsOn: [testPrintLog] +} +function testPrintErrorWithCause() { + test:when(mock_fprintln).call("mockFprintln"); + error cause = error("root cause"); + error chained = error("top level", cause); + printError("chained error test", 'error = chained); + test:assertEquals(logMessage, "something went wrong"); +} + +@test:Config { + dependsOn: [testPrintErrorWithCause] +} +function testPrintErrorWithJsonMessage() { + test:when(mock_fprintln).call("mockFprintln"); + // An error whose message is a valid JSON string — parseErrorMessage returns json + error jsonMsgError = error("{\"type\":\"NotFound\",\"code\":404}"); + printError("json message error", 'error = jsonMsgError); + test:assertEquals(logMessage, "something went wrong"); +} diff --git a/ballerina/tests/logger_test.bal b/ballerina/tests/logger_test.bal index d7a99108..1016714f 100644 --- a/ballerina/tests/logger_test.bal +++ b/ballerina/tests/logger_test.bal @@ -62,9 +62,9 @@ function testBasicLoggingFunctions() returns error? { PrintableRawTemplate value2Temp = `val:${value2}`; logger1.printError("This is an error message", error("An error ocurred"), key2 = `${value2Temp}`); logger1.printWarn("This is a warning message"); - string expectedMsg1 = string `, "level":"INFO", "module":"ballerina/log$test", "message":"This is an info message", "key1":"value1", "key2":"val:value2", "ctx":{"id":"ctx-1234", "msg":"Sample Context Message"}, "env":"prod", "name":"logger1"}`; + string expectedMsg1 = string `, "level":"INFO", "module":"ballerina/log$test", "message":"This is an info message", "env":"prod", "name":"logger1", "key1":"value1", "key2":"val:value2", "ctx":{"id":"ctx-1234", "msg":"Sample Context Message"}}`; string expectedMsg21 = string `, "level":"ERROR", "module":"ballerina/log$test", "message":"This is an error message", "error":{"causes":[], "message":"An error ocurred", "detail":{}, "stackTrace":`; - string expectedMsg22 = string `, "key2":"val:value2", "env":"prod", "name":"logger1"}`; + string expectedMsg22 = string `, "env":"prod", "name":"logger1", "key2":"val:value2"}`; string expectedMsg3 = string `, "level":"WARN", "module":"ballerina/log$test", "message":"This is a warning message", "env":"prod", "name":"logger1"}`; test:assertEquals(stdErrLogs.length(), 3); @@ -94,7 +94,7 @@ function testBasicLoggingFunctions() returns error? { string expectedMsg4 = string ` level=INFO module=ballerina/log$test message="This is an info message" env="dev" name="logger2"`; string expectedMsg51 = string ` level=ERROR module=ballerina/log$test message="This is an error message" error={"causes":[],"message":"An error occurred","detail":{},"stackTrace":`; string expectedMsg52 = string ` env="dev" name="logger2"`; - string expectedMsg6 = string ` level=DEBUG module=ballerina/log$test message="This is a debug message" key1="value1" key2="val:value2" ctx={"id":"ctx-1234","msg":"Sample Context Message"} env="dev" name="logger2"`; + string expectedMsg6 = string ` level=DEBUG module=ballerina/log$test message="This is a debug message" env="dev" name="logger2" key1="value1" key2="val:value2" ctx={"id":"ctx-1234","msg":"Sample Context Message"}`; string expectedMsg7 = string ` level=WARN module=ballerina/log$test message="This is a warning message" env="dev" name="logger2"`; test:assertTrue(stdErrLogs.length() == 0); @@ -160,4 +160,40 @@ function testChildLogger() { test:assertEquals(stdErrLogs.length(), 1); test:assertTrue(stdErrLogs[0].endsWith(string `, "level":"INFO", "module":"ballerina/log$test", "message":"This is an info message", "env":"test", "child":true, "name":"child-logger", "key":"value"}`)); stdErrLogs.removeAll(); + + childLogger.printDebug("This is a debug message", requestId = "req-123"); + test:assertEquals(stdErrLogs.length(), 1); + test:assertTrue(stdErrLogs[0].endsWith(string `, "level":"DEBUG", "module":"ballerina/log$test", "message":"This is a debug message", "env":"test", "requestId":"req-123", "child":true, "name":"child-logger", "key":"value"}`)); + stdErrLogs.removeAll(); + + childLogger.printError("This is an error message", error("test error")); + test:assertEquals(stdErrLogs.length(), 1); + test:assertTrue(stdErrLogs[0].includes(string `"level":"ERROR", "module":"ballerina/log$test", "message":"This is an error message"`)); + test:assertTrue(stdErrLogs[0].endsWith(string `, "env":"test", "child":true, "name":"child-logger", "key":"value"}`)); + stdErrLogs.removeAll(); + + childLogger.printWarn("This is a warn message"); + test:assertEquals(stdErrLogs.length(), 1); + test:assertTrue(stdErrLogs[0].endsWith(string `, "level":"WARN", "module":"ballerina/log$test", "message":"This is a warn message", "env":"test", "child":true, "name":"child-logger", "key":"value"}`)); + stdErrLogs.removeAll(); +} + +@test:Config { + groups: ["logger"], + dependsOn: [testChildLogger] +} +function testModuleLevelOverride() { + test:when(mock_fprintln).call("addLogs"); + // "myorg/myproject" has level ERROR in Config.toml — DEBUG/INFO/WARN should be suppressed. + // Use root() (Logger interface) so optional error/stackTrace params get default values. + Logger rootLog = root(); + rootLog.printDebug("should be suppressed", module = "myorg/myproject"); + rootLog.printInfo("should be suppressed", module = "myorg/myproject"); + rootLog.printWarn("should be suppressed", module = "myorg/myproject"); + test:assertEquals(stdErrLogs.length(), 0, "DEBUG/INFO/WARN should be suppressed by module level ERROR"); + + rootLog.printError("should pass through", module = "myorg/myproject"); + test:assertEquals(stdErrLogs.length(), 1, "ERROR should pass through module level ERROR"); + test:assertTrue(stdErrLogs[0].includes("\"message\":\"should pass through\"")); + stdErrLogs.removeAll(); } diff --git a/build-config/spotbugs-exclude.xml b/build-config/spotbugs-exclude.xml new file mode 100644 index 00000000..45b02afd --- /dev/null +++ b/build-config/spotbugs-exclude.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/changelog.md b/changelog.md index 590dce69..7d51f247 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,10 @@ This file contains all the notable changes done to the Ballerina Log package thr ## [Unreleased] +### Added + +- [Add Java APIs for runtime log level modification](https://github.com/ballerina-platform/ballerina-library/issues/6213) + ## [2.16.1] - 2026-01-05 ### Fixed diff --git a/docs/spec/spec.md b/docs/spec/spec.md index b5f6e556..eda59bee 100644 --- a/docs/spec/spec.md +++ b/docs/spec/spec.md @@ -3,7 +3,7 @@ _Authors_: @daneshk @MadhukaHarith92 @TharmiganK _Reviewers_: @daneshk @ThisaruGuruge _Created_: 2021/11/15 -_Updated_: 2025/10/08 +_Updated_: 2026/02/17 _Edition_: Swan Lake ## Introduction @@ -32,10 +32,16 @@ The conforming implementation of the specification is released and included in t * 4.3. [Child logger](#43-child-logger) * 4.3.1. [Loggers with additional context](#431-loggers-with-additional-context) * 4.3.2. [Loggers with unique logging configuration](#432-loggers-with-unique-logging-configuration) -5. [Sensitive data masking](#5-sensitive-data-masking) - * 5.1. [Sensitive data annotation](#51-sensitive-data-annotation) - * 5.2. [Masked string function](#52-masked-string-function) - * 5.3. [Type-based masking](#53-type-based-masking) +5. [Runtime log level modification](#5-runtime-log-level-modification) + * 5.1. [Logger level APIs](#51-logger-level-apis) + * 5.2. [Logger identification](#52-logger-identification) + * 5.3. [Logger registry](#53-logger-registry) + * 5.4. [Module log levels](#54-module-log-levels) + * 5.5. [Child logger level inheritance](#55-child-logger-level-inheritance) +6. [Sensitive data masking](#6-sensitive-data-masking) + * 6.1. [Sensitive data annotation](#61-sensitive-data-annotation) + * 6.2. [Masked string function](#62-masked-string-function) + * 6.3. [Type-based masking](#63-type-based-masking) ## 1. Overview @@ -284,7 +290,7 @@ public type Logger isolated object { public isolated function printDebug(string|PrintableRawTemplate msg, error? 'error = (), error:StackFrame[]? stackTrace = (), *KeyValues keyValues); # Prints info logs. - # + # # + msg - The message to be logged # + 'error - The error struct to be logged # + stackTrace - The error stack trace to be logged @@ -292,7 +298,7 @@ public type Logger isolated object { public isolated function printInfo(string|PrintableRawTemplate msg, error? 'error = (), error:StackFrame[]? stackTrace = (), *KeyValues keyValues); # Prints warn logs. - # + # # + msg - The message to be logged # + 'error - The error struct to be logged # + stackTrace - The error stack trace to be logged @@ -300,7 +306,7 @@ public type Logger isolated object { public isolated function printWarn(string|PrintableRawTemplate msg, error? 'error = (), error:StackFrame[]? stackTrace = (), *KeyValues keyValues); # Prints error logs. - # + # # + msg - The message to be logged # + 'error - The error struct to be logged # + stackTrace - The error stack trace to be logged @@ -312,6 +318,20 @@ public type Logger isolated object { # + keyValues - The key-value pairs to be added to the logger context # + return - A new Logger instance with the given key-values added to its context public isolated function withContext(*KeyValues keyValues) returns Logger|error; + + # Gets the effective log level of this logger. + # For root and custom loggers, returns the explicitly set level. + # For child loggers, returns the inherited level from the parent logger. + # + # + return - The effective log level + public isolated function getLevel() returns Level; + + # Sets the log level of this logger at runtime. + # Returns an error if the operation is not supported (e.g., on child loggers). + # + # + level - The new log level to set + # + return - An error if the operation is not supported, nil on success + public isolated function setLevel(Level level) returns error?; }; ``` @@ -362,6 +382,11 @@ The following type defines the configuration options for a Ballerina logger: ```ballerina # Configuration for the Ballerina logger public type Config record {| + # Optional unique identifier for this logger. If provided, this ID is module-prefixed + # to produce a fully qualified ID: /: (e.g., "myorg/payment:payment-service"). + # If not provided, a readable identifier is auto-generated using the pattern: + # : for the first logger, :- for subsequent ones. + string id?; # Log format to use. Default is the logger format configured in the module level LogFormat format = format; # Log level to use. Default is the logger level configured in the module level @@ -384,11 +409,159 @@ log:Config auditLogConfig = { destinations: [{path: "./logs/audit.log"}] }; -log:Logger auditLogger = log:fromConfig(auditLogConfig); +log:Logger auditLogger = check log:fromConfig(auditLogConfig); auditLogger.printInfo("Hello World from the audit logger!"); ``` -## 5. Sensitive data masking +To create a logger with a unique identifier that allows runtime log level modification: + +```ballerina +log:Logger auditLogger = check log:fromConfig(id = "audit-logger", level = log:INFO, format = log:JSON_FORMAT); +auditLogger.printInfo("Hello World from the audit logger!"); +``` + +> **Note:** The `id` must be unique across all loggers in the application. If a logger with the same ID already exists, an error will be returned. + +## 5. Runtime log level modification + +The Ballerina log module supports runtime log level modification, enabling developers and the ICP (Integration Control Panel) to dynamically adjust log levels without restarting the application. + +### 5.1. Logger level APIs + +Two methods are available on the `Logger` interface for runtime level management: + +- `getLevel()` returns the effective log level. For root and custom loggers, this is the explicitly set level. For child loggers, this is the inherited level from the parent. +- `setLevel()` updates the log level at runtime. Returns an error on child loggers (which always inherit from their parent). + +```ballerina +log:Logger logger = check log:fromConfig(id = "payment-service", level = log:INFO); + +// Get current level +log:Level currentLevel = logger.getLevel(); // INFO + +// Change level at runtime +check logger.setLevel(log:DEBUG); +logger.getLevel(); // DEBUG +``` + +### 5.2. Logger identification + +All loggers created via `fromConfig` are registered in the logger registry and are identifiable by a unique ID. + +**User-provided IDs** are module-prefixed to produce a fully qualified ID of the form `/:`: + +```ballerina +// In module "myorg/payment": +log:Logger paymentLogger = check log:fromConfig(id = "payment-service", level = log:INFO); +// Registered as: "myorg/payment:payment-service" +``` + +**Auto-generated IDs** are created when no `id` is provided, using the module name and caller function name: + +- `:` for the first logger in a function +- `:-` for subsequent loggers in the same function + +```ballerina +// In function "processOrder" of module "myorg/payment": +log:Logger logger1 = check log:fromConfig(level = log:DEBUG); +// Registered as: "myorg/payment:processOrder" + +log:Logger logger2 = check log:fromConfig(level = log:DEBUG); +// Registered as: "myorg/payment:processOrder-2" +``` + +Stack frame inspection is performed only once at logger creation time — there is no runtime performance impact on logging operations. + +> **Note:** The `id` must be unique across all loggers in the application. If a logger with the same ID already exists, an error is returned. + +### 5.3. Logger registry + +The log module maintains an internal logger registry that tracks all registered loggers. Access to the registry is provided via the `LoggerRegistry` class, obtained by calling `getLoggerRegistry()`. + +The registry tracks: +- The root logger (registered with the well-known ID `"root"`) +- All loggers created via `fromConfig` (with module-prefixed or auto-generated IDs) + +Child loggers (created via `withContext`) are **not** registered in the registry. + +```ballerina +# Provides access to the logger registry for discovering and managing registered loggers. +public isolated class LoggerRegistry { + + # Returns the IDs of all registered loggers. + # + return - An array of logger IDs + public isolated function getIds() returns string[]; + + # Returns a logger by its registered ID. + # + id - The logger ID to look up + # + return - The Logger instance if found, nil otherwise + public isolated function getById(string id) returns Logger?; +} + +# Returns the logger registry for discovering and managing registered loggers. +# + return - The LoggerRegistry instance +public isolated function getLoggerRegistry() returns LoggerRegistry; +``` + +Usage example: + +```ballerina +log:LoggerRegistry registry = log:getLoggerRegistry(); + +// List all registered logger IDs +string[] ids = registry.getIds(); +// e.g., ["root", "myorg/payment:payment-service", "myorg/payment:init"] + +// Look up a logger by ID and change its level +log:Logger? logger = registry.getById("myorg/payment:payment-service"); +if logger is log:Logger { + check logger.setLevel(log:DEBUG); +} +``` + +### 5.4. Module log levels + +Per-module log levels configured in `Config.toml` are applied at the start of the program and filter log statements from each module statically. Module log levels are **not** registered in the logger registry and cannot be modified at runtime. + +```toml +[ballerina.log] +level = "INFO" + +[[ballerina.log.modules]] +name = "myorg/payment" +level = "DEBUG" +``` + +When code in `myorg/payment` logs a message, the root logger checks the configured module level (DEBUG) before deciding whether to emit the log, regardless of the root logger's own level (INFO). This check happens on the hot logging path using a lock-free lookup. + +> **Note:** Module log levels are a static, configuration-time feature. To change a module's effective log level at runtime, use a logger created via `fromConfig` and control it through the `LoggerRegistry`. + +### 5.5. Child logger level inheritance + +Child loggers (created via `withContext`) always inherit their log level from the parent logger: + +- `getLevel()` on a child logger always delegates to the parent's `getLevel()`. When the parent's level changes, the child's effective level changes automatically. +- `setLevel()` on a child logger returns an unsupported operation error. To change a child's effective level, change the parent's level instead. +- Child loggers are **not** registered in the logger registry. +- Grandchild loggers chain correctly — each delegates `getLevel()` up the chain to the nearest root/custom logger. + +```ballerina +log:Logger parent = check log:fromConfig(id = "payment-service", level = log:INFO); +log:Logger child = check parent.withContext(component = "order-handler"); + +// Child inherits parent's level +child.getLevel(); // INFO + +// Change parent level — child follows +check parent.setLevel(log:DEBUG); +child.getLevel(); // DEBUG + +// setLevel() on child returns an error +error? result = child.setLevel(log:ERROR); +// result is error("Unsupported operation: cannot set log level on a child logger...") +``` + +## 6. Sensitive data masking The Ballerina log module provides the capability to mask sensitive data in log messages. This is crucial for maintaining data privacy and security, especially when dealing with personally identifiable information (PII) or other sensitive data. @@ -408,7 +581,7 @@ The Ballerina log module provides the capability to mask sensitive data in log m > log:Logger secureLogger = log:fromConfig(secureConfig); > ``` -### 5.1. Sensitive data annotation +### 6.1. Sensitive data annotation The `@log:Sensitive` annotation can be used to mark fields in a record as sensitive. When such fields are logged, their values will be excluded or masked to prevent exposure of sensitive information. @@ -481,7 +654,7 @@ Output: time=2025-08-20T09:20:45.456+05:30 level=INFO module="" message="user details" user={"id":"U001","password":"****","ssn":"1****9","name":"John Doe"} ``` -### 5.2. Masked string function +### 6.2. Masked string function The `log:toMaskedString()` function can be used to obtain the masked version of a value. This is useful when developers want to implement custom loggers and need to mask sensitive data. @@ -509,7 +682,7 @@ Output: {"id":"U001","name":"John Doe"} ``` -### 5.3. Type-based masking +### 6.3. Type-based masking The masking is based on the type of the value. Since, Ballerina is a structurally typed language, same value can be assigned to different typed variables. So the masking is based on the actual value type which is determined at the value creation time. The original type information can be extracted using the `typeof` operator. diff --git a/gradle.properties b/gradle.properties index 010ea164..9e489b36 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true group=io.ballerina.stdlib -version=2.16.2-SNAPSHOT +version=2.17.0-SNAPSHOT ballerinaLangVersion=2201.13.0 checkstylePluginVersion=10.12.0 diff --git a/integration-tests/Ballerina.toml b/integration-tests/Ballerina.toml index a4b2c935..dffd6200 100644 --- a/integration-tests/Ballerina.toml +++ b/integration-tests/Ballerina.toml @@ -17,4 +17,4 @@ path = "../test-utils/build/libs/log-test-utils-@project.version@.jar" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "io-native" -path = "./lib/io-native-@io.native.version@.jar" \ No newline at end of file +path = "./lib/io-native-@io.native.version@.jar" diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index 92aa4d91..beb9ff42 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -117,12 +117,21 @@ task copyTestResources(type: Copy) { into("logger-from-config") { from "tests/resources/samples/logger/logger-from-config" } + into("logger-registry") { + from "tests/resources/samples/logger/logger-registry" + } into("masked-logger") { from "tests/resources/samples/masked-logger" } } task copyTestOutputResources(type: Copy) { + // Always re-copy so that APPEND-mode output files start from a known initial state + // on every test run, even without a clean build. + outputs.upToDateWhen { false } + doFirst { + delete "${project.projectDir}/build/tmp/output" + } into tmpDir into("output") { from "tests/resources/samples/file-write-output/output" diff --git a/integration-tests/tests/resources/samples/logger/custom-logger/main.bal b/integration-tests/tests/resources/samples/logger/custom-logger/main.bal index 23bd209a..091681e8 100644 --- a/integration-tests/tests/resources/samples/logger/custom-logger/main.bal +++ b/integration-tests/tests/resources/samples/logger/custom-logger/main.bal @@ -75,6 +75,14 @@ isolated class CustomLogger { return new CustomLogger(filePath = self.filePath, level = self.level, keyValues = newKeyValues.cloneReadOnly()); } + public isolated function getLevel() returns log:Level { + return self.level; + } + + public isolated function setLevel(log:Level level) returns error? { + return error("Unsupported operation: CustomLogger does not support runtime level changes."); + } + isolated function print(log:Level level, string|log:PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *log:KeyValues keyValues) { if !self.isLogLevelEnabled(level) { return; diff --git a/integration-tests/tests/resources/samples/logger/logger-registry/Ballerina.toml b/integration-tests/tests/resources/samples/logger/logger-registry/Ballerina.toml new file mode 100644 index 00000000..ffbc8fd8 --- /dev/null +++ b/integration-tests/tests/resources/samples/logger/logger-registry/Ballerina.toml @@ -0,0 +1,4 @@ +[package] +org = "myorg" +name = "registrytest" +version = "1.0.0" diff --git a/integration-tests/tests/resources/samples/logger/logger-registry/main.bal b/integration-tests/tests/resources/samples/logger/logger-registry/main.bal new file mode 100644 index 00000000..cd2aabc8 --- /dev/null +++ b/integration-tests/tests/resources/samples/logger/logger-registry/main.bal @@ -0,0 +1,101 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/io; +import ballerina/log; + +// Module-level logger with explicit ID — tests module prefix resolution during module init +log:Logger moduleLogger = check log:fromConfig(id = "audit-service", level = log:WARN); + +public function main() returns error? { + // 1. Verify module-level logger is registered with module prefix + log:Logger? moduleLookedUp = log:getLoggerRegistry().getById("myorg/registrytest:audit-service"); + if moduleLookedUp is log:Logger { + io:println("MODULE_LEVEL_ID:myorg/registrytest:audit-service"); + io:println("MODULE_LEVEL_LEVEL:" + moduleLookedUp.getLevel().toString()); + } else { + io:println("MODULE_LEVEL_ID:NOT_FOUND"); + io:println("MODULE_LEVEL_LEVEL:NOT_FOUND"); + } + + // 2. Create a logger with explicit ID inside main + log:Logger explicitLogger = check log:fromConfig(id = "payment-service", level = log:INFO); + io:println("EXPLICIT_ID:" + log:getLoggerRegistry().getIds().reduce( + isolated function(string acc, string id) returns string { + if id.includes("payment-service") { + return id; + } + return acc; + }, "NOT_FOUND")); + + // 2. Create a logger without ID (auto-generated) + log:Logger autoLogger = check log:fromConfig(level = log:DEBUG); + string[] allIds = log:getLoggerRegistry().getIds(); + boolean autoIdFound = false; + foreach string id in allIds { + if id.startsWith("myorg/registrytest:") + && id != "myorg/registrytest:audit-service" + && id != "myorg/registrytest:payment-service" { + autoIdFound = true; + break; + } + } + io:println("AUTO_ID_FOUND:" + autoIdFound.toString()); + + // 3. Check that registry has the global root logger + boolean hasRoot = false; + foreach string id in allIds { + if id == "root" { + hasRoot = true; + break; + } + } + io:println("REGISTRY_HAS_ROOT:" + hasRoot.toString()); + + // 4. Look up explicit logger by ID and print its level + log:Logger? lookedUp = log:getLoggerRegistry().getById("myorg/registrytest:payment-service"); + if lookedUp is log:Logger { + io:println("LOOKUP_LEVEL:" + lookedUp.getLevel().toString()); + } else { + io:println("LOOKUP_LEVEL:NOT_FOUND"); + } + + // 5. Create a child logger + log:Logger childLogger = check explicitLogger.withContext(component = "order"); + + // 6. Change parent level and verify child inherits + check explicitLogger.setLevel(log:DEBUG); + io:println("CHILD_INHERITS:" + childLogger.getLevel().toString()); + + // 7. Attempt setLevel on child — should return error + error? childSetResult = childLogger.setLevel(log:WARN); + if childSetResult is error { + io:println("CHILD_SET_ERROR:" + childSetResult.message()); + } else { + io:println("CHILD_SET_ERROR:NO_ERROR"); + } + + // 8. Verify child is NOT in registry + boolean childInRegistry = false; + string[] finalIds = log:getLoggerRegistry().getIds(); + foreach string id in finalIds { + if id.includes("order") { + childInRegistry = true; + break; + } + } + io:println("CHILD_NOT_IN_REGISTRY:" + (!childInRegistry).toString()); +} diff --git a/integration-tests/tests/tests_logger.bal b/integration-tests/tests/tests_logger.bal index a61faa26..579cf5cd 100644 --- a/integration-tests/tests/tests_logger.bal +++ b/integration-tests/tests/tests_logger.bal @@ -21,6 +21,7 @@ const CONFIG_ROOT_LOGGER = "tests/resources/config/json/root-logger/Config.toml" const CHILD_LOGGERS_SRC_FILE = "tests/resources/samples/logger/child-loggers/main.bal"; const CUSTOM_LOGGER_SRC_FILE = "tests/resources/samples/logger/custom-logger/main.bal"; const LOGGER_FROM_CONFIG_CONFIG_FILE = "tests/resources/samples/logger/logger-from-config/Config.toml"; +const LOGGER_REGISTRY_SRC_DIR = "logger-registry"; @test:Config { groups: ["logger"] @@ -125,3 +126,38 @@ function testCustomLogger() returns error? { test:assertTrue(fileDebugLogs[2].endsWith(string `] {WARN} "This is a warning message" mode="debug"`)); test:assertTrue(fileDebugLogs[3].endsWith(string `] {DEBUG} "This is a debug message" mode="debug"`)); } + +@test:Config { + groups: ["logger"] +} +function testLoggerRegistry() returns error? { + Process|error execResult = exec(bal_exec_path, {}, (), "run", string `${temp_dir_path}/${LOGGER_REGISTRY_SRC_DIR}`); + Process result = check execResult; + int _ = check result.waitForExit(); + int _ = check result.exitCode(); + io:ReadableByteChannel outStreamResult = result.stdout(); + io:ReadableCharacterChannel outCharStreamResult = new (outStreamResult, UTF_8); + string outText = check outCharStreamResult.read(100000); + check outCharStreamResult.close(); + + string[] outLines = re `\n`.split(outText.trim()); + map results = {}; + foreach string line in outLines { + int? colonIdx = line.indexOf(":"); + if colonIdx is int { + string key = line.substring(0, colonIdx).trim(); + string value = line.substring(colonIdx + 1).trim(); + results[key] = value; + } + } + + test:assertEquals(results["MODULE_LEVEL_ID"], "myorg/registrytest:audit-service"); + test:assertEquals(results["MODULE_LEVEL_LEVEL"], "WARN"); + test:assertEquals(results["EXPLICIT_ID"], "myorg/registrytest:payment-service"); + test:assertEquals(results["AUTO_ID_FOUND"], "true"); + test:assertEquals(results["REGISTRY_HAS_ROOT"], "true"); + test:assertEquals(results["LOOKUP_LEVEL"], "INFO"); + test:assertEquals(results["CHILD_INHERITS"], "DEBUG"); + test:assertTrue((results["CHILD_SET_ERROR"] ?: "").includes("Unsupported operation")); + test:assertEquals(results["CHILD_NOT_IN_REGISTRY"], "true"); +} diff --git a/native/src/main/java/io/ballerina/stdlib/log/LogConfigManager.java b/native/src/main/java/io/ballerina/stdlib/log/LogConfigManager.java new file mode 100644 index 00000000..a9889e16 --- /dev/null +++ b/native/src/main/java/io/ballerina/stdlib/log/LogConfigManager.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.ballerina.stdlib.log; + +import io.ballerina.runtime.api.utils.IdentifierUtils; +import io.ballerina.runtime.api.utils.StringUtils; +import io.ballerina.runtime.api.values.BString; + +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Provides logger ID generation and module-level log level storage. + * Module log levels are stored in a ConcurrentHashMap so reads on the hot logging + * path are lock-free (no Ballerina isolation lock required). + * + * @since 2.17.0 + */ +public class LogConfigManager { + + private static final LogConfigManager INSTANCE = new LogConfigManager(); + + // Per-function counters for auto-generated IDs: "module:function" -> counter + private final ConcurrentHashMap functionCounters = new ConcurrentHashMap<>(); + + // Module-level log level overrides: moduleName -> level string. + // ConcurrentHashMap gives lock-free reads on the hot logging path. + private final ConcurrentHashMap moduleLogLevels = new ConcurrentHashMap<>(); + + private LogConfigManager() { + } + + /** + * Get the singleton instance of LogConfigManager. + * + * @return the LogConfigManager instance + */ + public static LogConfigManager getInstance() { + return INSTANCE; + } + + /** + * Generate a readable logger ID based on the calling context. + * Format: "module:function-counter" (e.g., "myorg/payment:processOrder-1") + * + * @param stackOffset the number of stack frames to skip to reach the caller + * @return the generated logger ID + */ + String generateLoggerId(int stackOffset) { + StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + StackWalker.StackFrame callerFrame = walker.walk(frames -> + frames.skip(stackOffset).findFirst().orElse(null)); + + String modulePart = "unknown"; + String functionPart = "unknown"; + if (callerFrame != null) { + String className = callerFrame.getClassName(); + String methodName = callerFrame.getMethodName(); + // Extract module name from Ballerina class name convention. + // Typical format: "org.module_name.version.file" (e.g., "demo.log_level.0.main") + // We want: "org/module_name" (e.g., "demo/log_level") + // The version segment (e.g., "0") and file name should be stripped. + // Class naming convention: org.module(.submodule)*.version.file + // e.g. "myorg.myproject.0.main" -> myorg/myproject + // "myorg.myproject.foo.0.main" -> myorg/myproject.foo + // The version segment is the first all-numeric part after the org segment. + String[] parts = className.split("\\."); + if (parts.length == 2) { + modulePart = parts[0] + "/" + IdentifierUtils.decodeIdentifier(parts[1]); + } else if (parts.length > 2) { + // Find the version segment (first all-numeric segment starting from index 2) + int versionIdx = parts.length - 1; + for (int i = 2; i < parts.length; i++) { + if (parts[i].matches("\\d+")) { + versionIdx = i; + break; + } + } + // Module name is everything from parts[1] up to (not including) the version segment + String rawModule = String.join(".", Arrays.copyOfRange(parts, 1, versionIdx)); + modulePart = parts[0] + "/" + IdentifierUtils.decodeIdentifier(rawModule); + } else { + modulePart = className; + } + functionPart = methodName; + } + + String key = modulePart + ":" + functionPart; + AtomicLong counter = functionCounters.computeIfAbsent(key, k -> new AtomicLong(0)); + long count = counter.incrementAndGet(); + if (count == 1) { + return key; + } + return key + "-" + count; + } + + /** + * Generate a readable logger ID from Ballerina. + * + * @param stackOffset the number of stack frames to skip + * @return the generated logger ID + */ + public static BString generateLoggerId(long stackOffset) { + int safeOffset = stackOffset < 0 ? 0 + : (stackOffset > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) stackOffset); + return StringUtils.fromString(getInstance().generateLoggerId(safeOffset)); + } + + /** + * Return the configured log level for a module, or null if no override is set. + * Called on every log statement — no Ballerina isolation lock is acquired. + * + * @param moduleName the Ballerina module name (e.g. "myorg/payment") + * @return a BString level value, or null if no override is registered + */ + public static Object getModuleLevel(BString moduleName) { + String level = getInstance().moduleLogLevels.get(moduleName.getValue()); + return level != null ? StringUtils.fromString(level) : null; + } + + /** + * Register or update the log level override for a module. + * + * @param moduleName the Ballerina module name + * @param level the log level string (DEBUG, INFO, WARN, ERROR) + */ + public static void setModuleLevel(BString moduleName, BString level) { + getInstance().moduleLogLevels.put(moduleName.getValue(), level.getValue()); + } +}