Introduction to Loguru-style Logging
Loguru is a popular Python logging library known for its simplicity, powerful configuration, and beautiful output. This implementation brings Loguru's philosophy to Java with easy setup, structured logging, and rich features.
Core Logging Interface
Simplified Logger API
public class Loguru {
private static final Logger INSTANCE = new Logger();
private Loguru() {} // Prevent instantiation
public static Logger getLogger() {
return INSTANCE;
}
// Convenience methods for direct logging
public static void info(String message) {
INSTANCE.info(message);
}
public static void info(String format, Object... args) {
INSTANCE.info(format, args);
}
public static void debug(String message) {
INSTANCE.debug(message);
}
public static void warning(String message) {
INSTANCE.warning(message);
}
public static void error(String message) {
INSTANCE.error(message);
}
public static void error(Throwable throwable, String message) {
INSTANCE.error(throwable, message);
}
public static void success(String message) {
INSTANCE.success(message);
}
// Context management
public static void bind(String key, Object value) {
INSTANCE.bind(key, value);
}
public static void unbind(String key) {
INSTANCE.unbind(key);
}
public static Context context(String key, Object value) {
return INSTANCE.context(key, value);
}
public static Context context(Map<String, Object> context) {
return INSTANCE.context(context);
}
}
// Main Logger class
public class Logger {
private final List<LogHandler> handlers = new CopyOnWriteArrayList<>();
private final Map<String, Object> globalContext = new ConcurrentHashMap<>();
private final ThreadLocal<Map<String, Object>> threadContext =
ThreadLocal.withInitial(HashMap::new);
private Level minimumLevel = Level.INFO;
private boolean enableColors = true;
private DateTimeFormatter timeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
// Core logging methods
public void info(String message) {
log(Level.INFO, message);
}
public void info(String format, Object... args) {
log(Level.INFO, format, args);
}
public void debug(String message) {
log(Level.DEBUG, message);
}
public void warning(String message) {
log(Level.WARNING, message);
}
public void error(String message) {
log(Level.ERROR, message);
}
public void error(Throwable throwable, String message) {
log(Level.ERROR, throwable, message);
}
public void success(String message) {
log(Level.SUCCESS, message);
}
public void log(Level level, String message) {
if (shouldLog(level)) {
LogRecord record = buildLogRecord(level, message, null);
handleLogRecord(record);
}
}
public void log(Level level, String format, Object... args) {
if (shouldLog(level)) {
String message = String.format(format, args);
LogRecord record = buildLogRecord(level, message, null);
handleLogRecord(record);
}
}
public void log(Level level, Throwable throwable, String message) {
if (shouldLog(level)) {
LogRecord record = buildLogRecord(level, message, throwable);
handleLogRecord(record);
}
}
// Context management
public void bind(String key, Object value) {
threadContext.get().put(key, value);
}
public void unbind(String key) {
threadContext.get().remove(key);
}
public Context context(String key, Object value) {
return new Context(this).bind(key, value);
}
public Context context(Map<String, Object> context) {
return new Context(this).bind(context);
}
public void addGlobalContext(String key, Object value) {
globalContext.put(key, value);
}
// Handler management
public void addHandler(LogHandler handler) {
handlers.add(handler);
}
public void removeHandler(LogHandler handler) {
handlers.remove(handler);
}
public void clearHandlers() {
handlers.clear();
}
// Configuration
public void setLevel(Level level) {
this.minimumLevel = level;
}
public void enableColors(boolean enable) {
this.enableColors = enable;
}
public void setTimeFormat(String pattern) {
this.timeFormatter = DateTimeFormatter.ofPattern(pattern);
}
// Internal implementation
private boolean shouldLog(Level level) {
return level.ordinal() >= minimumLevel.ordinal();
}
private LogRecord buildLogRecord(Level level, String message, Throwable throwable) {
Map<String, Object> combinedContext = new HashMap<>();
combinedContext.putAll(globalContext);
combinedContext.putAll(threadContext.get());
return new LogRecord(
Instant.now(),
level,
Thread.currentThread().getName(),
Thread.currentThread().getId(),
message,
throwable,
Collections.unmodifiableMap(combinedContext)
);
}
private void handleLogRecord(LogRecord record) {
for (LogHandler handler : handlers) {
try {
handler.handle(record);
} catch (Exception e) {
// Don't let handler exceptions break logging
System.err.println("Log handler failed: " + e.getMessage());
}
}
}
}
Data Models and Enums
Log Levels and Records
public enum Level {
TRACE(0, "TRACE", AnsiColor.CYAN),
DEBUG(1, "DEBUG", AnsiColor.BLUE),
INFO(2, "INFO", AnsiColor.GREEN),
SUCCESS(3, "SUCCESS", AnsiColor.BRIGHT_GREEN),
WARNING(4, "WARNING", AnsiColor.YELLOW),
ERROR(5, "ERROR", AnsiColor.RED),
CRITICAL(6, "CRITICAL", AnsiColor.BRIGHT_RED);
private final int severity;
private final String name;
private final AnsiColor color;
Level(int severity, String name, AnsiColor color) {
this.severity = severity;
this.name = name;
this.color = color;
}
public int getSeverity() { return severity; }
public String getName() { return name; }
public AnsiColor getColor() { return color; }
}
public class LogRecord {
private final Instant timestamp;
private final Level level;
private final String threadName;
private final long threadId;
private final String message;
private final Throwable throwable;
private final Map<String, Object> context;
public LogRecord(Instant timestamp, Level level, String threadName,
long threadId, String message, Throwable throwable,
Map<String, Object> context) {
this.timestamp = timestamp;
this.level = level;
this.threadName = threadName;
this.threadId = threadId;
this.message = message;
this.throwable = throwable;
this.context = context;
}
// Getters
public Instant getTimestamp() { return timestamp; }
public Level getLevel() { return level; }
public String getThreadName() { return threadName; }
public long getThreadId() { return threadId; }
public String getMessage() { return message; }
public Throwable getThrowable() { return throwable; }
public Map<String, Object> getContext() { return context; }
}
// Context wrapper for fluent API
public class Context implements AutoCloseable {
private final Logger logger;
private final Map<String, Object> localBindings = new HashMap<>();
public Context(Logger logger) {
this.logger = logger;
}
public Context bind(String key, Object value) {
localBindings.put(key, value);
logger.bind(key, value);
return this;
}
public Context bind(Map<String, Object> context) {
localBindings.putAll(context);
context.forEach(logger::bind);
return this;
}
@Override
public void close() {
// Remove bindings when context goes out of scope
localBindings.keySet().forEach(logger::unbind);
}
}
Handlers and Formatters
Console Handler with Colors
public interface LogHandler {
void handle(LogRecord record);
void setFormatter(LogFormatter formatter);
void close();
}
public class ConsoleHandler implements LogHandler {
private LogFormatter formatter = new DefaultFormatter();
private PrintStream output = System.out;
private PrintStream errorOutput = System.err;
@Override
public void handle(LogRecord record) {
String formatted = formatter.format(record);
if (record.getLevel().getSeverity() >= Level.ERROR.getSeverity()) {
errorOutput.println(formatted);
} else {
output.println(formatted);
}
// Print exception stack trace if present
if (record.getThrowable() != null) {
record.getThrowable().printStackTrace(
record.getLevel().getSeverity() >= Level.ERROR.getSeverity() ?
errorOutput : output);
}
}
@Override
public void setFormatter(LogFormatter formatter) {
this.formatter = formatter;
}
@Override
public void close() {
// Nothing to close for console
}
public void setOutput(PrintStream output) {
this.output = output;
}
public void setErrorOutput(PrintStream errorOutput) {
this.errorOutput = errorOutput;
}
}
// File handler with rotation
public class FileHandler implements LogHandler {
private final Path logPath;
private final long maxFileSize;
private final int backupCount;
private final boolean compressBackups;
private LogFormatter formatter = new DefaultFormatter();
private Writer currentWriter;
private long currentFileSize;
public FileHandler(String filePath) {
this(filePath, 10 * 1024 * 1024, 5, true); // 10MB, 5 backups, compress
}
public FileHandler(String filePath, long maxFileSize, int backupCount, boolean compressBackups) {
this.logPath = Paths.get(filePath);
this.maxFileSize = maxFileSize;
this.backupCount = backupCount;
this.compressBackups = compressBackups;
initializeWriter();
}
private void initializeWriter() {
try {
Files.createDirectories(logPath.getParent());
currentWriter = Files.newBufferedWriter(logPath,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
currentFileSize = Files.size(logPath);
} catch (IOException e) {
throw new LoggingException("Failed to initialize file handler", e);
}
}
@Override
public void handle(LogRecord record) {
try {
String formatted = formatter.format(record) + System.lineSeparator();
byte[] bytes = formatted.getBytes(StandardCharsets.UTF_8);
// Check if we need to rotate
if (currentFileSize + bytes.length > maxFileSize) {
rotateFiles();
}
currentWriter.write(formatted);
currentWriter.flush();
currentFileSize += bytes.length;
} catch (IOException e) {
throw new LoggingException("Failed to write log record", e);
}
}
private void rotateFiles() throws IOException {
currentWriter.close();
// Rotate existing backup files
for (int i = backupCount - 1; i >= 0; i--) {
Path source = getBackupPath(i);
Path target = getBackupPath(i + 1);
if (Files.exists(source)) {
if (i + 1 >= backupCount) {
Files.delete(source); // Remove oldest backup
} else {
Files.move(source, target);
// Compress if enabled
if (compressBackups && i == 0) {
compressFile(target);
}
}
}
}
// Move current to backup 0
Path firstBackup = getBackupPath(0);
Files.move(logPath, firstBackup);
initializeWriter();
}
private Path getBackupPath(int backupIndex) {
if (backupIndex == 0) {
return logPath.resolveSibling(
logPath.getFileName() + ".1");
}
return logPath.resolveSibling(
logPath.getFileName() + "." + backupIndex);
}
private void compressFile(Path file) throws IOException {
Path compressedFile = file.resolveSibling(file.getFileName() + ".gz");
try (GZIPOutputStream gzos = new GZIPOutputStream(
Files.newOutputStream(compressedFile));
InputStream is = Files.newInputStream(file)) {
is.transferTo(gzos);
}
Files.delete(file);
}
@Override
public void setFormatter(LogFormatter formatter) {
this.formatter = formatter;
}
@Override
public void close() {
try {
if (currentWriter != null) {
currentWriter.close();
}
} catch (IOException e) {
// Ignore close errors
}
}
}
Formatters
public interface LogFormatter {
String format(LogRecord record);
}
public class DefaultFormatter implements LogFormatter {
private boolean enableColors = true;
private boolean showThread = true;
private boolean showContext = true;
@Override
public String format(LogRecord record) {
StringBuilder sb = new StringBuilder();
// Timestamp
String timestamp = LocalDateTime.ofInstant(record.getTimestamp(), ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
sb.append(timestamp).append(" | ");
// Level with color
String levelName = record.getLevel().getName();
if (enableColors && record.getLevel().getColor() != null) {
levelName = record.getLevel().getColor().colorize(
String.format("%-8s", levelName));
} else {
levelName = String.format("%-8s", levelName);
}
sb.append(levelName).append(" | ");
// Thread info
if (showThread) {
sb.append(record.getThreadName())
.append("(").append(record.getThreadId()).append(") | ");
}
// Message
sb.append(record.getMessage());
// Context
if (showContext && !record.getContext().isEmpty()) {
sb.append(" {");
record.getContext().forEach((key, value) -> {
sb.append(key).append("=").append(value).append(", ");
});
sb.setLength(sb.length() - 2); // Remove trailing comma
sb.append("}");
}
return sb.toString();
}
public void enableColors(boolean enable) {
this.enableColors = enable;
}
public void showThread(boolean show) {
this.showThread = show;
}
public void showContext(boolean show) {
this.showContext = show;
}
}
// JSON formatter for structured logging
public class JsonFormatter implements LogFormatter {
private final ObjectMapper mapper;
private boolean prettyPrint = false;
public JsonFormatter() {
this.mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
@Override
public String format(LogRecord record) {
try {
Map<String, Object> logEntry = new LinkedHashMap<>();
logEntry.put("timestamp", record.getTimestamp().toString());
logEntry.put("level", record.getLevel().name());
logEntry.put("thread", record.getThreadName());
logEntry.put("threadId", record.getThreadId());
logEntry.put("message", record.getMessage());
if (record.getThrowable() != null) {
logEntry.put("exception", buildExceptionInfo(record.getThrowable()));
}
if (!record.getContext().isEmpty()) {
logEntry.put("context", record.getContext());
}
if (prettyPrint) {
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(logEntry);
} else {
return mapper.writeValueAsString(logEntry);
}
} catch (JsonProcessingException e) {
return "{\"error\": \"Failed to format log entry\"}";
}
}
private Map<String, Object> buildExceptionInfo(Throwable throwable) {
Map<String, Object> exceptionInfo = new HashMap<>();
exceptionInfo.put("type", throwable.getClass().getName());
exceptionInfo.put("message", throwable.getMessage());
// Include stack trace
StringWriter sw = new StringWriter();
throwable.printStackTrace(new PrintWriter(sw));
exceptionInfo.put("stackTrace", sw.toString().split(System.lineSeparator()));
if (throwable.getCause() != null) {
exceptionInfo.put("cause", buildExceptionInfo(throwable.getCause()));
}
return exceptionInfo;
}
public void setPrettyPrint(boolean prettyPrint) {
this.prettyPrint = prettyPrint;
}
}
ANSI Color Support
public enum AnsiColor {
BLACK("\u001B[30m"),
RED("\u001B[31m"),
GREEN("\u001B[32m"),
YELLOW("\u001B[33m"),
BLUE("\u001B[34m"),
MAGENTA("\u001B[35m"),
CYAN("\u001B[36m"),
WHITE("\u001B[37m"),
BRIGHT_BLACK("\u001B[90m"),
BRIGHT_RED("\u001B[91m"),
BRIGHT_GREEN("\u001B[92m"),
BRIGHT_YELLOW("\u001B[93m"),
BRIGHT_BLUE("\u001B[94m"),
BRIGHT_MAGENTA("\u001B[95m"),
BRIGHT_CYAN("\u001B[96m"),
BRIGHT_WHITE("\u001B[97m"),
RESET("\u001B[0m");
private final String code;
AnsiColor(String code) {
this.code = code;
}
public String colorize(String text) {
return code + text + RESET.code;
}
public static boolean isColorSupported() {
return System.console() != null &&
!System.getProperty("os.name").toLowerCase().contains("win");
}
}
Spring Boot Integration
Auto-Configuration
@Configuration
@EnableConfigurationProperties(LoguruProperties.class)
public class LoguruAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public Logger loguruLogger(LoguruProperties properties) {
Logger logger = Loguru.getLogger();
// Configure based on properties
logger.setLevel(Level.valueOf(properties.getLevel().toUpperCase()));
logger.enableColors(properties.isEnableColors());
// Add handlers
if (properties.getConsole().isEnabled()) {
ConsoleHandler consoleHandler = new ConsoleHandler();
if (properties.getConsole().isJsonFormat()) {
consoleHandler.setFormatter(new JsonFormatter());
}
logger.addHandler(consoleHandler);
}
if (properties.getFile().isEnabled()) {
FileHandler fileHandler = new FileHandler(
properties.getFile().getPath(),
properties.getFile().getMaxSize(),
properties.getFile().getBackupCount(),
properties.getFile().isCompress()
);
if (properties.getFile().isJsonFormat()) {
fileHandler.setFormatter(new JsonFormatter());
}
logger.addHandler(fileHandler);
}
return logger;
}
@Bean
public LoguruHealthIndicator loguruHealthIndicator() {
return new LoguruHealthIndicator();
}
}
@ConfigurationProperties(prefix = "loguru")
public class LoguruProperties {
private String level = "INFO";
private boolean enableColors = true;
private Console console = new Console();
private File file = new File();
public static class Console {
private boolean enabled = true;
private boolean jsonFormat = false;
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isJsonFormat() { return jsonFormat; }
public void setJsonFormat(boolean jsonFormat) { this.jsonFormat = jsonFormat; }
}
public static class File {
private boolean enabled = false;
private String path = "logs/app.log";
private long maxSize = 10 * 1024 * 1024; // 10MB
private int backupCount = 5;
private boolean compress = true;
private boolean jsonFormat = false;
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public long getMaxSize() { return maxSize; }
public void setMaxSize(long maxSize) { this.maxSize = maxSize; }
public int getBackupCount() { return backupCount; }
public void setBackupCount(int backupCount) { this.backupCount = backupCount; }
public boolean isCompress() { return compress; }
public void setCompress(boolean compress) { this.compress = compress; }
public boolean isJsonFormat() { return jsonFormat; }
public void setJsonFormat(boolean jsonFormat) { this.jsonFormat = jsonFormat; }
}
// Getters and setters
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public boolean isEnableColors() { return enableColors; }
public void setEnableColors(boolean enableColors) { this.enableColors = enableColors; }
public Console getConsole() { return console; }
public void setConsole(Console console) { this.console = console; }
public File getFile() { return file; }
public void setFile(File file) { this.file = file; }
}
Usage Examples
Basic Logging
public class OrderService {
public void processOrder(Order order) {
// Simple logging
Loguru.info("Processing order {}", order.getId());
try {
// Log with context
try (Context ctx = Loguru.context("order_id", order.getId())
.context("customer", order.getCustomerId())) {
validateOrder(order);
processPayment(order);
updateInventory(order);
Loguru.success("Order {} processed successfully", order.getId());
}
} catch (Exception e) {
Loguru.error(e, "Failed to process order {}", order.getId());
throw e;
}
}
public void batchProcess(List<Order> orders) {
Loguru.info("Starting batch processing of {} orders", orders.size());
long startTime = System.currentTimeMillis();
orders.forEach(order -> {
// Add per-order context
Loguru.bind("order_id", order.getId());
Loguru.bind("order_amount", order.getAmount());
processOrder(order);
// Remove context
Loguru.unbind("order_id");
Loguru.unbind("order_amount");
});
long duration = System.currentTimeMillis() - startTime;
Loguru.info("Batch processing completed in {} ms", duration);
}
}
Advanced Features
public class AdvancedLoggingExamples {
public void demonstrateFeatures() {
// 1. Conditional logging
Loguru.debug("Debug information only visible in development");
// 2. Structured logging with JSON
Map<String, Object> businessContext = new HashMap<>();
businessContext.put("service", "payment");
businessContext.put("transaction_id", UUID.randomUUID().toString());
businessContext.put("amount", 99.99);
try (Context ctx = Loguru.context(businessContext)) {
Loguru.info("Payment processed");
}
// 3. Exception handling with rich context
try {
riskyOperation();
} catch (Exception e) {
Loguru.error(e, "Operation failed with user: {}", getCurrentUser());
}
// 4. Performance logging
long start = System.nanoTime();
expensiveOperation();
long duration = System.nanoTime() - start;
Loguru.info("Expensive operation took {} ns", duration);
}
// Custom handler for metrics
public class MetricsHandler implements LogHandler {
private final MeterRegistry meterRegistry;
public MetricsHandler(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public void handle(LogRecord record) {
// Count log messages by level
Counter.builder("log.messages")
.tag("level", record.getLevel().name())
.register(meterRegistry)
.increment();
// Record error occurrences
if (record.getLevel() == Level.ERROR) {
Counter.builder("errors.total")
.register(meterRegistry)
.increment();
}
}
@Override
public void setFormatter(LogFormatter formatter) {
// Not used for metrics
}
@Override
public void close() {
// Cleanup if needed
}
}
}
Testing Support
public class TestLogCapture implements LogHandler {
private final List<LogRecord> capturedRecords = new CopyOnWriteArrayList<>();
private final Level captureLevel;
public TestLogCapture() {
this(Level.DEBUG); // Capture all levels by default
}
public TestLogCapture(Level captureLevel) {
this.captureLevel = captureLevel;
}
@Override
public void handle(LogRecord record) {
if (record.getLevel().ordinal() >= captureLevel.ordinal()) {
capturedRecords.add(record);
}
}
public List<LogRecord> getCapturedRecords() {
return Collections.unmodifiableList(capturedRecords);
}
public void clear() {
capturedRecords.clear();
}
public boolean containsMessage(String message) {
return capturedRecords.stream()
.anyMatch(record -> record.getMessage().contains(message));
}
public boolean containsLevel(Level level) {
return capturedRecords.stream()
.anyMatch(record -> record.getLevel() == level);
}
@Override
public void setFormatter(LogFormatter formatter) {
// Not used for testing
}
@Override
public void close() {
clear();
}
}
@ExtendWith(LogCaptureExtension.class)
public class LoguruTest {
@Test
public void testLoggingCapture(TestLogCapture logCapture) {
Loguru.info("Test message");
Loguru.error(new RuntimeException("Test error"), "Error occurred");
assertTrue(logCapture.containsMessage("Test message"));
assertTrue(logCapture.containsLevel(Level.ERROR));
assertEquals(2, logCapture.getCapturedRecords().size());
}
}
Configuration Examples
application.yml
loguru: level: DEBUG enable-colors: true console: enabled: true json-format: false file: enabled: true path: "logs/application.log" max-size: 10MB backup-count: 5 compress: true json-format: true
Programmatic Configuration
public class LoguruConfig {
public static void configure() {
Logger logger = Loguru.getLogger();
// Set minimum level
logger.setLevel(Level.DEBUG);
// Console handler with colors
ConsoleHandler consoleHandler = new ConsoleHandler();
DefaultFormatter consoleFormatter = new DefaultFormatter();
consoleFormatter.enableColors(true);
consoleHandler.setFormatter(consoleFormatter);
logger.addHandler(consoleHandler);
// File handler with JSON
FileHandler fileHandler = new FileHandler("logs/app.json.log");
fileHandler.setFormatter(new JsonFormatter());
logger.addHandler(fileHandler);
// Add global context
logger.addGlobalContext("application", "my-service");
logger.addGlobalContext("version", "1.0.0");
logger.addGlobalContext("environment", System.getenv().getOrDefault("ENV", "development"));
}
}
Maven Dependencies
<dependencies> <!-- Spring Boot (optional) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <optional>true</optional> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.0</version> </dependency> <!-- Testing --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.0</version> <scope>test</scope> </dependency> </dependencies>
Conclusion
This Loguru-inspired Java logging library provides:
- Simple API - Easy-to-use static methods and fluent interface
- Powerful Context - Thread-local and request-scoped context management
- Beautiful Output - Colored console output with customizable formats
- Structured Logging - JSON support for machine-readable logs
- Flexible Handlers - Console, file, and custom handlers with rotation
- Spring Integration - Auto-configuration and health checks
- Testing Support - Log capture for unit testing
The library maintains the philosophy of Loguru while leveraging Java's type safety and ecosystem integration capabilities.