Blog Sending logs and traces

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, param1 and so on, always as text. The message is formatted with MessageFormat, which is why 83112 shows as 83,112 in the message. Pass ids as strings if that bothers you.
  • SEVERE becomes error, WARNING becomes warn, INFO becomes info, and anything lower becomes debug. The handler never sends fatal.
  • A thrown exception fills stackTrace, exceptionType (the full class name) and exceptionMessage.
  • The root logger's default level is INFO, so log.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 in client.dropped().
  • Batches of up to 100 lines (batchSize) go as NDJSON to /ingest/v1/logs with an X-Api-Key header. 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-After is 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 when close() 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:

  • source defaults to ZIPLOGGER_SOURCE, then the word java. Every service would be called java.
  • release comes from ZIPLOGGER_RELEASE only. The jar's version is not read.
  • commitSha comes from ZIPLOGGER_COMMIT_SHA, GIT_COMMIT or COMMIT_SHA.
  • environment comes from ZIPLOGGER_ENVIRONMENT, default production.

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.

Parse log lines into fields and metrics

Add fields to plain-text logs as they arrive with pattern, JSON or key=value rules, then turn matching logs into a metric you can chart and alert on.

Alert on error logs and quiet services

Set up log-based alert rules that fire on error spikes or when a service goes quiet, send them to email or Slack, and keep them from paging for nothing.

Centralized .NET logging with Serilog and ILogger

Send ASP.NET Core logs to ZipLogger with the Serilog sink or the ILogger provider, with structured properties you can search by user or order id.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.