JsonLineFormatter.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas;
5
6
import java.io.PrintWriter;
7
import java.io.StringWriter;
8
import java.time.Instant;
9
import java.util.logging.Formatter;
10
import java.util.logging.LogRecord;
11
12
/**
13
 * {@link java.util.logging.Formatter} that emits one JSON object per log
14
 * record, on a single line, suitable for ingestion by log-aggregation
15
 * pipelines (Elastic, Splunk, Loki) and for reproducible audit-trail
16
 * archives.
17
 *
18
 * <p>
19
 * Acts as the JUL-native equivalent of an SLF4J / Logback JSON encoder.
20
 * The MethodAtlas codebase stays on the classic {@code java.util.logging}
21
 * stack (no SLF4J / Log4j dependency) per project convention; this
22
 * formatter delivers structured-logging semantics without adding any
23
 * third-party logging library.
24
 * </p>
25
 *
26
 * <h2>Output schema</h2>
27
 *
28
 * <p>
29
 * Each {@link LogRecord} becomes one line of UTF-8 text containing a JSON
30
 * object with the following fields:
31
 * </p>
32
 * <ul>
33
 *   <li>{@code timestamp} — ISO-8601 instant ({@code 2026-05-27T13:45:06Z})</li>
34
 *   <li>{@code level}     — JUL level name ({@code INFO}, {@code WARNING}, …)</li>
35
 *   <li>{@code logger}    — full logger name from
36
 *       {@link LogRecord#getLoggerName()}</li>
37
 *   <li>{@code thread}    — name of the thread that emitted the record</li>
38
 *   <li>{@code message}   — the formatted log message (with parameters
39
 *       interpolated)</li>
40
 *   <li>{@code runId}     — short correlation id from {@link ScanRunContext},
41
 *       omitted when no run is set on the current thread</li>
42
 *   <li>{@code thrown}    — string-rendered exception stack trace, present
43
 *       only when {@link LogRecord#getThrown()} is non-null</li>
44
 * </ul>
45
 *
46
 * <h2>Activation</h2>
47
 *
48
 * <p>
49
 * The formatter is not installed automatically. Activate it through a JUL
50
 * configuration file (commonly via the {@code -Djava.util.logging.config.file}
51
 * system property), or programmatically by attaching the formatter to a
52
 * {@link java.util.logging.Handler}:
53
 * </p>
54
 * <pre>{@code
55
 * Handler handler = new ConsoleHandler();
56
 * handler.setFormatter(new JsonLineFormatter());
57
 * Logger.getLogger("org.egothor.methodatlas").addHandler(handler);
58
 * }</pre>
59
 *
60
 * <h2>Thread safety</h2>
61
 *
62
 * <p>
63
 * The formatter is stateless and safe for concurrent use by multiple
64
 * handlers and threads.
65
 * </p>
66
 *
67
 * @see ScanRun
68
 * @see ScanRunContext
69
 * @since 1.0.0
70
 */
71
public final class JsonLineFormatter extends Formatter {
72
73
    /**
74
     * Highest control-code code point that JSON requires to be escaped as a
75
     * {@code \\u00XX} sequence. Characters strictly below {@code U+0020}
76
     * (space) are not valid unescaped inside a JSON string per RFC 8259.
77
     */
78
    private static final char JSON_CONTROL_BOUNDARY = 0x20;
79
80
    /**
81
     * Creates a new formatter. The class carries no instance state and is
82
     * safe to share across handlers.
83
     */
84
    public JsonLineFormatter() {
85
        super();
86
    }
87
88
    /**
89
     * Renders {@code record} as a single line of JSON terminated by
90
     * {@code \n}. The exact field set is documented on the class.
91
     *
92
     * @param record log record to render; must not be {@code null}
93
     * @return JSON-encoded log entry followed by a newline
94
     */
95
    @Override
96
    @SuppressWarnings("PMD.DoNotUseThreads") // MethodAtlas is a CLI tool, not a J2EE webapp; current-thread name is metadata, not concurrency
97
    public String format(LogRecord record) {
98
        StringBuilder buf = new StringBuilder(256);
99
        buf.append('{');
100 1 1. format : removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED
        appendField(buf, "timestamp",
101
                Instant.ofEpochMilli(record.getMillis()).toString(), true);
102
        appendField(buf, "level", record.getLevel().getName(), false);
103 1 1. format : removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED
        appendField(buf, "logger",
104
                record.getLoggerName() == null ? "" : record.getLoggerName(), false);
105 1 1. format : removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED
        appendField(buf, "thread", Thread.currentThread().getName(), false);
106 1 1. format : removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED
        appendField(buf, "message", formatMessage(record), false);
107
108 1 1. format : removed call to java/util/Optional::ifPresent → KILLED
        ScanRunContext.current().ifPresent(run -> {
109
            buf.append(',');
110 1 1. lambda$format$0 : removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED
            appendField(buf, "runId", run.runId(), true);
111
        });
112
113
        if (record.getThrown() != null) {
114
            buf.append(',');
115
            appendField(buf, "thrown", renderThrowable(record.getThrown()), true);
116
        }
117
118
        buf.append("}\n");
119 1 1. format : replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::format → KILLED
        return buf.toString();
120
    }
121
122
    private static void appendField(StringBuilder buf, String name, String value, boolean first) {
123 2 1. appendField : removed conditional - replaced equality check with true → KILLED
2. appendField : removed conditional - replaced equality check with false → KILLED
        if (!first) {
124
            buf.append(',');
125
        }
126
        buf.append('"').append(name).append("\":\"").append(escape(value)).append('"');
127
    }
128
129
    private static String renderThrowable(Throwable t) {
130
        StringWriter sw = new StringWriter();
131
        try (PrintWriter pw = new PrintWriter(sw)) {
132 1 1. renderThrowable : removed call to java/lang/Throwable::printStackTrace → KILLED
            t.printStackTrace(pw);
133
        }
134 1 1. renderThrowable : replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::renderThrowable → KILLED
        return sw.toString();
135
    }
136
137
    private static String escape(String value) {
138 2 1. escape : removed conditional - replaced equality check with false → SURVIVED
2. escape : removed conditional - replaced equality check with true → KILLED
        if (value == null) {
139
            return "";
140
        }
141 1 1. escape : Replaced integer addition with subtraction → KILLED
        StringBuilder out = new StringBuilder(value.length() + 16);
142 3 1. escape : removed conditional - replaced comparison check with false → KILLED
2. escape : removed conditional - replaced comparison check with true → KILLED
3. escape : changed conditional boundary → KILLED
        for (int i = 0; i < value.length(); i++) {
143
            char c = value.charAt(i);
144 1 1. escape : Changed switch default to be first case → KILLED
            switch (c) {
145
                case '"'  -> out.append("\\\"");
146
                case '\\' -> out.append("\\\\");
147
                case '\n' -> out.append("\\n");
148
                case '\r' -> out.append("\\r");
149
                case '\t' -> out.append("\\t");
150
                case '\b' -> out.append("\\b");
151
                case '\f' -> out.append("\\f");
152
                default -> {
153 3 1. escape : changed conditional boundary → SURVIVED
2. escape : removed conditional - replaced comparison check with true → SURVIVED
3. escape : removed conditional - replaced comparison check with false → KILLED
                    if (c < JSON_CONTROL_BOUNDARY) {
154
                        out.append(String.format("\\u%04x", (int) c));
155
                    } else {
156
                        out.append(c);
157
                    }
158
                }
159
            }
160
        }
161 1 1. escape : replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::escape → KILLED
        return out.toString();
162
    }
163
}

Mutations

100

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED

103

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_isValidJsonWithExpectedFields()]
removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED

105

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_isValidJsonWithExpectedFields()]
removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED

106

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED

108

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_includesRunIdWhenScanRunSet()]
removed call to java/util/Optional::ifPresent → KILLED

110

1.1
Location : lambda$format$0
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_includesRunIdWhenScanRunSet()]
removed call to org/egothor/methodatlas/JsonLineFormatter::appendField → KILLED

119

1.1
Location : format
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::format → KILLED

123

1.1
Location : appendField
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : appendField
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed conditional - replaced equality check with false → KILLED

132

1.1
Location : renderThrowable
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_includesThrownWhenExceptionAttached()]
removed call to java/lang/Throwable::printStackTrace → KILLED

134

1.1
Location : renderThrowable
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_includesThrownWhenExceptionAttached()]
replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::renderThrowable → KILLED

138

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : escape
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

141

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
Replaced integer addition with subtraction → KILLED

142

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
removed conditional - replaced comparison check with true → KILLED

3.3
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
changed conditional boundary → KILLED

144

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
Changed switch default to be first case → KILLED

153

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesControlCharactersAsUnicode()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : escape
Killed by : none
changed conditional boundary → SURVIVED
Covering tests

3.3
Location : escape
Killed by : none
removed conditional - replaced comparison check with true → SURVIVED Covering tests

161

1.1
Location : escape
Killed by : org.egothor.methodatlas.JsonLineFormatterTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.JsonLineFormatterTest]/[method:output_escapesNewlinesAsBackslashN()]
replaced return value with "" for org/egothor/methodatlas/JsonLineFormatter::escape → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1