Structured Logging

Write logs as one JSON object per line with named fields, so log platforms can search, filter and count them, and attackers cannot forge lines.

Syntax{"ts":"...","level":"INFO","logger":"orders","msg":"Order placed","orderId":"A-1001","ms":42} handler.setFormatter(new JsonFormatter()); // java.util.logging # application.properties, Spring Boot 3.4+ logging.structured.format.console=ecs

A plain log line is written for a person reading a terminal: Order A-1001 placed for ada in 42 ms. Once a service runs as ten containers shipping logs to Elasticsearch, Loki, CloudWatch or Datadog, the reader is a machine. To find every slow order you would have to parse that sentence with a regular expression, and the first time someone rewords the message the query silently stops matching.

Structured logging writes each event as data, almost always one JSON object per line:

{"ts":"2026-10-13T09:30:00Z","level":"INFO","logger":"orders","msg":"Order placed","orderId":"A-1001","customer":"ada","ms":42}

The message becomes a fixed label for the event, and everything that varies goes in named fields. Queries such as msg = "Order placed" AND ms > 500 are then exact and fast, and dashboards can count and chart fields directly.

How Java services do it

The JDK has no JSON log formatter, but the pieces are small: a Formatter for java.util.logging that turns a LogRecord into a JSON line, as in the programs on this page. In practice most services get it from their stack: Spring Boot 3.4 and later has built-in structured console logging (logging.structured.format.console=ecs, with logstash and gelf as the other formats), and SLF4J 2.x's addKeyValue lets fields travel with the event rather than inside the text.

Context on every line

The most useful fields are ones you did not type in each call: request id, user id, tenant, trace id. Put them in a context once per request (MDC in SLF4J, or a ScopedValue in plain Java 25) and let the formatter add them. Then one query on requestId returns every line a request produced, across all classes.

Mistakes people make

  • Building JSON with string concatenation. A quote or newline in a value breaks the line and the collector drops it. Every string must be escaped.
  • Trusting plain-text logs with user input. A username containing a newline can forge a whole fake log line (log injection). JSON escaping turns the newline into \n inside one field.
  • Changing a field's type. If ms is a number in one service and a string in another, many log stores reject or mis-index one of them. Keep names and types consistent across services.
  • Logging secrets and personal data. Fields make it easy to dump a whole request object. Log identifiers, never passwords, tokens or full card numbers.

Keep the human-readable pattern for local development and switch to JSON by configuration in deployed environments.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Ship JSON logs from containers to Elasticsearch or Loki so on-call engineers can filter by orderId or status in seconds.
  • Attach requestId, tenant and userId to every line through request context so one query reconstructs a whole request across classes.
  • Escape user-supplied values such as usernames and search terms so they cannot forge log lines in the audit trail.

More examples

Log injection: plain text versus JSON

In plain text the attacker's newline produces a forged success line that looks real. In JSON the newline is escaped as \n, so the whole value stays inside one username field of one event.

Example · java
import java.util.logging.*;
import java.util.logging.Formatter;

// One JSON object per line: what log collectors expect.
class JsonFormatter extends Formatter {
    @Override public String format(LogRecord r) {
        StringBuilder sb = new StringBuilder("{");
        field(sb, "ts", r.getInstant().toString());
        sb.append(',');
        field(sb, "level", r.getLevel().getName());
        sb.append(',');
        field(sb, "logger", r.getLoggerName());
        sb.append(',');
        field(sb, "msg", r.getMessage());
        if (r.getParameters() != null && r.getParameters().length > 0
                && r.getParameters()[0] instanceof Map<?, ?> fields) {
            fields.forEach((k, v) -> {
                sb.append(',');
                field(sb, k.toString(), v);
            });
        }
        return sb.append("}\n").toString();   // a Handler writes exactly what format returns
    }

    static void field(StringBuilder sb, String key, Object value) {
        sb.append(quote(key)).append(':');
        if (value instanceof Boolean || (value instanceof Number n && Double.isFinite(n.doubleValue()))) sb.append(value);
        else sb.append(quote(String.valueOf(value)));
    }

    static String quote(String s) {
        StringBuilder out = new StringBuilder("\"");
        for (char ch : s.toCharArray()) {
            switch (ch) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (ch < 0x20) out.append(String.format("\\u%04x", (int) ch));
                    else out.append(ch);
                }
            }
        }
        return out.append('"').toString();
    }
}

void main() {
    // A login attempt whose username contains a newline and a fake second entry.
    String username = "eve\nINFO auth - Login succeeded for admin";

    IO.println("plain text:");
    IO.println("WARNING auth - Login failed for " + username);

    IO.println("json:");
    LogRecord r = new LogRecord(Level.WARNING, "Login failed");
    r.setLoggerName("auth");
    r.setInstant(Instant.parse("2026-10-13T09:31:00Z"));
    r.setParameters(new Object[] {Map.of("username", username)});
    IO.print(new JsonFormatter().format(r));
}
// Output:
// plain text:
// WARNING auth - Login failed for eve
// INFO auth - Login succeeded for admin
// json:
// {"ts":"2026-10-13T09:31:00Z","level":"WARNING","logger":"auth","msg":"Login failed","username":"eve\nINFO auth - Login succeeded for admin"}

A request id on every line with ScopedValue

The repository logs without knowing the request id, yet its line carries it because the handler reads the ScopedValue bound for that request. Outside any request the value is unbound and the field shows a dash. The messages here are fixed labels, so this short demo skips escaping; real values need the escaping shown above.

Example · java
import java.util.logging.*;

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

// The handler adds the current request id, so callers never pass it around.
void install() {
    Logger root = Logger.getLogger("");
    for (Handler h : root.getHandlers()) root.removeHandler(h);
    root.addHandler(new Handler() {
        @Override public void publish(LogRecord r) {
            String rid = REQUEST_ID.isBound() ? REQUEST_ID.get() : "-";
            IO.println("{\"requestId\":\"" + rid + "\",\"logger\":\"" + r.getLoggerName()
                    + "\",\"msg\":\"" + r.getMessage() + "\"}");
        }
        @Override public void flush() {}
        @Override public void close() {}
    });
}

class Repository {
    private static final System.Logger LOG = System.getLogger("repo");
    void save(String id) { LOG.log(System.Logger.Level.INFO, "Saved"); }
}

void handle(String requestId, Repository repo) {
    ScopedValue.where(REQUEST_ID, requestId).run(() -> {
        System.getLogger("web").log(System.Logger.Level.INFO, "Request received");
        repo.save("A-1");
    });
}

void main() {
    install();
    Repository repo = new Repository();
    handle("req-7f3a", repo);
    handle("req-91bc", repo);
    System.getLogger("app").log(System.Logger.Level.INFO, "Idle");
}
// Output:
// {"requestId":"req-7f3a","logger":"web","msg":"Request received"}
// {"requestId":"req-7f3a","logger":"repo","msg":"Saved"}
// {"requestId":"req-91bc","logger":"web","msg":"Request received"}
// {"requestId":"req-91bc","logger":"repo","msg":"Saved"}
// {"requestId":"-","logger":"app","msg":"Idle"}

Testing the formatter's escaping

Quotes, backslashes, tabs and other control characters are the inputs that break hand-rolled JSON. Checking them directly is a cheap test that keeps every log line parseable.

Example · java
import java.util.logging.*;
import java.util.logging.Formatter;

// One JSON object per line: what log collectors expect.
class JsonFormatter extends Formatter {
    @Override public String format(LogRecord r) {
        StringBuilder sb = new StringBuilder("{");
        field(sb, "ts", r.getInstant().toString());
        sb.append(',');
        field(sb, "level", r.getLevel().getName());
        sb.append(',');
        field(sb, "logger", r.getLoggerName());
        sb.append(',');
        field(sb, "msg", r.getMessage());
        if (r.getParameters() != null && r.getParameters().length > 0
                && r.getParameters()[0] instanceof Map<?, ?> fields) {
            fields.forEach((k, v) -> {
                sb.append(',');
                field(sb, k.toString(), v);
            });
        }
        return sb.append("}\n").toString();   // a Handler writes exactly what format returns
    }

    static void field(StringBuilder sb, String key, Object value) {
        sb.append(quote(key)).append(':');
        if (value instanceof Boolean || (value instanceof Number n && Double.isFinite(n.doubleValue()))) sb.append(value);
        else sb.append(quote(String.valueOf(value)));
    }

    static String quote(String s) {
        StringBuilder out = new StringBuilder("\"");
        for (char ch : s.toCharArray()) {
            switch (ch) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (ch < 0x20) out.append(String.format("\\u%04x", (int) ch));
                    else out.append(ch);
                }
            }
        }
        return out.append('"').toString();
    }
}

void main() {
    List<String> inputs = List.of("plain", "say \"hi\"", "C:\\temp", "tab\there", "bell\u0007");
    for (String s : inputs) {
        IO.println(JsonFormatter.quote(s));
    }
}
// Output:
// "plain"
// "say \"hi\""
// "C:\\temp"
// "tab\there"
// "bell\u0007"

Discussion

  • Be the first to comment on this lesson.