Article
Serilog from .NET revolutionized logging with its focus on structured logging, rich event data, and flexible sinks. While Java has excellent logging frameworks like Logback and Log4j2, we can implement Serilog's elegant approach to create more meaningful and queryable logs.
This guide shows how to bring Serilog's capabilities to Java with custom implementations and existing libraries.
Why Serilog-Style Logging?
- Structured Logging: Log events as structured data rather than plain text
- Property Bag Model: Attach rich properties to log events
- Powerful Enrichment: Add context automatically
- Flexible Outputs: Format logs as JSON, text, or send to various destinations
- Excellent Queryability: Easy to search and analyze in systems like Elasticsearch
Project Setup and Dependencies
Maven Dependencies:
<properties> <logback.version>1.4.11</logback.version> <logstash.version>7.4</logstash.version> <jackson.version>2.15.2</jackson.version> </properties> <dependencies> <!-- Logback Core --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>${logback.version}</version> </dependency> <!-- Logstash Logback Encoder for JSON --> <dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>${logstash.version}</version> </dependency> <!-- Jackson for JSON --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency> </dependencies> 1. Core Serilog-Style Logger
Logger Interface (Serilog-style):
package com.example.serilog; import java.util.Map; public interface SerilogLogger { // Basic log methods with message template void verbose(String messageTemplate, Object... propertyValues); void debug(String messageTemplate, Object... propertyValues); void information(String messageTemplate, Object... propertyValues); void warning(String messageTemplate, Object... propertyValues); void error(String messageTemplate, Object... propertyValues); void fatal(String messageTemplate, Object... propertyValues); // Methods with exception void warning(Throwable exception, String messageTemplate, Object... propertyValues); void error(Throwable exception, String messageTemplate, Object... propertyValues); void fatal(Throwable exception, String messageTemplate, Object... propertyValues); // Methods with explicit properties void information(String messageTemplate, Map<String, Object> properties, Object... propertyValues); void error(Throwable exception, String messageTemplate, Map<String, Object> properties, Object... propertyValues); // ForContext methods for creating enriched loggers SerilogLogger forContext(String propertyName, Object propertyValue); SerilogLogger forContext(Class<?> sourceType); // Begin a timed operation LogOperation beginTimedOperation(String operationName); } Log Operation for Timing:
package com.example.serilog; public interface LogOperation extends AutoCloseable { void complete(); void complete(String outcome); void abandon(); void setOutcome(String outcome); @Override void close(); // Auto-complete on close } 2. Implementation Using Logback
Logback-based Serilog Logger:
package com.example.serilog.core; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import com.example.serilog.LogOperation; import com.example.serilog.SerilogLogger; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class LogbackSerilogLogger implements SerilogLogger { private final Logger logger; private final Map<String, Object> contextProperties; private final ObjectMapper objectMapper; public LogbackSerilogLogger(Class<?> clazz) { this((Logger) LoggerFactory.getLogger(clazz)); } public LogbackSerilogLogger(String name) { this((Logger) LoggerFactory.getLogger(name)); } public LogbackSerilogLogger(Logger logger) { this.logger = logger; this.contextProperties = new ConcurrentHashMap<>(); this.objectMapper = new ObjectMapper(); } private LogbackSerilogLogger(Logger logger, Map<String, Object> contextProperties) { this.logger = logger; this.contextProperties = new ConcurrentHashMap<>(contextProperties); this.objectMapper = new ObjectMapper(); } @Override public void verbose(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.VERBOSE, null, messageTemplate, Map.of(), propertyValues); } @Override public void debug(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.DEBUG, null, messageTemplate, Map.of(), propertyValues); } @Override public void information(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.INFORMATION, null, messageTemplate, Map.of(), propertyValues); } @Override public void warning(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.WARNING, null, messageTemplate, Map.of(), propertyValues); } @Override public void error(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.ERROR, null, messageTemplate, Map.of(), propertyValues); } @Override public void fatal(String messageTemplate, Object... propertyValues) { logInternal(LogLevel.FATAL, null, messageTemplate, Map.of(), propertyValues); } @Override public void warning(Throwable exception, String messageTemplate, Object... propertyValues) { logInternal(LogLevel.WARNING, exception, messageTemplate, Map.of(), propertyValues); } @Override public void error(Throwable exception, String messageTemplate, Object... propertyValues) { logInternal(LogLevel.ERROR, exception, messageTemplate, Map.of(), propertyValues); } @Override public void fatal(Throwable exception, String messageTemplate, Object... propertyValues) { logInternal(LogLevel.FATAL, exception, messageTemplate, Map.of(), propertyValues); } @Override public void information(String messageTemplate, Map<String, Object> properties, Object... propertyValues) { logInternal(LogLevel.INFORMATION, null, messageTemplate, properties, propertyValues); } @Override public void error(Throwable exception, String messageTemplate, Map<String, Object> properties, Object... propertyValues) { logInternal(LogLevel.ERROR, exception, messageTemplate, properties, propertyValues); } @Override public SerilogLogger forContext(String propertyName, Object propertyValue) { Map<String, Object> newProperties = new HashMap<>(this.contextProperties); newProperties.put(propertyName, propertyValue); return new LogbackSerilogLogger(logger, newProperties); } @Override public SerilogLogger forContext(Class<?> sourceType) { return forContext("SourceContext", sourceType.getName()); } @Override public LogOperation beginTimedOperation(String operationName) { return new TimedLogOperation(this, operationName); } private void logInternal(LogLevel level, Throwable exception, String messageTemplate, Map<String, Object> additionalProperties, Object... propertyValues) { // Parse message template and extract named properties LogEvent logEvent = parseMessageTemplate(messageTemplate, propertyValues); // Merge all properties: context + additional + template properties Map<String, Object> allProperties = new HashMap<>(); allProperties.putAll(contextProperties); allProperties.putAll(additionalProperties); allProperties.putAll(logEvent.getProperties()); // Set properties in MDC for logback setMDCProperties(allProperties); // Log using SLF4J String message = logEvent.getMessage(); switch (level) { case VERBOSE: logger.trace(message, exception); break; case DEBUG: logger.debug(message, exception); break; case INFORMATION: logger.info(message, exception); break; case WARNING: logger.warn(message, exception); break; case ERROR: logger.error(message, exception); break; case FATAL: logger.error(message, exception); // SLF4J doesn't have fatal, use error break; } // Clear MDC properties clearMDCProperties(allProperties); } private LogEvent parseMessageTemplate(String template, Object[] propertyValues) { // Simple implementation - in real scenario, use proper template parser StringBuilder message = new StringBuilder(); Map<String, Object> properties = new HashMap<>(); int paramIndex = 0; int lastIndex = 0; int startBrace = template.indexOf('{'); while (startBrace != -1 && paramIndex < propertyValues.length) { int endBrace = template.indexOf('}', startBrace); if (endBrace == -1) break; // Add text before the brace message.append(template, lastIndex, startBrace); String propertyName = template.substring(startBrace + 1, endBrace).trim(); Object propertyValue = propertyValues[paramIndex]; // Add to properties map properties.put(propertyName, propertyValue); // Add to message as formatted value message.append(propertyValue); paramIndex++; lastIndex = endBrace + 1; startBrace = template.indexOf('{', lastIndex); } // Add remaining text if (lastIndex < template.length()) { message.append(template.substring(lastIndex)); } return new LogEvent(message.toString(), properties); } private void setMDCProperties(Map<String, Object> properties) { properties.forEach((key, value) -> { try { String jsonValue = objectMapper.writeValueAsString(value); MDC.put(key, jsonValue); } catch (JsonProcessingException e) { MDC.put(key, String.valueOf(value)); } }); } private void clearMDCProperties(Map<String, Object> properties) { properties.keySet().forEach(MDC::remove); } // Supporting classes private enum LogLevel { VERBOSE, DEBUG, INFORMATION, WARNING, ERROR, FATAL } private static class LogEvent { private final String message; private final Map<String, Object> properties; public LogEvent(String message, Map<String, Object> properties) { this.message = message; this.properties = properties; } public String getMessage() { return message; } public Map<String, Object> getProperties() { return properties; } } } 3. Timed Operation Implementation
Timed Log Operation:
package com.example.serilog.core; import com.example.serilog.LogOperation; import com.example.serilog.SerilogLogger; import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Map; public class TimedLogOperation implements LogOperation { private final SerilogLogger logger; private final String operationName; private final Instant startTime; private String outcome; private boolean completed = false; public TimedLogOperation(SerilogLogger logger, String operationName) { this.logger = logger; this.operationName = operationName; this.startTime = Instant.now(); // Log start of operation logger.information("Operation {OperationName} started", Map.of("OperationName", operationName, "OperationId", System.identityHashCode(this))); } @Override public void complete() { complete("Completed"); } @Override public void complete(String outcome) { if (!completed) { this.outcome = outcome; completed = true; logCompletion(); } } @Override public void abandon() { if (!completed) { this.outcome = "Abandoned"; completed = true; logCompletion(); } } @Override public void setOutcome(String outcome) { this.outcome = outcome; } @Override public void close() { if (!completed) { complete("Completed"); } } private void logCompletion() { Duration duration = Duration.between(startTime, Instant.now()); Map<String, Object> properties = new HashMap<>(); properties.put("OperationName", operationName); properties.put("OperationId", System.identityHashCode(this)); properties.put("DurationMs", duration.toMillis()); properties.put("Outcome", outcome); logger.information("Operation {OperationName} {Outcome} in {DurationMs}ms", properties, operationName, outcome, duration.toMillis()); } } 4. Logger Configuration and Factory
Logger Factory:
package com.example.serilog; import com.example.serilog.core.LogbackSerilogLogger; public class Serilog { private Serilog() {} // Static factory public static SerilogLogger getLogger(Class<?> clazz) { return new LogbackSerilogLogger(clazz); } public static SerilogLogger getLogger(String name) { return new LogbackSerilogLogger(name); } // Static convenience methods public static void verbose(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).verbose(messageTemplate, propertyValues); } public static void debug(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).debug(messageTemplate, propertyValues); } public static void information(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).information(messageTemplate, propertyValues); } public static void warning(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).warning(messageTemplate, propertyValues); } public static void error(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).error(messageTemplate, propertyValues); } public static void fatal(String messageTemplate, Object... propertyValues) { getLogger(Serilog.class).fatal(messageTemplate, propertyValues); } } 5. Logback Configuration for JSON Output
logback.xml for Structured JSON Logging:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- Console Appender with JSON Layout --> <appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <providers> <timestamp> <timeZone>UTC</timeZone> </timestamp> <logLevel/> <loggerName/> <message/> <threadName/> <contextMap/> <mdc/> <stackTrace> <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter"> <maxDepthPerThrowable>30</maxDepthPerThrowable> <maxLength>2048</maxLength> <shortenedClassNameLength>20</shortenedClassNameLength> <rootCauseFirst>true</rootCauseFirst> </throwableConverter> </stackTrace> </providers> </encoder> </appender> <!-- Console Appender with Pattern Layout (Human Readable) --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- File Appender with JSON --> <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/application.json</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/application.%d{yyyy-MM-dd}.json</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <providers> <timestamp> <timeZone>UTC</timeZone> </timestamp> <logLevel/> <loggerName/> <message/> <threadName/> <mdc/> <stackTrace/> </providers> </encoder> </appender> <!-- Async Appender for Better Performance --> <appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="JSON_FILE" /> <queueSize>10000</queueSize> <discardingThreshold>0</discardingThreshold> <includeCallerData>false</includeCallerData> </appender> <root level="INFO"> <appender-ref ref="JSON_CONSOLE" /> <appender-ref ref="ASYNC_JSON" /> </root> <!-- Specific logger for our application --> <logger name="com.example" level="DEBUG" additivity="false"> <appender-ref ref="JSON_CONSOLE" /> <appender-ref ref="ASYNC_JSON" /> </logger> </configuration> 6. Usage Examples
Basic Usage:
package com.example.service; import com.example.serilog.Serilog; import com.example.serilog.SerilogLogger; public class UserService { private static final SerilogLogger log = Serilog.getLogger(UserService.class); public User getUser(String userId) { log.information("Getting user {UserId} from database", userId); try { User user = userRepository.findById(userId); log.information("Successfully retrieved user {UserId} with name {UserName}", userId, user.getName()); return user; } catch (Exception ex) { log.error(ex, "Failed to retrieve user {UserId}", userId); throw ex; } } } Advanced Usage with Context and Operations:
package com.example.service; import com.example.serilog.Serilog; import com.example.serilog.SerilogLogger; import com.example.serilog.LogOperation; public class OrderProcessingService { private final SerilogLogger log; public OrderProcessingService() { this.log = Serilog.getLogger(OrderProcessingService.class) .forContext("Service", "OrderProcessing") .forContext("Version", "1.0.0"); } public void processOrder(Order order) { // Create a logger with order context SerilogLogger orderLog = log.forContext("OrderId", order.getId()) .forContext("CustomerId", order.getCustomerId()); try (LogOperation operation = orderLog.beginTimedOperation("ProcessOrder")) { orderLog.information("Starting order processing for {OrderTotal:C} order", Map.of("OrderTotal", order.getTotal()), order.getTotal()); validateOrder(order); processPayment(order); fulfillOrder(order); operation.complete("Success"); orderLog.information("Order processed successfully"); } catch (Exception ex) { log.error(ex, "Order processing failed for order {OrderId}", order.getId()); throw ex; } } private void processPayment(Order order) { Map<String, Object> properties = Map.of( "PaymentAmount", order.getTotal(), "PaymentMethod", order.getPaymentMethod(), "Currency", "USD" ); log.information("Processing payment of {PaymentAmount} {Currency} via {PaymentMethod}", properties, order.getTotal(), "USD", order.getPaymentMethod()); // Payment processing logic... } } Web Controller Example:
package com.example.web; import com.example.serilog.Serilog; import com.example.serilog.SerilogLogger; import com.example.serilog.LogOperation; import org.springframework.web.bind.annotation.*; @RestController public class UserController { private static final SerilogLogger log = Serilog.getLogger(UserController.class); @GetMapping("/users/{id}") public User getUser(@PathVariable String id, @RequestHeader("User-Agent") String userAgent) { // Create request-scoped logger SerilogLogger requestLog = log.forContext("RequestId", generateRequestId()) .forContext("UserAgent", userAgent) .forContext("UserId", id); try (LogOperation operation = requestLog.beginTimedOperation("GetUser")) { requestLog.information("HTTP GET /users/{UserId}"); User user = userService.getUser(id); requestLog.information("User retrieved: {UserName} ({UserEmail})", user.getName(), user.getEmail()); operation.complete("Success"); return user; } } private String generateRequestId() { return java.util.UUID.randomUUID().toString(); } } 7. Custom Enrichers (Serilog-style)
Enricher Interface:
package com.example.serilog.enrichers; import java.util.Map; public interface LogEnricher { Map<String, Object> enrich(); } Common Enrichers:
package com.example.serilog.enrichers; import java.lang.management.ManagementFactory; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.HashMap; import java.util.Map; public class EnvironmentEnricher implements LogEnricher { @Override public Map<String, Object> enrich() { Map<String, Object> properties = new HashMap<>(); try { properties.put("Hostname", InetAddress.getLocalHost().getHostName()); properties.put("IPAddress", InetAddress.getLocalHost().getHostAddress()); } catch (UnknownHostException e) { properties.put("Hostname", "unknown"); } properties.put("ProcessId", getProcessId()); properties.put("OS", System.getProperty("os.name")); properties.put("Runtime", System.getProperty("java.version")); return properties; } private String getProcessId() { String runtimeName = ManagementFactory.getRuntimeMXBean().getName(); return runtimeName.split("@")[0]; } } public class ThreadContextEnricher implements LogEnricher { @Override public Map<String, Object> enrich() { Map<String, Object> properties = new HashMap<>(); properties.put("ThreadId", Thread.currentThread().getId()); properties.put("ThreadName", Thread.currentThread().getName()); return properties; } } 8. Spring Boot Integration
Configuration:
package com.example.config; import com.example.serilog.enrichers.EnvironmentEnricher; import com.example.serilog.enrichers.LogEnricher; import com.example.serilog.enrichers.ThreadContextEnricher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.List; @Configuration public class LoggingConfig { @Bean public List<LogEnricher> logEnrichers() { return List.of( new EnvironmentEnricher(), new ThreadContextEnricher() ); } } Auto-enriched Logger:
package com.example.serilog.core; import com.example.serilog.enrichers.LogEnricher; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class EnrichedSerilogLogger extends LogbackSerilogLogger { private final List<LogEnricher> enrichers; public EnrichedSerilogLogger(Class<?> clazz, List<LogEnricher> enrichers) { super(clazz); this.enrichers = enrichers; } @Override protected void setMDCProperties(Map<String, Object> properties) { // Add enriched properties Map<String, Object> enrichedProperties = enrichers.stream() .map(LogEnricher::enrich) .flatMap(map -> map.entrySet().stream()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); enrichedProperties.putAll(properties); super.setMDCProperties(enrichedProperties); } } 9. Best Practices and Patterns
Log Event Schema:
package com.example.serilog.model; import java.time.Instant; import java.util.Map; public class LogEventSchema { // Standard fields for consistent logging public static final String TIMESTAMP = "@timestamp"; public static final String LEVEL = "level"; public static final String MESSAGE = "message"; public static final String LOGGER = "logger"; public static final String THREAD = "thread"; public static final String EXCEPTION = "exception"; // Application-specific fields public static final String SERVICE = "service"; public static final String VERSION = "version"; public static final String ENVIRONMENT = "environment"; public static final String CORRELATION_ID = "correlationId"; public static final String USER_ID = "userId"; public static final String SESSION_ID = "sessionId"; public static final String DURATION_MS = "durationMs"; public static final String OPERATION_NAME = "operationName"; } Performance Considerations:
// Use when debug logging to avoid expensive operations when not needed if (log.isDebugEnabled()) { log.debug("Expensive operation result: {Result}", expensiveOperation()); } // Use lazy evaluation log.debug("User data: {User}", (LazyEvaluator) () -> convertToJson(user)); Conclusion
This Serilog-style implementation brings the power of structured logging to Java applications. Key benefits:
- Rich Structured Data: Logs become queryable events rather than just text
- Contextual Logging: Easy to add and maintain context across operations
- Performance Monitoring: Built-in operation timing
- Flexible Output: JSON for machines, formatted text for humans
- Enterprise Ready: Proper MDC handling, async logging, and enrichment
The approach works seamlessly with existing Java logging infrastructure while providing Serilog's elegant API and powerful features. This makes logs much more valuable for debugging, monitoring, and business intelligence.