Flogger Fluent Logging in Java: Rate Limits vs SLF4J 2

Learn Flogger fluent logging in Java with version 0.9, backends, rate limiting, lazy arguments and stack traces, compared with the SLF4J 2 fluent API.

Flogger log statement chain from level selector through optional modifiers to log(), with the no-op path for disabled levels

Flogger is Google’s fluent logging API for Java, in which every log statement is a chain of method calls such as logger.atInfo().every(100).log(“Processed %d rows”, count). Flogger is only the API, so the messages go to a backend such as java.util.logging (the default), Log4j2 or SLF4J, which decides the format and the destination.

We use Flogger in services that log a lot and need features beyond plain levels, for example rate limiting a message that would otherwise appear thousands of times per minute, attaching a stack trace on demand, or skipping an expensive argument when the level is disabled. Google uses it as its recommended Java logging API.

The following example creates a FluentLogger in an import job and logs a message with two arguments.

public class ImportJob {
    private static final FluentLogger logger = FluentLogger.forEnclosingClass();

    void importFile(String file, int orders) {
        logger.atInfo().log("Imported %d orders from %s", orders, file);
    }
}
Oct 10, 2026 2:32:49 PM ImportJob importFile
INFO: Imported 120 orders from orders.csv

Notice that the placeholders are printf style (%d, %s) and not the {} placeholders of SLF4J, and that the default backend prints in the java.util.logging format. We go through the dependencies, levels, exceptions, rate limiting and lazy arguments, explain why disabled statements cost almost nothing, and compare Flogger with the fluent API that SLF4J 2 added.

1. Flogger Dependencies

Flogger, similar to SLF4J, is an abstraction over a logging framework. Code depends on the flogger artifact, and the application adds one backend at runtime. The current version on Maven Central is 0.9, released in May 2025.

1.1. Flogger Core and the Default Backend

The flogger artifact contains FluentLogger and the API, and flogger-system-backend sends the messages to java.util.logging (JUL). Without any backend, FluentLogger.forEnclosingClass() throws an ExceptionInInitializerError caused by IllegalStateException: No logging platforms found, so the system backend is the safe default for small apps and tests.

<dependency>
    <groupId>com.google.flogger</groupId>
    <artifactId>flogger</artifactId>
    <version>0.9</version>
</dependency>

<!-- The java.util.logging backend -->
<dependency>
    <groupId>com.google.flogger</groupId>
    <artifactId>flogger-system-backend</artifactId>
    <version>0.9</version>
    <scope>runtime</scope>
</dependency>

1.2. Using Log4j2 or SLF4J as the Backend

Most applications already have a logging setup, such as log4j2.xml or a Logback file behind SLF4J. In that case we add the matching Flogger backend instead of the system backend, and the existing configuration controls levels, appenders and layouts.

<!-- Log4j2 backend -->
<dependency>
    <groupId>com.google.flogger</groupId>
    <artifactId>flogger-log4j2-backend</artifactId>
    <version>0.9</version>
    <scope>runtime</scope>
</dependency>

<!-- or the SLF4J backend -->
<dependency>
    <groupId>com.google.flogger</groupId>
    <artifactId>flogger-slf4j-backend</artifactId>
    <version>0.9</version>
    <scope>runtime</scope>
</dependency>

Flogger finds the backend with Java’s ServiceLoader, so the dependency alone is enough. If several backends are on the classpath, the system property flogger.backend_factory selects one, for example -Dflogger.backend_factory=com.google.common.flogger.backend.log4j2.Log4j2BackendFactory. The old flogger-log4j-backend targets Log4j 1.x, which reached end of life in 2015, so new projects skip it.

2. The FluentLogger API

Every log statement follows the same shape. A level method such as atInfo() starts the chain, optional methods such as withCause() or every() add behavior, and log() ends it and writes the message.

Flogger log statement chain from level selector through optional modifiers to log(), with the no-op path for disabled levels
When the level is disabled, atFine() returns a shared no-op object and the rest of the chain does nothing

2.1. Creating the Logger

We declare one logger per class as a private static final field and create it with FluentLogger.forEnclosingClass(), which is the recommended way to get a Flogger logger. The method finds the class name for us, so a copied logger line never logs under the wrong class name, which is a common mistake with getLogger(SomeOtherClass.class).

The message uses Java format specifiers such as %s, %d and %.2f. Flogger formats the message only when the statement is enabled, so we pass arguments instead of concatenating strings.

2.2. Log Levels

Flogger uses the levels of java.util.logging.Level, from SEVERE down to FINEST. Each common level has its own method, and at(Level) covers the case where the level is chosen at runtime.

MethodJUL levelTypical use
atSevere()SEVEREA failure that needs attention, such as a stopped import
atWarning()WARNINGA problem the code recovered from, such as a rejected row
atInfo()INFONormal milestones, such as a finished import
atConfig()CONFIGConfiguration values read at startup
atFine(), atFiner(), atFinest()FINE to FINESTDebug and trace details
at(Level.WARNING)AnyLevel decided at runtime

The backend decides which levels are enabled. With the system backend, JUL prints INFO and above by default, so atFine() statements stay silent until we lower the level in logging.properties or in code. With Log4j2 or SLF4J, the level comes from their configuration files. The Log4j2 backend maps CONFIG and FINE to DEBUG, and FINER and FINEST to TRACE.

java.util.logging.Logger jul = java.util.logging.Logger.getLogger("ImportJob");
jul.setLevel(java.util.logging.Level.FINE);        // enables atFine() for this class

2.3. Logging Exceptions and Stack Traces

The withCause() method attaches an exception, and the backend prints its stack trace after the message. The withStackTrace() method adds the stack of the current log call even when there is no exception, which helps when we need to know which code path reached a statement.

public class RowValidator {
    private static final FluentLogger logger = FluentLogger.forEnclosingClass();

    void reject(int row, Exception cause) {
        logger.atWarning().withCause(cause).log("Rejected row %d", row);
    }

    void trace() {
        logger.atInfo().withStackTrace(StackSize.SMALL).log("Who called the validator?");
    }
}
Oct 10, 2026 2:32:49 PM RowValidator reject
WARNING: Rejected row 7
java.lang.IllegalStateException: price missing
	at ImportApp.main(ImportApp.java:25)

Oct 10, 2026 2:32:49 PM RowValidator trace
INFO: Who called the validator?
com.google.common.flogger.LogSiteStackTrace: SMALL
	at RowValidator.trace(ImportApp.java:11)
	at ImportApp.main(ImportApp.java:26)

The StackSize enum has five constants. SMALL, MEDIUM and LARGE capture an increasing number of frames, FULL captures the whole stack, and NONE captures nothing, which lets us switch the stack trace on and off with one variable such as withStackTrace(debug ? StackSize.MEDIUM : StackSize.NONE).

2.4. Rate Limiting with every(), atMostEvery() and per()

Rate limiting is the main reason teams pick Flogger. Say an import of 2 million rows logs a warning for every row without a tax rate. The log file grows by gigabytes, and the one useful line is lost. Flogger can log such a statement only every N calls or at most once per time period.

public class TaxLookup {
    enum Region { US, IN }

    private static final FluentLogger logger = FluentLogger.forEnclosingClass();

    void progress(int row) {
        logger.atInfo().every(10).log("Processed row %d", row);
    }

    void missingRate(Region region) {
        logger.atWarning()
                .atMostEvery(1, TimeUnit.MINUTES)
                .per(region)
                .log("Tax table missing for %s", region);
    }
}
TaxLookup lookup = new TaxLookup();
for (int row = 0; row < 25; row++) {
    lookup.progress(row);              // logs rows 0, 10 and 20
}
for (TaxLookup.Region region : List.of(TaxLookup.Region.US, TaxLookup.Region.US, TaxLookup.Region.IN)) {
    lookup.missingRate(region);        // logs US once and IN once
}
INFO: Processed row 0 [CONTEXT ratelimit_count=10 ]
INFO: Processed row 10 [CONTEXT ratelimit_count=10 skipped=9 ]
INFO: Processed row 20 [CONTEXT ratelimit_count=10 skipped=9 ]
WARNING: Tax table missing for US [CONTEXT ratelimit_period="1 MINUTES" group_by="US" ]
WARNING: Tax table missing for IN [CONTEXT ratelimit_period="1 MINUTES" group_by="IN" ]

We can see that the backend adds a [CONTEXT …] suffix with the limit and the number of skipped calls, so the reader knows that lines are missing. The per(region) call keeps a separate limit for each enum value, so a frequent US warning does not hide the first IN warning. For non-enum keys, the overload per(key, LogPerBucketingStrategy) takes a strategy that limits the number of buckets.

2.5. Lazy Arguments

Arguments are evaluated before log() runs, even when the level is disabled. For a cheap value that does not matter, but a call such as buildSummary() that walks a large collection wastes CPU on every disabled DEBUG statement. The static method LazyArgs.lazy() wraps the argument in a lambda that Flogger calls only when the message is logged.

public class ReportBuilder {
    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
    static int summaryCalls = 0;

    static String buildSummary() {
        summaryCalls++;
        return "120 ok, 3 rejected";
    }

    void finish() {
        logger.atFine().log("Summary %s", LazyArgs.lazy(() -> buildSummary()));
    }
}
new ReportBuilder().finish();
int calls = ReportBuilder.summaryCalls;    // 0, FINE is disabled
boolean fine = FluentLogger.forEnclosingClass().atFine().isEnabled();   // false

The isEnabled() method answers the same question for a whole block of code, but lazy() keeps the log statement on one line, so we prefer it for single arguments.

3. Why Disabled Flogger Statements Cost Almost Nothing

Classic logging APIs have methods such as info(String, Object…). Every call allocates an Object[] for the varargs and boxes primitive arguments such as int, and this work happens before the method can check the level, so a disabled statement still allocates memory.

Flogger checks the level first. When atFine() finds the level disabled, it returns a shared no-op object whose methods ignore all arguments. The no-op object is immutable and thread-safe, so the same instance is reused, and log() has overloads for up to ten arguments, including variants with int, long and boolean parameters, so the common cases avoid varargs and boxing.

The saving matters in hot loops with many disabled DEBUG statements, and it does not matter for a few INFO lines per request. SLF4J’s {} placeholders already avoid string building for disabled levels, so for most applications the difference between the APIs is the feature set, not the speed.

4. Flogger vs the SLF4J 2 Fluent API

SLF4J 2.0 added a fluent logging API with methods such as atInfo(), addArgument(), addKeyValue() and setCause(). Teams that already use SLF4J get fluent statements without a new dependency, so the decision is whether Flogger adds anything beyond that.

public class SlfImportJob {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SlfImportJob.class);

    void finish(String file, int orders, Exception problem) {
        log.atInfo().setMessage("Imported {} orders from {}").addArgument(orders).addArgument(file).log();
        log.atInfo().addKeyValue("file", file).addKeyValue("orders", orders).log("Import finished");
        log.atWarn().setCause(problem).log("Rejected row {}", 7);
    }
}
[main] INFO SlfImportJob - Imported 120 orders from orders.csv
[main] INFO SlfImportJob - file=orders.csv orders=120 Import finished
[main] WARN SlfImportJob - Rejected row 7
java.lang.IllegalStateException: price missing

The output comes from slf4j-simple 2.0.20, which prints key-value pairs before the message. Logback and Log4j2 can write them as separate JSON fields instead, which helps log search tools.

FeatureFlogger 0.9SLF4J 2.0 fluent API
Placeholdersprintf style, %s and %d{}
Rate limitingevery(), atMostEvery(), per()No
Lazy argumentsLazyArgs.lazy()addArgument(Supplier)
Key-value pairsMetadata API, less commonaddKeyValue()
Stack trace without exceptionwithStackTrace()No
ExceptionwithCause()setCause()
BackendsJUL, Log4j2, SLF4JLogback, Log4j2, JUL, simple and more
Typical userGoogle projects, libraries that log heavilyMost Spring Boot and Jakarta EE apps

We pick Flogger when rate limiting or call-site stack traces solve a real problem, such as a noisy batch job. For a Spring Boot application, which logs through SLF4J and Logback already, the SLF4J 2 fluent API covers fluent statements and structured key-value logging without another library, as described in SLF4J vs Log4j.

5. Status and Compatibility

Flogger is open source under the Apache 2.0 license and lives in the google/flogger repository. Release 0.9 came out in May 2025, about a year and a half after 0.8, so releases are infrequent, and the version number has not reached 1.0. The examples in this article run on Java 25 with Flogger 0.9 and the system backend, and version 0.9 depends on the small org.jspecify:jspecify annotations library, which Maven adds for us.

The slow release pace is a reason to keep Flogger calls behind the logger field of each class and not to spread backend-specific code. Switching from Flogger to SLF4J later means rewriting the log statements, because the placeholder syntax differs.

6. Flogger FAQs

Rate limiting and the choice between Flogger and SLF4J lead the questions about Flogger, followed by backend setup.

6.1. Is Flogger better than SLF4J?

Not in general, because it depends on the features we need. Flogger has built-in rate limiting and call-site stack traces, and SLF4J has wider framework support and {} placeholders that every Java developer knows. For a typical Spring Boot app, SLF4J 2 is enough, and for a batch job that floods its logs, Flogger’s atMostEvery() helps.

6.2. How do I change the log level when using Flogger?

We change it in the backend. With the system backend, we set the level in logging.properties or call java.util.logging.Logger.getLogger(name).setLevel(), and with Log4j2 or SLF4J we use log4j2.xml or logback.xml. Flogger has no level setting of its own.

6.3. Why does Flogger throw No logging platforms found?

The flogger artifact contains only the API, so the first logger creation fails with IllegalStateException when no backend is present. Add flogger-system-backend or another backend to the runtime classpath, as shown in section 1.

6.4. Can Flogger log only once every few seconds?

Yes. The call atMostEvery(5, TimeUnit.SECONDS) logs the statement at most once per 5 seconds, and combining it with per(key) keeps a separate limit for each key, such as each region or customer type.

6.5. Does Flogger work with Spring Boot?

Yes. Add flogger-slf4j-backend, and the messages go through SLF4J to Logback, so the Spring Boot logging properties apply. The placeholders inside the code stay in printf style.

7. Conclusion

Flogger writes each log statement as a chain that starts with a level method and ends with log(). Disabled statements return a shared no-op object, and the API adds rate limiting, lazy arguments and call-site stack traces that classic logging APIs do not have.

Version 0.9 runs on Java 25 with JUL, Log4j2 or SLF4J as the backend. When an application already uses SLF4J and does not need rate limiting, the SLF4J 2 fluent API gives a similar style without another dependency.

8. References

Happy Learning !!

Source Code on Github

Leave a Comment

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.