Java and Kotlin

com.infinitetoken:lumber is Lumber's client for Java, Kotlin and any other JVM language. It encrypts every log with AES-256-GCM before it leaves your process and has no dependencies beyond the JDK: it sends with java.net.http and encrypts with javax.crypto. com.infinitetoken:lumber-logback plugs it into SLF4J and Logback, so Spring Boot and the libraries you already use can send to Lumber too.

Install

Both artifacts are on Maven Central. With Gradle:

dependencies {
    implementation("com.infinitetoken:lumber:1.0.0")
}

With Maven:

<dependency>
  <groupId>com.infinitetoken</groupId>
  <artifactId>lumber</artifactId>
  <version>1.0.0</version>
</dependency>

Java 11 or later. Not Android, which has no java.net.http.

Quick start

import com.infinitetoken.lumber.LumberClient;

LumberClient lumber = LumberClient.create(System.getenv("LUMBER_API_KEY"));

lumber.info("Server started", Map.of("port", 8080));
lumber.warn("Rate limit approaching", Map.of("requestId", "req-123", "userId", 456));
lumber.error("Payment failed", Map.of("traceId", "trace-abc", "orderId", 789), exception);

In Kotlin:

val lumber = LumberClient.create(System.getenv("LUMBER_API_KEY"))

lumber.info("Server started", mapOf("port" to 8080))
lumber.error("Payment failed", mapOf("orderId" to 789), e)

Make one client at startup and share it: it's safe to use from any thread. Logs are sent from a background thread, so logging never waits on the network, and logs still queued when the program exits get up to 5 seconds to send, from a shutdown hook. close() sends them and stops the client's thread, and flush(Duration) waits for them without stopping.

The API key comes from the Lumber app (see Getting Started). Keep it in an environment variable or your secrets manager, never in source control, and never in an app or game you ship to people (why).

SLF4J, Logback and Spring Boot

lumber-logback is a Logback appender, so the loggers your code and its libraries already use through SLF4J send to Lumber too. Spring Boot logs through Logback unless you've changed it.

dependencies {
    implementation("com.infinitetoken:lumber-logback:1.0.0")
}

Add the appender to logback.xml:

<configuration>
  <appender name="LUMBER" class="com.infinitetoken.lumber.logback.LumberAppender">
    <apiKey>${LUMBER_API_KEY}</apiKey>
    <environment>production</environment>
  </appender>

  <root level="INFO">
    <appender-ref ref="LUMBER" />
  </root>
</configuration>

In Spring Boot, the same goes in logback-spring.xml, beside Spring's console output:

<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml" />
  <include resource="org/springframework/boot/logging/logback/console-appender.xml" />

  <appender name="LUMBER" class="com.infinitetoken.lumber.logback.LumberAppender">
    <apiKey>${LUMBER_API_KEY}</apiKey>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="LUMBER" />
  </root>
</configuration>

Then log as you already do:

private static final Logger log = LoggerFactory.getLogger(Billing.class);

log.info("Report ready in {}s", 2.4);
log.atWarn().addKeyValue("requestId", "req-123").addKeyValue("rows", 1500).log("Report slow");
log.error("Payment failed", exception);
log.error(MarkerFactory.getMarker("FATAL"), "Database unreachable");

Each event becomes a log at the matching level: TRACE and DEBUG are DEBUG, and an event with a marker named FATAL is FATAL. Its message is the formatted message.

  • The MDC's entries and the event's key-value pairs go into the log's metadata, except requestId and traceId (or request_id and trace_id), which are the log's own fields, and a context map, which goes into its context. So the traceId Spring Boot's tracing puts in the MDC becomes the log's trace ID.
  • The logger and thread go into the context too, and an exception goes into the metadata as exception and stackTrace.
  • The appender encrypts each event where it's logged and sends it from the client's thread, so it needs no AsyncAppender in front of it.

The appender takes the client's options as elements: apiKey, environment, endpoint, autoContext, timeout (10 seconds), shutdownTimeout (5 seconds) and maxQueue, plus includeCallerData (send the file, line and method that logged; off unless set, since Logback finds them from a stack trace on every event) and includeMdc (on unless set). Give it a ThresholdFilter to send fewer levels than your console shows. To share one client between the appender and your own calls, make the appender in code and pass it the client with setClient.

Log methods

MethodLevel
debug(message), debug(message, data)DEBUG
info(message), info(message, data)INFO
warn(message), warn(message, data), warn(message, exception), warn(message, data, exception)WARN
error(...), with the same arguments as warnERROR
fatal(...), with the same arguments as warnFATAL. It doesn't exit
log(level, message, data, exception)Any LogLevel; data and exception can be null
flush(timeout)Waits for queued logs to send; false if the time ran out first
close(), close(timeout)Sends what's queued, waiting for the timeout at most, and stops

Every log method returns at once and never throws.

Data

A map's keys become the log's fields:

lumber.info("User signed in", Map.of(
    "requestId", "req-abc",                   // the log's request ID
    "traceId", "trace-xyz",                   // the log's trace ID
    "context", Map.of("region", "us-east"),   // merged into its context
    "metadata", Map.of("plan", "pro"),        // merged into its metadata
    "userId", 123                             // any other key goes into metadata
));

An explicit metadata key wins over a colliding key. An exception goes into metadata as exception (java.lang.IllegalStateException: card declined) and stackTrace. Values JSON has no type for are sent as text (an Instant or LocalDate as ISO 8601, a UUID as its text, an enum as its name), and a NaN as null. Everything, the message included, is encrypted.

Options

LumberClient lumber = LumberClient.builder(System.getenv("LUMBER_API_KEY"))
    .environment("production")              // sent as `env` in every log's context
    .autoContext(true)                      // send the process id and hostname with every log
    .caller(false)                          // also send the file, line and method that logged
    .callerDepth(0)                         // frames to skip for `caller`, one per wrapper around Lumber
    .timeout(Duration.ofSeconds(10))        // how long to wait for Lumber to answer one log
    .maxQueue(10_000)                       // logs waiting to send before new ones are dropped
    .shutdownTimeout(Duration.ofSeconds(5)) // how long queued logs get when the program exits; zero adds no hook
    .httpClient(httpClient)                 // your own java.net.http.HttpClient, for a proxy or custom TLS
    .warnings(message -> { /* your logger */ })
    .build();

build() throws IllegalArgumentException when the API key is missing or has no valid encryption key.

Delivery

Logs are sent in order, one POST /logs per log, from a daemon thread over connections kept open between logs.

  • A network error or a 5xx counts as a failed attempt. Each log gets 5 attempts, waiting 1, 2, 4, then 8 seconds between them, and is then dropped with a warning.
  • A log Lumber refuses (a 4xx, such as an invalid API key) is dropped at once with a warning, since sending it again won't help.
  • When your team has sent its plan's logs for the month, Lumber answers 429: the client warns once and drops logs until the month turns.
  • While Lumber can't be reached, up to maxQueue logs wait; past that, new ones are dropped.

Warnings go to standard error unless you pass warnings, never through a logging library that could send them back to Lumber.

Serverless

A serverless platform can freeze a function as soon as it returns, with logs still queued, and a frozen function's threads don't run. Make the client once, outside the handler, and flush it before returning, with a time limit so a slow answer can't hold the function to its maximum duration. On AWS Lambda:

public class Handler implements RequestHandler<Map<String, Object>, String> {
    private static final LumberClient lumber = LumberClient.create(System.getenv("LUMBER_API_KEY"));

    @Override
    public String handleRequest(Map<String, Object> event, Context context) {
        lumber.info("Handled request", Map.of("requestId", context.getAwsRequestId()));
        lumber.flush(Duration.ofSeconds(10));
        return "ok";
    }
}

Source

The client is open source: Lumber-Java on GitHub, published as com.infinitetoken:lumber and com.infinitetoken:lumber-logback on Maven Central. It's tested against the same known answers for encryption and the wire format as Lumber's other clients.