JsonLineFormatter.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.Instant;
import java.util.logging.Formatter;
import java.util.logging.LogRecord;

/**
 * {@link java.util.logging.Formatter} that emits one JSON object per log
 * record, on a single line, suitable for ingestion by log-aggregation
 * pipelines (Elastic, Splunk, Loki) and for reproducible audit-trail
 * archives.
 *
 * <p>
 * Acts as the JUL-native equivalent of an SLF4J / Logback JSON encoder.
 * The MethodAtlas codebase stays on the classic {@code java.util.logging}
 * stack (no SLF4J / Log4j dependency) per project convention; this
 * formatter delivers structured-logging semantics without adding any
 * third-party logging library.
 * </p>
 *
 * <h2>Output schema</h2>
 *
 * <p>
 * Each {@link LogRecord} becomes one line of UTF-8 text containing a JSON
 * object with the following fields:
 * </p>
 * <ul>
 *   <li>{@code timestamp} — ISO-8601 instant ({@code 2026-05-27T13:45:06Z})</li>
 *   <li>{@code level}     — JUL level name ({@code INFO}, {@code WARNING}, …)</li>
 *   <li>{@code logger}    — full logger name from
 *       {@link LogRecord#getLoggerName()}</li>
 *   <li>{@code thread}    — name of the thread that emitted the record</li>
 *   <li>{@code message}   — the formatted log message (with parameters
 *       interpolated)</li>
 *   <li>{@code runId}     — short correlation id from {@link ScanRunContext},
 *       omitted when no run is set on the current thread</li>
 *   <li>{@code thrown}    — string-rendered exception stack trace, present
 *       only when {@link LogRecord#getThrown()} is non-null</li>
 * </ul>
 *
 * <h2>Activation</h2>
 *
 * <p>
 * The formatter is not installed automatically. Activate it through a JUL
 * configuration file (commonly via the {@code -Djava.util.logging.config.file}
 * system property), or programmatically by attaching the formatter to a
 * {@link java.util.logging.Handler}:
 * </p>
 * <pre>{@code
 * Handler handler = new ConsoleHandler();
 * handler.setFormatter(new JsonLineFormatter());
 * Logger.getLogger("org.egothor.methodatlas").addHandler(handler);
 * }</pre>
 *
 * <h2>Thread safety</h2>
 *
 * <p>
 * The formatter is stateless and safe for concurrent use by multiple
 * handlers and threads.
 * </p>
 *
 * @see ScanRun
 * @see ScanRunContext
 * @since 1.0.0
 */
public final class JsonLineFormatter extends Formatter {

    /**
     * Highest control-code code point that JSON requires to be escaped as a
     * {@code \\u00XX} sequence. Characters strictly below {@code U+0020}
     * (space) are not valid unescaped inside a JSON string per RFC 8259.
     */
    private static final char JSON_CONTROL_BOUNDARY = 0x20;

    /**
     * Creates a new formatter. The class carries no instance state and is
     * safe to share across handlers.
     */
    public JsonLineFormatter() {
        super();
    }

    /**
     * Renders {@code record} as a single line of JSON terminated by
     * {@code \n}. The exact field set is documented on the class.
     *
     * @param record log record to render; must not be {@code null}
     * @return JSON-encoded log entry followed by a newline
     */
    @Override
    @SuppressWarnings("PMD.DoNotUseThreads") // MethodAtlas is a CLI tool, not a J2EE webapp; current-thread name is metadata, not concurrency
    public String format(LogRecord record) {
        StringBuilder buf = new StringBuilder(256);
        buf.append('{');
        appendField(buf, "timestamp",
                Instant.ofEpochMilli(record.getMillis()).toString(), true);
        appendField(buf, "level", record.getLevel().getName(), false);
        appendField(buf, "logger",
                record.getLoggerName() == null ? "" : record.getLoggerName(), false);
        appendField(buf, "thread", Thread.currentThread().getName(), false);
        appendField(buf, "message", formatMessage(record), false);

        ScanRunContext.current().ifPresent(run -> {
            buf.append(',');
            appendField(buf, "runId", run.runId(), true);
        });

        if (record.getThrown() != null) {
            buf.append(',');
            appendField(buf, "thrown", renderThrowable(record.getThrown()), true);
        }

        buf.append("}\n");
        return buf.toString();
    }

    private static void appendField(StringBuilder buf, String name, String value, boolean first) {
        if (!first) {
            buf.append(',');
        }
        buf.append('"').append(name).append("\":\"").append(escape(value)).append('"');
    }

    private static String renderThrowable(Throwable t) {
        StringWriter sw = new StringWriter();
        try (PrintWriter pw = new PrintWriter(sw)) {
            t.printStackTrace(pw);
        }
        return sw.toString();
    }

    private static String escape(String value) {
        if (value == null) {
            return "";
        }
        StringBuilder out = new StringBuilder(value.length() + 16);
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            switch (c) {
                case '"'  -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                case '\b' -> out.append("\\b");
                case '\f' -> out.append("\\f");
                default -> {
                    if (c < JSON_CONTROL_BOUNDARY) {
                        out.append(String.format("\\u%04x", (int) c));
                    } else {
                        out.append(c);
                    }
                }
            }
        }
        return out.toString();
    }
}