Skip to content
noavarice

Logging to journald from Java application using Logback and Foreign Function and Memory API

Publication date
Reading time

8 min read

Original content Original content

When we deploy a web application, the primary way to understand what’s happening inside it is through logs. There are numerous ways to deploy apps on the web, and even more ways to write and collect logs. In this article, I’d like to outline how it’s possible to use journald for a Java application deployed as a systemd service. It’s not the purpose of this article to compare systemd and Docker, nor argue whether you should use journald instead of other structured logging solutions — these are the topics for another article. Rather, I’d like to show what I found interesting about writing logs from a Java application to journald.

Setup and default behavior

I have a rather small Spring web application that writes simple, unstructured text logs to stdout using Logback. This web application is deployed as a systemd service on Linux VPS. In my case, journald is responsible for handling the logs on the machine, which means systemd forwards application stdout logs to journald. Consider this code sample that logs several messages:

public final class LogbackJournaldFfmApi {

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

  public static void main(final String[] args) {
    log.debug("This is a debug message");
    log.info("This is an information message");
    log.warn("This is a warning");

    try {
      methodThatThrowsException();
    } catch (final IllegalStateException e) {
      log.error("Error with an exception", e);
    }
  }

  private static void methodThatThrowsException() {
    throw new IllegalStateException("Some exception");
  }
}

We can use journalctl to view the logs:

Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]: main com.github.LogbackJournaldFfmApi.main(14) - This is a debug message
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]: main com.github.LogbackJournaldFfmApi.main(15) - This is an information message
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]: main com.github.LogbackJournaldFfmApi.main(16) - This is a warning
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]: main com.github.LogbackJournaldFfmApi.main(21) - Error with an exception
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]: java.lang.IllegalStateException: Some exception
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]:         at com.github.LogbackJournaldFfmApi.methodThatThrowsException(LogbackJournaldFfmApi.java:26)
Jan 1 00:00:00 hostname logback-journald-ffm-api[73619]:         at com.github.LogbackJournaldFfmApi.main(LogbackJournaldFfmApi.java:19)

There are several problems with this output. The first problem is that the application logs each message with a different level, but journald cannot distinguish this level because it accepts raw stdout bytes, so it assigns some default level to each message (which is usually INFO). Without level it’s hard to understand whether this particular message is important or not. Next, multiline log messages are considered multiple log messages, one per line — again, this is how journald handles stdout forwarding, which is reasonable. This is dangerous as messages can get mixed together, rendering logs almost useless, especially exception stack traces.

Here’s what log output should look like:

Jan 1 00:00:00 hostname java[73619]: main com.github.LogbackJournaldFfmApi.main(14) - This is a debug message
Jan 1 00:00:00 hostname java[73619]: main com.github.LogbackJournaldFfmApi.main(15) - This is an information message
Jan 1 00:00:00 hostname java[73619]: main com.github.LogbackJournaldFfmApi.main(16) - This is a warning
Jan 1 00:00:00 hostname java[73619]: main com.github.LogbackJournaldFfmApi.main(21) - Error with an exception
    java.lang.IllegalStateException: Some exception
            at com.github.LogbackJournaldFfmApi.methodThatThrowsException(LogbackJournaldFfmApi.java:26)
            at com.github.LogbackJournaldFfmApi.main(LogbackJournaldFfmApi.java:19)

Log levels are obvious, multiline messages are displayed correctly, it’s possible to filter messages via different properties, etc. Much better.

I lived with the first type of logs for some time, but eventually I decided to fix it. I didn’t manage to find a simple, out-of-the-box way (like unit configuration option) of telling journald how it should detect structure in my simple text logs. Then I stumbled upon the journald C API (which is part of systemd API) that allows writing structured logs (preserving level, exact location inside codebase, etc.). Around the same time, finalized FFM API went into production as part of JDK 22 release, offering a modern way to deal with native stuff, and the picture finally came together for me.

While the approach being discussed works perfectly for me, you should know the following:

  • first and foremost, errors in native code, such as segmentation faults, will crash your whole JVM without the ability to recover from it — it’s not possible to catch a crash like an exception, JVM shutdown hooks will also not work. This is not merely a possibility — I encountered multiple crashes when I finally implemented logging over FFM API;
  • your application becomes platform-dependent, because FFM API code will likely vary for different platforms. In general it means that you should develop the application in the same environment in which you want to deploy it.

That said, use this approach if you want to experiment with FFM API in a safe environment (my case) or if you have very strong reasons to use it in a mission-critical scenario (for example, if some native API can significantly boost application performance). Otherwise you should really be careful when considering FFM API as an option.

Implementation

The overall solution is simple (here’s an  example project). First, you wrap your native calls using FFM API (we’ll dive into this in a moment). For our task we need sd_journal_send_with_location from libsystemd. This function allows sending a message to journald with additional parameters such as source file name and line number.

Once done, create a custom ch.qos.logback.core.Appender:

public class JournaldAppender extends AppenderBase<ILoggingEvent> {

  // simplified example, consult repository for actual code
  @Override
  protected void append(final ILoggingEvent eventObject) {
    final var invoker = SystemdJournal.sd_journal_send_with_location.makeInvoker(
        // message
        SystemdJournal.C_POINTER,
        // priority
        SystemdJournal.C_POINTER,
        // trailing null pointer
        SystemdJournal.C_POINTER
    );

    final String codeFile = getCodeFile(eventObject);
    final String codeLine = getCodeLine(eventObject);
    final String codeFunc = getCodeFunc(eventObject);
    final String message = getMessage(eventObject);
    final String priority = getPriority(eventObject);

    try (final var arena = Arena.ofConfined()) {
      invoker.apply(
          arena.allocateFrom(codeFile),
          arena.allocateFrom(codeLine),
          arena.allocateFrom(codeFunc),

          arena.allocateFrom("MESSAGE=%s"),
          arena.allocateFrom(message),

          arena.allocateFrom(priority),
          MemorySegment.NULL
      );
    } catch (final Exception e) {
      addError("Failed to invoke sd_journal_send_with_location", e);
    }
  }
}

Finally, provide Logback configuration which uses your newly created appender:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender class="com.github.JournaldAppender" name="journald">
    <encoder>
      <pattern>%thread %logger.%M\(%line{5}\) - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="debug">
    <appender-ref ref="journald"/>
  </root>
</configuration>

Logs might not display if something is wrong with appender. In that case, we can use configuration-level attribute debug to display additional Logback output:

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">
  <!-- omitted for brevity -->
</configuration>

And that’s it. The only real difficulty is to get your native calls right. Wrapping native code pretty much comes down to describing memory layouts for different data types (for basic ones, such as int and boolean, and for complex ones, such as structures) and constructing function calls as specific Java objects. While it may sound easy, there are pitfalls, especially if you’re not as experienced in C or similar languages, and those pitfalls can be fatal for the application process.

There are essentially two ways to get our native code wrappers ready. The first approach — the dangerous one — is writing FFM code manually while consulting various sources such as Javadocs, search engines and LLMs — just like we usually write code in Java. The approach is dangerous because FFM API does not protect you from making silly mistakes like passing an int value where a pointer is expected. Consider this code for calling sd_journal_send_with_location and compare it with the previous example in JournaldAppender:

final var invoker = SystemdJournal.sd_journal_send_with_location.makeInvoker(
    // message
    SystemdJournal.C_POINTER,
    // priority
    SystemdJournal.C_POINTER,
    // trailing null pointer. Here we must actually use C_POINTER
    // instead of C_INT
    SystemdJournal.C_INT
);

try (final var arena = Arena.ofConfined()) {
  invoker.apply(
      arena.allocateFrom(codeFile),
      arena.allocateFrom(codeLine),
      arena.allocateFrom(codeFunc),

      arena.allocateFrom("MESSAGE=%s"),
      arena.allocateFrom(message),

      arena.allocateFrom(priority),
      0 // Passing 4-byte int where 8-byte null pointer is expected - wrong!
  );
}

This way of passing NULL to varargs results in a segmentation fault, leading to JVM crash, but the worst part is that it will actually work for the first hundreds or thousands of log messages! The problem is that we pass 4-byte zero value as an 8-byte pointer on a 64-bit system, resulting in undefined behavior.

So the less FFM code we write, the better. This is why FFM API developers provide us with the second, safer approach for writing FFM code – we can generate a good portion of boilerplate with the help of  jextract. jextract is a simple CLI utility which takes C header files as input and produces ready-to-use Java sources:

./jextract-22/bin/jextract \
    --target-package com.github \
    --header-class-name SystemdJournal \
    --output src/main/java \
    --library systemd \
    --include-function sd_journal_send_with_location \
    /usr/include/systemd/sd-journal.h

This command will analyze sd-journal.h, looking for sd_journal_send_with_location function, and generate com.github.SystemdJournal Java class at src/main/java/com/github/SystemdJournal.java containing lots of FFM API boilerplate code, leaving you with only the necessary parts for calling the function.

In order to call a function from some native library, generated code must first find and load it at runtime — this is what --library argument is for. It’s possible to load a native library by name or by exact path to some .so or .dll file. In the command example above, we specify the library by name (--library systemd). Of course, in order to load a library by name, it must be installed system-wide (e.g., with libsystemd-dev apt package under Ubuntu).

Libraries can contain dozens of methods and structures that we don’t really need. Generating code for such redundant stuff might result in an overwhelming amount of code that is never going to be used. For such case, jextract allows filtering out unnecessary declarations from code generation with the help of --include-... options (--include-function, --include-struct, etc.). For our purposes, we only need sd_journal_send_with_location function, specified as --include-function sd_journal_send_with_location.

Note that you do not need to integrate jextract code generation into your build process. As mentioned in the jextract usage guide:

Jextract assumes that the version of a native library that a  project uses is relatively stable. Therefore, jextract is intended to be run once, and then for the generated sources to be added to  the project. Jextract only needs to be run again when the native library, or jextract itself are updated.