Serilog-Style Structured Logging in Java: A Complete Implementation


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:

  1. Rich Structured Data: Logs become queryable events rather than just text
  2. Contextual Logging: Easy to add and maintain context across operations
  3. Performance Monitoring: Built-in operation timing
  4. Flexible Output: JSON for machines, formatted text for humans
  5. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper