| 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 |
|
| 103 |
1.1 |
|
| 105 |
1.1 |
|
| 106 |
1.1 |
|
| 108 |
1.1 |
|
| 110 |
1.1 |
|
| 119 |
1.1 |
|
| 123 |
1.1 2.2 |
|
| 132 |
1.1 |
|
| 134 |
1.1 |
|
| 138 |
1.1 2.2 |
|
| 141 |
1.1 |
|
| 142 |
1.1 2.2 3.3 |
|
| 144 |
1.1 |
|
| 153 |
1.1 2.2 3.3 |
|
| 161 |
1.1 |