Skip to content

Feature request: Log buffering support for Logger utility #2095

@phipag

Description

@phipag

Use case

Log buffering enables you to buffer logs for a specific request or invocation, and flush them automatically on error or manually as needed. This is useful when you want to reduce the number of log messages emitted while still having detailed logs when needed, such as when troubleshooting issues.

This feature is already available in other Powertools language implementations:

PLEASE READ:

The remainder of this issue outlines a high-level overview of how this experience can look like in Java.

Solution/User Experience

Complete Example

public class MyHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponse> {
    private static final Logger logger = LoggerFactory.getLogger(MyHandler.class);
    
    public MyHandler() {
        // Programmatic configuration using Builder pattern
        PowertoolsLogging.setBufferConfig(BufferConfig.builder()
            .maxBytes(20480)  // Default is 20KB (20480 bytes)
            .bufferAtVerbosity(Level.DEBUG)  // Buffers DEBUG and TRACE
            .flushOnErrorLog(true)  // default true, auto-flushes buffer when error logs are emitted
            .compress(false)  // default false
            .build());
            
        // INIT logs will never be buffered due to no trace ID being present
        logger.debug("This is a debug message");  // This is NOT buffered
    }
    
    @Logging
    public APIGatewayProxyResponse handleRequest(APIGatewayProxyRequestEvent event, Context context) {
        // Assuming current log level is INFO
        logger.debug("This is a debug message");  // This is buffered
        logger.info("This is an info message");
        
        // your business logic here
        
        logger.error("This is an error message");  // This also flushes the buffer
        
        return new APIGatewayProxyResponse();
    }
}

Annotation-based Configuration

@Logging(bufferConfig = @BufferConfig(maxBytes = 20480, flushOnErrorLog = true))
public String handleRequest(APIGatewayProxyRequestEvent event, Context context) {
    Logger logger = LoggerFactory.getLogger(MyHandler.class);
    // Assuming current log level is INFO
    
    logger.debug("a debug log");  // this is buffered
    logger.info("an info log");   // this is not buffered
    
    // do stuff
    
    return "Success";
}

Manual Buffer Control

@Logging(bufferConfig = @BufferConfig(flushOnErrorLog = false))
public String handleRequest(APIGatewayProxyRequestEvent event, Context context) {
    Logger logger = LoggerFactory.getLogger(MyHandler.class);
    // Assuming current log level is INFO
    
    logger.debug("a debug log");  // this is buffered
    
    try {
        // Some operation that might fail
    } catch (Exception error) {
        logger.error("An error occurred", error);  // Logs won't be flushed here
    }
    
    // Need to flush logs manually due to flushOnErrorLog = false
    PowertoolsLogging.flushBuffer();
    return "Success";
}

Flush Buffer on Unhandled Exceptions

@Logging(bufferConfig = @BufferConfig(maxBytes = 20480), flushBufferOnUncaughtError = true)
public String handleRequest(APIGatewayProxyRequestEvent event, Context context) {
    Logger logger = LoggerFactory.getLogger(MyHandler.class);
    // Assuming current log level is INFO
    
    logger.debug("a debug log");  // this is buffered
    logger.info("processing request");   // this is not buffered
    
    // This unhandled exception will trigger buffer flush before re-throwing
    // Only compatible with @Logging annotation
    throw new RuntimeException("Something went wrong");
}

Configuration Options

Annotation Configuration

@BufferConfig(
    maxBytes = 20480,                    // Maximum size of log buffer in bytes (default: 20480)
    bufferAtVerbosity = Level.DEBUG,     // Minimum log level to buffer (default: DEBUG, includes TRACE)
    flushOnErrorLog = true,              // Automatically flush on error (default: true)
    compress = false                     // Enable compression (default: false)
)

Builder Pattern Configuration

BufferConfig config = BufferConfig.builder()
    .maxBytes(20480)                     // Maximum size of log buffer in bytes (default: 20480)
    .bufferAtVerbosity(Level.DEBUG)      // Minimum log level to buffer (default: DEBUG, includes TRACE)
    .flushOnErrorLog(true)               // Automatically flush on error (default: true)
    .compress(false)                     // Enable compression (default: false)
    .build();

PowertoolsLogging.setBufferConfig(config);

Implementation Details

When to Buffer

  • Use _X_AMZN_TRACE_ID as key to group logs belonging to an invocation and isolate buffers per Lambda execution
  • INIT phase logs are NEVER buffered (no trace ID present yet)
  • Only buffer during Lambda invocation when trace ID is available

What to Buffer

  • Default: Buffer only DEBUG and TRACE levels
  • Configurable: Allow buffering up to specified level (e.g., bufferAtVerbosity=WARNING buffers WARNING, INFO, DEBUG, TRACE)
  • Higher levels (ERROR, FATAL) are logged immediately unless configured otherwise

Buffer Flush Triggers

  1. Automatic flush on error logs (logger.error(), logger.exception(), logger.critical()) when flushOnErrorLog=true (default behavior when buffering is enabled)
  2. Automatic flush on unhandled exceptions when using @Logging annotation with flushBufferOnUncaughtError=true (default: false, must be explicitly enabled)
  3. Manual flush via PowertoolsLogging.flushBuffer()

Buffer Management

  • Multi-key buffer cache using trace ID as key to isolate logs per Lambda execution
  • Per-execution buffering prevents logs from different warm executions mixing
  • Size-based eviction when maxBytes limit reached for each trace ID
  • Automatic cleanup when new trace ID detected (new Lambda invocation)
  • Optional compression using Java GZIP (standard library) to optimize for space at the cost of CPU overhead (disabled by default)
  • Configuration validation in BufferConfig class throws clear exceptions when invalid buffer settings are applied
  • Warning log emitted when buffer overflow causes log eviction:
    {
      "message": "Some logs are not displayed because they were evicted from the buffer. Increase buffer size to store more logs in the buffer",
      "level": "WARN",
      ...
    }

Integration with Existing Architecture

Logback Integration
  • Extend existing LambdaJsonEncoder to support buffering
  • Create BufferingAppender that works with current encoder structure
  • Integrate with existing MDC and structured argument handling
Log4j2 Integration
  • Extend existing PowertoolsResolver to support buffering
  • Create custom appender that integrates with current JSON template layout
  • Maintain compatibility with existing resolver architecture
AspectJ Integration
  • Extend LambdaLoggingAspect to handle buffer configuration and lifecycle
  • Add buffer flush logic to exception handling in aspect
  • Integrate buffer cleanup with existing state management

Technical Design

Core Classes Structure

// Buffer cache for a single key
public class KeyBufferCache {
    private final Deque<LogEvent> cache = new ArrayDeque<>();
    private int currentSize = 0;
    private boolean hasEvicted = false;
    
    public void add(LogEvent event) { }
    public LogEvent removeOldest() { }
    public List<LogEvent> get() { }
    public void clear() { }
}

// Multi-key buffer management
public class LoggerBufferCache {
    private final int maxSizeBytes;
    private final Map<String, KeyBufferCache> cache = new ConcurrentHashMap<>();
    
    public void add(String key, LogEvent event) { }
    public List<LogEvent> get(String key) { }
    public void clear(String key) { }
    public boolean hasItemsEvicted(String key) { }
}

// Buffer configuration
public class BufferConfig {
    private int maxBytes = 20480;
    private Level bufferAtVerbosity = Level.DEBUG;
    private boolean flushOnErrorLog = true;
    private boolean compress = false;
}

// Integration point
public class PowertoolsLogging {
    private static LoggerBufferCache bufferCache;
    
    public static void setBufferConfig(BufferConfig config) { }
    public static void flushBuffer() { }
    public static void clearBuffer() { }
}

Annotation Updates

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Logging {
    // ... existing fields ...
    
    BufferConfig bufferConfig() default @BufferConfig;
    boolean flushBufferOnUncaughtError() default false;
}

@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface BufferConfig {
    int maxBytes() default 20480;
    Level bufferAtVerbosity() default Level.DEBUG;
    boolean flushOnErrorLog() default true;
    boolean compress() default false;
}

Alternative solutions

Standard Java logging frameworks (SLF4J with Logback/Log4j2) do not provide built-in conditional log buffering:

  • Logback AsyncAppender: Provides asynchronous logging but no conditional flushing based on exceptions
  • Log4j2 AsyncLogger: Similar limitations to Logback
  • Custom Solutions: We want to integrate with standard Java logging frameworks (SLF4J with Logback/Log4j2) and will not consider a custom logger implementation.

Acknowledgment

Future readers

Please react with 👍 and your use case to help us understand customer demand.

Metadata

Metadata

Assignees

Type

No type

Projects

Status

Working on it

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions