Centralized Java logging with ZipLogger
Java services write logs to files and consoles on every instance. When a payment fails for one customer, you want that stack trace and the lines around it in one place, not on whichever pod handled the request. The ZipLogger Java SDK sends your log lines to one searchable store. It is JDK-only, with no dependencies, and needs Java 17 or newer.
Before you start, know what it ships. There is a java.util.logging handler and a client you can call directly. There is no SLF4J binding, no Logback appender and no Log4j2 appender in the package. If you use Logback or Log4j2, see the section on them below.
Add the dependency
Maven:
<dependency>
<groupId>dev.ziplogger</groupId>
<artifactId>ziplogger</artifactId>
<version>0.4.0</version>
</dependency>
Gradle:
implementation("dev.ziplogger:ziplogger:0.4.0")
0.4.0 is the latest version on Maven Central. For a key, go to Settings → API keys in the app and click Create key. The key starts with zk_ and is shown once.
java.util.logging
If your code uses java.util.logging.Logger, one handler on the root logger is enough:
import dev.ziplogger.ZipLoggerClient;
import dev.ziplogger.ZipLoggerJulHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
public class PaymentsApp {
private static final Logger log = Logger.getLogger("demo.payments");
public static void main(String[] args) {
var options = new ZipLoggerClient.Options(
"https://app.ziplogger.ai", System.getenv("ZIPLOGGER_API_KEY")); // YOUR_API_KEY
options.source = "payments-api";
options.release = "3.2.0";
var client = new ZipLoggerClient(options);
Logger.getLogger("").addHandler(new ZipLoggerJulHandler(client, true));
log.log(Level.INFO, "Charged order {0} for {1}", new Object[] { 83112, "41.50 EUR" });
try {
charge();
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Payment failed", e);
}
}
}
We ran this against a local HTTP catcher with GIT_COMMIT set. The two lines arrived like this (host name changed, stack trace shortened):
{
"timestamp": "2026-09-29T11:28:21.964431Z",
"severity": "info",
"message": "Charged order 83,112 for 41.50 EUR",
"source": "payments-api",
"release": "3.2.0",
"commitSha": "4f2a9c1",
"fields": {
"environment": "production",
"machineName": "web-01",
"category": "demo.payments",
"param0": "83112",
"param1": "41.50 EUR"
}
}
{
"timestamp": "2026-09-29T11:28:22.318995Z",
"severity": "error",
"message": "Payment failed",
"source": "payments-api",
"release": "3.2.0",
"commitSha": "4f2a9c1",
"stackTrace": "java.lang.IllegalStateException: card declined\n\tat demo.PaymentsApp.main(PaymentsApp.java:21)",
"fields": {
"environment": "production",
"machineName": "web-01",
"category": "demo.payments",
"exceptionType": "java.lang.IllegalStateException",
"exceptionMessage": "card declined"
}
}
What the handler does:
- The logger name becomes
fields.category. - Parameters become
param0,param1and so on, always as text. The message is formatted withMessageFormat, which is why83112shows as83,112in the message. Pass ids as strings if that bothers you. SEVEREbecomeserror,WARNINGbecomeswarn,INFObecomesinfo, and anything lower becomesdebug. The handler never sendsfatal.- A thrown exception fills
stackTrace,exceptionType(the full class name) andexceptionMessage. - The root logger's default level is
INFO, solog.fine(...)never reaches the handler unless you lower it.
The true in new ZipLoggerJulHandler(client, true) matters. It tells the handler to close the client when the handler is closed, and the JDK closes root handlers from a shutdown hook. In our test, main returned without calling close() and every line was still delivered. Without true, you must call client.close() yourself.
Fields and the direct client
JUL has no MDC and no key-value arguments. For searchable fields, call the client directly:
client.log(new ZipLoggerClient.Entry()
.severity("warn")
.message("Retrying charge")
.field("orderId", 83112)
.field("attempt", 2));
Here orderId arrives as the number 83112. Numbers, booleans, strings, Map and List values are sent as JSON. Anything else is sent as its toString(). Use .error(exception) to attach an exception. Severity must be debug, info, warn, error or fatal; warning is accepted as warn, and anything else is sent as info.
Logback, Log4j2 and SLF4J
There is no Logback or Log4j2 appender in the SDK yet. If your app logs through SLF4J, you have two options today:
- Call the direct client above from the places that matter most, such as error handlers and background jobs.
- Run the OpenTelemetry Java agent. It picks up Logback and Log4j2 output and can export it over OTLP/HTTP, which ZipLogger accepts at
/v1/logs. The settings are in Send OpenTelemetry traces over OTLP/HTTP.
Shutdown and delivery
The client sends from a daemon thread. If the JVM exits without close(), whatever is still queued is lost. The JUL handler with true closes it for you.
log()never blocks and never throws. The queue holds 10,000 lines (queueCapacity). When it is full, new lines are dropped and counted inclient.dropped().- Batches of up to 100 lines (
batchSize) go as NDJSON to/ingest/v1/logswith anX-Api-Keyheader. A partial batch waits at most 2 seconds (flushInterval). - Network errors, 408, 429 and 5xx are retried up to 5 times (
maxRetries), starting at 500ms and capped at 30 seconds.Retry-Afteris respected. Other errors, like 401 for a wrong key, drop the batch and count it. close()waits up to 5 seconds. If a batch is being sent at that moment, that batch is dropped and counted. We saw 100 lines dropped this way whenclose()ran during a slow request. Give the app a moment after its last line if you can.- When your plan's ingest volume is used up, the API answers 429, and after the retries the batch is dropped. See the pricing page.
- A retry resends the whole batch without an
Idempotency-Key, so a request that timed out after it was stored can arrive twice.
Set source and release
The defaults tell you little, so set these yourself:
sourcedefaults toZIPLOGGER_SOURCE, then the wordjava. Every service would be calledjava.releasecomes fromZIPLOGGER_RELEASEonly. The jar's version is not read.commitShacomes fromZIPLOGGER_COMMIT_SHA,GIT_COMMITorCOMMIT_SHA.environmentcomes fromZIPLOGGER_ENVIRONMENT, defaultproduction.
This is a field on each line. Which ZipLogger environment the line is stored in is decided by the API key, not by this value. See Separate staging and production logs.
Release, commit and a stack trace are what ZipLogger uses to point at the commit that likely broke something. Java stack traces have PaymentsApp.java:21-style frames, which is what that matching reads. See finding the commit that broke production.
Finding the lines
In Search, the box searches the message unless you name a field:
fields.category:demo.payments AND severity:error
fields.exceptionType:"java.lang.IllegalStateException"
fields.requestId:req_7Hq2
The SDK sends logs only. It has no events API and no tracing. For traces, use the OpenTelemetry Java agent and send them over OTLP. A string field named traceId on a log line gives the log detail a View trace link.
The setup is also in the Java docs. To try it, create an account and make a key under Settings → API keys. Running Go or Node as well? See the Go and Node.js guides.