A lightweight structured logging library for Java, inspired by Go's log/slog.
- Structured key-value logging — every log event carries typed attributes, not just a formatted string
- Zero overhead when disabled — level checks use a cached generation-counter scheme; disabled levels cost a single integer comparison with no volatile fence or framework call
- Immutable context propagation — derive loggers with
logger.with()to attach attributes that are automatically included in every subsequent log call; parent attrs are shared, never copied - Cross-component context — propagate context across component boundaries with
builder.ctx(otherLogger) - Fluent event builder —
log.info().attr("k", "v").log("msg")for structured events; returns a no-op singleton when the level is disabled - Deferred logging —
log.debug(e -> e.attr("k", v()).log(msg()))wraps everything in a lambda that is only invoked when the level is enabled — ideal for expensive computations - Throwing suppliers — attribute and message suppliers may throw checked exceptions; failures are caught and the exception message is recorded as the value, so a failing supplier never crashes the application
- Printf formatting —
log.infof("Processed %d items", count)andlog.info().logf(...)with deferred formatting - Rate limiting —
onceEvery(N)andonceEvery(Duration)throttle noisy log statements per call site with automaticskippedcount tracking - Timed events — automatically records elapsed duration
- Backend auto-discovery — delegates to Log4j2 if available, falls back to SLF4J; no hard runtime dependencies
Gradle
implementation("io.github.merlimat.slog:slog:0.9.5")Maven
<dependency>
<groupId>io.github.merlimat.slog</groupId>
<artifactId>slog</artifactId>
<version>0.9.5</version>
</dependency>- Java 17+
- At least one of the following logging backends on the classpath:
- Log4j2 (preferred when available)
- SLF4J 1.x or 2.x
import io.github.merlimat.slog.Logger;
// Simple messages
Logger log = Logger.get(MyService.class);
log.info("Server started");
log.infof("Listening on port %d", port);
// Fluent builder for structured events
log.info()
.attr("method", "GET")
.attr("path", "/api/orders")
.attr("status", 200)
.log("Request handled");
// Exceptions — full stack trace or message only
log.error()
.attr("orderId", orderId)
.exception(cause)
.log("Order processing failed");
log.warn()
.exceptionMessage(cause) // just the message, no stack trace
.log("Retrying operation");
// Deferred logging — lambda is only called if the level is enabled
log.debug(e -> e.attr("payload", serialize(data)).log("Request detail"));
log.debug(e -> e
.attr("key", expensiveValue())
.attr("dump", generateDump())
.log(expensiveMessage()));
// Lazy attributes — supplier is only invoked at emit time
log.info()
.attr("snapshot", () -> generateExpensiveSnapshot())
.log("State captured");
// Throwing suppliers — checked exceptions are caught gracefully;
// the exception message becomes the attribute value
log.info()
.attr("config", () -> loadConfigFromDisk()) // throws IOException
.log("Service started");
// If loadConfigFromDisk() fails: config="<error: file not found>"
// Timed events
Event e = log.info().timed();
executeQuery(sql);
e.attr("query", sql).log("Query executed");
// Automatically includes durationMsThrottle noisy log statements directly at the call site — no external filter
configuration required. When a call is suppressed, a no-op singleton is returned
so all subsequent attr() and log() calls in the chain are free.
// Count-based: emit once every 1000 calls from this site
log.info().onceEvery(1000)
.attr("item", item)
.log("Processing");
// Time-based: emit at most once every 30 seconds from this site
log.warn().onceEvery(Duration.ofSeconds(30))
.attr("queueDepth", queue.size())
.log("Queue backlog growing");Both overloads are keyed by call site (class + line number), so different log
statements maintain independent counters. The first invocation from any call site
always emits. When calls have been suppressed, the emitted event automatically
includes a skipped attribute with the number of suppressed occurrences:
INFO Processing {skipped=999, item=...}
The with() builder returns a new immutable logger — the original is never modified.
Parent attributes are shared by reference, never copied. This is designed for
component-scoped logging where you want certain attributes attached to every log
call without repeating them:
public class Producer {
private final Logger log;
public Producer(String topic, String clientAddr, String namespace) {
this.log = Logger.get(Producer.class).with()
.attr("topic", topic)
.attr("clientAddr", clientAddr)
.attr("namespace", namespace)
.build();
}
public void publish(Message msg) {
log.info()
.attr("msgId", msg.id())
.attr("size", msg.size())
.log("Published");
// Output includes: topic, clientAddr, namespace, msgId, size
}
}Use ctx() to inherit context from another logger across component boundaries:
Logger producerLog = Logger.get(Producer.class).with()
.attr("topic", topic)
.attr("clientAddr", addr)
.build();
Logger consumerLog = Logger.get(Consumer.class).with()
.ctx(producerLog) // inherits topic, clientAddr
.attr("subscription", sub) // adds own attrs
.build();
// Multiple ctx() calls append in order
Logger combined = Logger.get(Pipeline.class).with()
.ctx(producerLog)
.ctx(requestLog)
.attr("step", "transform")
.build();When the same key appears at multiple levels (parent context, inherited via ctx(),
builder attrs, or per-event attrs), all occurrences are preserved in order.
Resolution (last-writer-wins, etc.) is left to the logging backend.
When Log4j2 is on the classpath, structured attributes are set directly on the
log event's context data — bypassing ThreadContext entirely for maximum
performance. With JsonLayout and properties="true", each attribute appears
as an individual field inside the contextMap JSON object. With PatternLayout,
use %X for the full map or %X{key} for individual keys.
When only SLF4J is available, attributes are placed into the MDC for the duration of each log call, making them available via pattern layouts or JSON encoders (e.g. logstash-logback-encoder).
If you use Lombok, you can use @CustomLog to generate the logger field
automatically. Add this to your lombok.config:
lombok.log.custom.declaration = io.github.merlimat.slog.Logger io.github.merlimat.slog.Logger.get(TYPE)Then annotate your classes:
@CustomLog
public class MyService {
public void process() {
log.info("hello");
}
}Lombok will generate private static final Logger log = Logger.get(MyService.class);.
slog is designed to add minimal overhead on top of the underlying logging framework. JMH benchmarks compare slog against direct Log4j2, SLF4J, and Flogger calls, all writing to a Null appender (measuring framework overhead, not I/O). Root logger level is INFO.
slog Simple ████████████████████████████████████████████ 1005.4
slog Fluent ████████████████████████████████████████████ 1005.0
Log4j2 Simple ██████████████████ 413.1
Log4j2 Positional ██████████████████ 413.2
SLF4J Simple ████████████████ 367.5
Flogger Simple ████████████████ 360.3
SLF4J Positional ███████████████ 360.5
SLF4J Fluent ███████████████ 359.6
Flogger Positional ███████████████ 344.9
When the level is disabled, slog checks a cached effective level using a
generation-counter scheme with VarHandle.getOpaque() — no volatile fence,
no call into the Log4j2 hierarchy. This makes the disabled path 2.4× faster
than Log4j2 and 2.8× faster than SLF4J. The fluent API returns a NoopEvent
singleton, so attr() and log() calls are no-ops with zero allocation.
slog Simple ████████████████████████████████████████████ 24.6
Log4j2 Simple ██████████████████████████ 14.6
slog Fluent ███████████████████████ 13.2
slog Fluent+Ctx █████████████████████ 12.0
SLF4J Simple ████████████████████ 11.2
SLF4J Positional ████████████ 7.1
Log4j2 Positional ███████████ 6.4
SLF4J Fluent ███████ 4.2
Flogger Simple █ 0.7
Flogger Positional █ 0.7
slog Simple (no structured attrs) is 1.7× faster than native Log4j2 and
2.2× faster than SLF4J. The emit path builds a MutableLogEvent directly and
calls LoggerConfig.log(), completely bypassing ThreadContext and the
ContextDataInjector pipeline. The event and context map are pooled in
ThreadLocals for zero allocation on the simple path.
slog Fluent (3 structured key-value attributes) runs at 13.2 ops/μs —
3.2× faster than SLF4J's fluent API (4.2 ops/μs) and 2× faster than
Log4j2 positional logging (6.4 ops/μs), which also carries 3 values but as
interpolated strings rather than structured data. Event attributes are stored
in inline parallel arrays, avoiding ArrayList and per-attribute object
allocation.
slog Simple ▏ 0
Log4j2 Simple █ 24
SLF4J Simple █ 24
Log4j2 Positional █ 40
slog Fluent+Ctx █ 40
SLF4J Positional ██ 72
slog Fluent ██ 80
SLF4J Fluent ██████████████████████████ 1104
Flogger Simple ██████████████████████████████████████ 1624
Flogger Positional ████████████████████████████████████████████ 1904
slog Simple achieves zero allocation — the MutableLogEvent, message, and
context map are all pooled in ThreadLocals, so the enabled simple path produces
no garbage at all.
slog Fluent allocates 80 B/op (two small arrays for event attributes plus
autoboxing of one int argument), compared to 1,104 B/op for SLF4J's fluent
API — a 14× reduction in garbage produced per log call.
Flogger allocates 1,624–1,904 B/op and achieves only 0.7 ops/μs on the enabled path — 35× slower than slog Simple and 6× slower than SLF4J Fluent. The overhead comes from Flogger's backend translation layer (Log4j2 backend) and heavy per-call allocation.
./gradlew :benchmark:jmhResults are written to benchmark/build/results/jmh/results.txt.
To generate async-profiler flame graphs alongside the benchmarks:
./gradlew :benchmark:jmh -PjmhProfilers='async:libPath=/path/to/libasyncProfiler.dylib;output=flamegraph;dir=profile-results'./gradlew build