diff --git a/logging/jul/etc/spotbugs/exclude.xml b/logging/jul/etc/spotbugs/exclude.xml index 369f312280c..20297c179a4 100644 --- a/logging/jul/etc/spotbugs/exclude.xml +++ b/logging/jul/etc/spotbugs/exclude.xml @@ -1,7 +1,7 @@ - + diff --git a/logging/jul/pom.xml b/logging/jul/pom.xml index bf589f75022..315cb7ce2c9 100644 --- a/logging/jul/pom.xml +++ b/logging/jul/pom.xml @@ -44,6 +44,10 @@ io.helidon.common helidon-common + + io.helidon.metadata + helidon-metadata-hson + org.junit.jupiter junit-jupiter-api @@ -54,6 +58,11 @@ hamcrest-all test + + io.helidon.common.testing + helidon-common-testing-junit5 + test + diff --git a/logging/jul/src/main/java/io/helidon/logging/jul/HelidonConsoleHandler.java b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonConsoleHandler.java index 4ca8d088ac3..27f89fb6249 100644 --- a/logging/jul/src/main/java/io/helidon/logging/jul/HelidonConsoleHandler.java +++ b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonConsoleHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package io.helidon.logging.jul; import java.util.logging.Level; +import java.util.logging.LogManager; import java.util.logging.LogRecord; import java.util.logging.StreamHandler; @@ -35,9 +36,15 @@ public class HelidonConsoleHandler extends StreamHandler { * . */ public HelidonConsoleHandler() { + super(); setOutputStream(System.out); - setLevel(Level.ALL); // Handlers should not filter, loggers should - setFormatter(new HelidonFormatter()); + if (LogManager.getLogManager().getProperty(HelidonConsoleHandler.class.getName() + ".level") == null) { + setLevel(Level.ALL); // Handlers should not filter, loggers should + } + // only set this if none set + if (LogManager.getLogManager().getProperty(HelidonConsoleHandler.class.getName() + ".formatter") == null) { + setFormatter(new HelidonFormatter()); + } } @Override diff --git a/logging/jul/src/main/java/io/helidon/logging/jul/HelidonFormatter.java b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonFormatter.java index 6f1680223ba..3faa127b25d 100644 --- a/logging/jul/src/main/java/io/helidon/logging/jul/HelidonFormatter.java +++ b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonFormatter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2021 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,12 +36,13 @@ * It also supports replacement of {@code "!thread!"} with the current thread. */ public class HelidonFormatter extends SimpleFormatter { - private static final String THREAD = "thread"; - private static final String THREAD_TOKEN = "!" + THREAD + "!"; - private static final Pattern THREAD_PATTERN = Pattern.compile(THREAD_TOKEN); - private static final Pattern X_VALUE = Pattern.compile("(\\s?%X\\{)(\\S*?)(})"); - private static final Map PATTERN_CACHE = new HashMap<>(); - private static final String JUL_FORMAT_PROP_KEY = "java.util.logging.SimpleFormatter.format"; + static final String THREAD = "thread"; + static final String THREAD_TOKEN = "!" + THREAD + "!"; + static final Pattern THREAD_PATTERN = Pattern.compile(THREAD_TOKEN); + static final Pattern X_VALUE = Pattern.compile("(\\s?%X\\{)(\\S*?)(})"); + static final Map PATTERN_CACHE = new HashMap<>(); + static final String JUL_FORMAT_PROP_KEY = "java.util.logging.SimpleFormatter.format"; + private final String format = LogManager.getLogManager().getProperty(JUL_FORMAT_PROP_KEY); private final Set parsedProps = new HashSet<>(); private final boolean thread; @@ -57,18 +58,10 @@ public HelidonFormatter() { } } - @Override - public String format(LogRecord record) { - String message = thread ? thread() : format; - for (String parsedKey : parsedProps) { - String value = HelidonMdc.get(parsedKey).orElse(""); - message = PATTERN_CACHE.computeIfAbsent(parsedKey, key -> Pattern.compile("%X\\{" + key + "}")) - .matcher(message).replaceAll(value); - } - return formatRow(record, message); - } - - private String thread() { + /* + Replace the thread pattern in the format with the current thread value + */ + static String thread(String format) { String currentThread = Thread.currentThread().toString(); String message = PATTERN_CACHE.computeIfAbsent(THREAD, key -> Pattern.compile("%X\\{" + THREAD + "}")) .matcher(format).replaceAll(currentThread); @@ -76,9 +69,11 @@ private String thread() { return message; } - //Copied from SimpleFormatter - private String formatRow(LogRecord record, String format) { - ZonedDateTime zdt = ZonedDateTime.ofInstant( + /* + All parameters expected by simple formatter (and json formatter as well) + */ + static Object[] parameters(LogRecord record, String formattedMessage) { + var timestamp = ZonedDateTime.ofInstant( record.getInstant(), ZoneId.systemDefault()); String source; if (record.getSourceClassName() != null) { @@ -89,7 +84,7 @@ private String formatRow(LogRecord record, String format) { } else { source = record.getLoggerName(); } - String message = formatMessage(record); + String throwable = ""; if (record.getThrown() != null) { StringWriter sw = new StringWriter(); @@ -99,12 +94,31 @@ private String formatRow(LogRecord record, String format) { pw.close(); throwable = sw.toString(); } + + Object[] result = new Object[6]; + result[0] = timestamp; + result[1] = source; + result[2] = record.getLoggerName(); + result[3] = record.getLevel().getName(); + result[4] = formattedMessage; + result[5] = throwable; + + return result; + } + + @Override + public String format(LogRecord record) { + String message = thread ? thread(format) : format; + for (String parsedKey : parsedProps) { + String value = HelidonMdc.get(parsedKey).orElse(""); + message = PATTERN_CACHE.computeIfAbsent(parsedKey, key -> Pattern.compile("%X\\{" + key + "}")) + .matcher(message).replaceAll(value); + } + return formatRow(record, message); + } + + private String formatRow(LogRecord record, String format) { return String.format(format, - zdt, - source, - record.getLoggerName(), - record.getLevel().getLocalizedName(), - message, - throwable); + parameters(record, super.formatMessage(record))); } } diff --git a/logging/jul/src/main/java/io/helidon/logging/jul/HelidonJsonFormatter.java b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonJsonFormatter.java new file mode 100644 index 00000000000..868c8c1b605 --- /dev/null +++ b/logging/jul/src/main/java/io/helidon/logging/jul/HelidonJsonFormatter.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.logging.jul; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Formatter; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.helidon.logging.common.HelidonMdc; +import io.helidon.metadata.hson.Hson; + +import static io.helidon.logging.jul.HelidonFormatter.JUL_FORMAT_PROP_KEY; +import static io.helidon.logging.jul.HelidonFormatter.THREAD_TOKEN; + +/** + * A {@link java.util.logging.Formatter} that stores each log record as a single-line JSON value. + * It also replaces all occurrences of MDC tags like {@code %X{value}} with specific values, + * and supports replacement of {@code "!thread!"} with the current thread. + *

+ * The configuration should be done through property {@value #JSON_FORMAT_PROP_KEY}, that provides comma separated list of name to + * fields to log (references are the same as you would use in a {@link java.util.logging.SimpleFormatter}). + *

+ * The configuration falls back to property {@value io.helidon.logging.jul.HelidonFormatter#JUL_FORMAT_PROP_KEY}, + * and analyzes it to provide a "guessed" JSON structure. + *

+ * Example (and also the default format):
+ * {@value #DEFAULT_FORMAT} + */ +public class HelidonJsonFormatter extends Formatter { + static final String DEFAULT_FORMAT = "ts:%1$tQ,date:%1$tY.%1$tm.%1$td,time:%1$tH:%1$tM:%1$tS.%1$tL,level:%4$s,message:%5$s," + + "exception:%6$s,thread:!thread!,logger:%3$s"; + + private static final String JSON_FORMAT_PROP_KEY = "io.helidon.logging.jul.HelidonJsonFormatter.fields"; + + // formats we understand + private static final String EPOCH_MILLIS_FORMAT = "%1$tQ"; + private static final String YEAR_FORMAT = "%1$tY"; + private static final String MONTH_FORMAT = "%1$tm"; + private static final String DAY_FORMAT = "%1$td"; + private static final String HOUR_FORMAT = "%1$tH"; + private static final String MINUTE_FORMAT = "%1$tM"; + private static final String SECOND_FORMAT = "%1$tS"; + private static final String SOURCE_FORMAT = "%2$s"; + private static final String LOGGER_FORMAT = "%3$s"; + private static final String LEVEL_FORMAT = "%4$s"; + private static final String MESSAGE_FORMAT = "%5$s"; + private static final String EXCEPTION_FORMAT = "%6$s"; + + private final List formatters; + + /** + * Create new instance of the {@link io.helidon.logging.jul.HelidonJsonFormatter}. + */ + public HelidonJsonFormatter() { + String jsonFormat = LogManager.getLogManager().getProperty(JSON_FORMAT_PROP_KEY); + String julFormat = LogManager.getLogManager().getProperty(JUL_FORMAT_PROP_KEY); + if (jsonFormat == null && julFormat == null) { + jsonFormat = DEFAULT_FORMAT; + } + if (jsonFormat == null) { + this.formatters = guessFromSimpleFormat(julFormat); + } else { + this.formatters = fromJsonFormat(jsonFormat); + } + } + + HelidonJsonFormatter(String format, boolean jsonFormat) { + this.formatters = jsonFormat ? fromJsonFormat(format) : guessFromSimpleFormat(format); + } + + @Override + public String format(LogRecord record) { + var builder = Hson.Struct.builder(); + var params = HelidonFormatter.parameters(record, super.formatMessage(record)); + + formatters.forEach(formatter -> formatter.update(builder, params)); + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + builder.build() + .write(pw); + pw.println(); + pw.close(); + return sw.toString(); + } + + private List fromJsonFormat(String jsonFormat) { + return Stream.of(jsonFormat.split(",")) + .map(it -> new ValueFormatter(Field.create(jsonFormat, it))) + .collect(Collectors.toUnmodifiableList()); + } + + private List guessFromSimpleFormat(String julFormat) { + List result = new ArrayList<>(); + Map counters = new HashMap<>(); + String usedFormat = julFormat.replaceAll("%n", " "); + // spaces expected to separate "blocks" + for (String block : usedFormat.split(" ")) { + if (block.isBlank()) { + continue; + } + // now lets do some "magic" + if (block.contains(YEAR_FORMAT) && block.contains(MONTH_FORMAT) && block.contains(DAY_FORMAT)) { + if (block.contains(HOUR_FORMAT)) { + // full timestamp + result.add(new ValueFormatter(new Field(name(counters, "timestamp"), block))); + } else { + // date only + result.add(new ValueFormatter(new Field(name(counters, "date"), block))); + } + continue; + } + if (block.contains(HOUR_FORMAT) && block.contains(MINUTE_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "time"), block))); + continue; + } + + // now only create sections if it only contains one parameter + if (block.contains(SOURCE_FORMAT) && block.length() < 7) { + result.add(new ValueFormatter(new Field(name(counters, "source"), block))); + continue; + } + if (block.contains(LOGGER_FORMAT) && block.length() < 7) { + result.add(new ValueFormatter(new Field(name(counters, "logger"), block))); + continue; + } + if (block.contains(LEVEL_FORMAT) && block.length() < 7) { + result.add(new ValueFormatter(new Field(name(counters, "level"), block))); + continue; + } + if (block.contains(MESSAGE_FORMAT) && block.length() < 7) { + result.add(new ValueFormatter(new Field(name(counters, "message"), block))); + continue; + } + if (block.contains(EXCEPTION_FORMAT) && block.length() < 7) { + result.add(new ValueFormatter(new Field(name(counters, "exception"), block))); + continue; + } + if (block.contains(THREAD_TOKEN) && block.length() < THREAD_TOKEN.length() + 3) { + result.add(new ValueFormatter(new Field(name(counters, "thread"), block))); + continue; + } + + // now let's extract the parts + if (block.contains(EPOCH_MILLIS_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "ts"), EPOCH_MILLIS_FORMAT))); + } + if (block.contains(YEAR_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "year"), YEAR_FORMAT))); + } + if (block.contains(MONTH_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "month"), MONTH_FORMAT))); + } + if (block.contains(DAY_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "day"), DAY_FORMAT))); + } + if (block.contains(HOUR_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "hour"), HOUR_FORMAT))); + } + if (block.contains(MINUTE_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "minute"), MINUTE_FORMAT))); + } + if (block.contains(SECOND_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "second"), SECOND_FORMAT))); + } + if (block.contains(SOURCE_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "source"), SOURCE_FORMAT))); + } + if (block.contains(LOGGER_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "logger"), LOGGER_FORMAT))); + } + if (block.contains(LEVEL_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "level"), LEVEL_FORMAT))); + } + if (block.contains(MESSAGE_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "message"), MESSAGE_FORMAT))); + } + if (block.contains(EXCEPTION_FORMAT)) { + result.add(new ValueFormatter(new Field(name(counters, "exception"), EXCEPTION_FORMAT))); + } + if (block.contains(THREAD_TOKEN) || block.contains("%X{" + HelidonFormatter.THREAD + "}")) { + result.add(new ValueFormatter(new Field(name(counters, "thread"), THREAD_TOKEN))); + } + + // MDC support + Matcher matcher = HelidonFormatter.X_VALUE.matcher(usedFormat); + while (matcher.find()) { + String name = matcher.group(2); + if (!name.equals(HelidonFormatter.THREAD)) { + result.add(new ValueFormatter(new Field(name(counters, "X." + name), "%X{" + name + "}"))); + } + } + } + return result; + } + + private String name(Map counters, String name) { + if (counters.containsKey(name)) { + return name + "_" + counters.get(name).incrementAndGet(); + } else { + counters.put(name, new AtomicInteger()); + return name; + } + } + + private static class ValueFormatter { + private final Set parsedProps = new HashSet<>(); + private final String jsonName; + private final String format; + private final boolean thread; + + private ValueFormatter(Field field) { + this.jsonName = field.name(); + this.format = field.format(); + + this.thread = this.format.contains(THREAD_TOKEN) || this.format.contains("%X{" + HelidonFormatter.THREAD + "}"); + Matcher matcher = HelidonFormatter.X_VALUE.matcher(this.format); + while (matcher.find()) { + parsedProps.add(matcher.group(2)); + } + } + + private void update(Hson.Struct.Builder jsonBuilder, Object... parameters) { + + String message = thread ? HelidonFormatter.thread(format) : format; + for (String parsedKey : parsedProps) { + String value = HelidonMdc.get(parsedKey).orElse(""); + message = HelidonFormatter.PATTERN_CACHE + .computeIfAbsent(parsedKey, key -> Pattern.compile("%X\\{" + key + "}")) + .matcher(message) + .replaceAll(value); + } + String formattedValue = String.format(message, parameters); + if (!formattedValue.isBlank()) { + jsonBuilder.set(jsonName, formattedValue); + } + } + } + + private record Field(String name, String format) { + private static Field create(String format, String field) { + int index = field.indexOf(':'); + if (index == -1) { + throw new IllegalArgumentException("Invalid format definition for " + HelidonJsonFormatter.class.getSimpleName() + + ", each field must have field name followed by a colon with field " + + "value," + + " such as 'message:%5$s', but got: '" + field + "'. " + + "Full format: " + format); + } + return new Field(field.substring(0, index), field.substring(index + 1)); + } + } +} diff --git a/logging/jul/src/main/java/module-info.java b/logging/jul/src/main/java/module-info.java index e17f91fd07c..36facfd2116 100644 --- a/logging/jul/src/main/java/module-info.java +++ b/logging/jul/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2023 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,13 @@ module io.helidon.logging.jul { requires io.helidon.common; + requires io.helidon.metadata.hson; requires java.logging; requires transitive io.helidon.common.context; requires transitive io.helidon.logging.common; - exports io.helidon.logging.jul; + exports io.helidon.logging.jul; provides io.helidon.common.context.spi.DataPropagationProvider with io.helidon.logging.jul.JulMdcPropagator; provides io.helidon.logging.common.spi.MdcProvider with io.helidon.logging.jul.JulMdcProvider; diff --git a/logging/jul/src/test/java/io/helidon/logging/jul/JulJsonTest.java b/logging/jul/src/test/java/io/helidon/logging/jul/JulJsonTest.java new file mode 100644 index 00000000000..9183bcb66b1 --- /dev/null +++ b/logging/jul/src/test/java/io/helidon/logging/jul/JulJsonTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.logging.jul; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import io.helidon.logging.common.HelidonMdc; +import io.helidon.metadata.hson.Hson; + +import org.junit.jupiter.api.Test; + +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalEmpty; +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalValue; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +public class JulJsonTest { + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + private static final Instant INSTANT = Instant.parse("2007-12-03T10:15:30.00Z"); + private static final ZonedDateTime DATE_TIME = INSTANT.atZone(ZoneId.systemDefault()); + + private static final LogRecord LOG_RECORD; + private static final LogRecord LOG_RECORD_WITH_EXCEPTION; + + static { + LogRecord record = new LogRecord(Level.WARNING, "Message content"); + record.setInstant(INSTANT); + record.setLoggerName("LoggerName"); + record.setThrown(new IllegalStateException("Thrown")); + record.setSourceClassName("io.helidon.logging.jul.JulJsonTest"); + record.setSourceMethodName("testJsonDefaultFormat"); + LOG_RECORD_WITH_EXCEPTION = record; + + record = new LogRecord(Level.WARNING, "Message content"); + record.setInstant(INSTANT); + record.setLoggerName("LoggerName"); + record.setSourceClassName("io.helidon.logging.jul.JulJsonTest"); + record.setSourceMethodName("testJsonDefaultFormat"); + LOG_RECORD = record; + } + + @Test + public void testJsonDefaultFormat() throws InterruptedException { + HelidonJsonFormatter formatter = new HelidonJsonFormatter(HelidonJsonFormatter.DEFAULT_FORMAT, true); + + String threadName = "logging-jul-test-thread"; + + AtomicReference resultReference = new AtomicReference<>(); + Thread.ofVirtual() + .name(threadName) + .start(() -> { + resultReference.set(formatter.format(LOG_RECORD)); + }) + .join(Duration.ofSeconds(5)); + + String result = resultReference.get(); + var json = Hson.parse(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8))) + .asStruct(); + + assertThat(json.stringValue("ts"), optionalValue(is(String.valueOf(INSTANT.toEpochMilli())))); + assertThat(json.stringValue("date"), optionalValue(is(DATE_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("time"), optionalValue(is(TIME_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("level"), optionalValue(is("WARNING"))); + assertThat(json.stringValue("message"), optionalValue(is("Message content"))); + assertThat(json.stringValue("exception"), optionalEmpty()); + assertThat(json.stringValue("logger"), optionalValue(is("LoggerName"))); + assertThat(json.stringValue("thread"), optionalValue(containsString(threadName))); + } + + @Test + public void testJsonWithExceptionDefaultFormat() throws InterruptedException { + HelidonJsonFormatter formatter = new HelidonJsonFormatter(HelidonJsonFormatter.DEFAULT_FORMAT, true); + + String threadName = "logging-jul-test-thread"; + + AtomicReference resultReference = new AtomicReference<>(); + Thread.ofVirtual() + .name(threadName) + .start(() -> { + resultReference.set(formatter.format(LOG_RECORD_WITH_EXCEPTION)); + }) + .join(Duration.ofSeconds(1000)); + + String result = resultReference.get(); + var json = Hson.parse(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8))) + .asStruct(); + + assertThat(json.stringValue("date"), optionalValue(is(DATE_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("time"), optionalValue(is(TIME_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("level"), optionalValue(is("WARNING"))); + assertThat(json.stringValue("message"), optionalValue(is("Message content"))); + assertThat(json.stringValue("exception"), optionalValue(containsString("Thrown"))); + assertThat(json.stringValue("logger"), optionalValue(is("LoggerName"))); + assertThat(json.stringValue("thread"), optionalValue(containsString(threadName))); + } + + @Test + public void testJsonWithExceptionSimpleFormat() throws InterruptedException { + String simpleFormat = "%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$s %3$s !thread!: %5$s%6$s %X{test}%n"; + HelidonJsonFormatter formatter = new HelidonJsonFormatter(simpleFormat, false); + + String threadName = "logging-jul-test-thread"; + + AtomicReference resultReference = new AtomicReference<>(); + Thread.ofVirtual() + .name(threadName) + .start(() -> { + HelidonMdc.set("test", "testValue"); + resultReference.set(formatter.format(LOG_RECORD_WITH_EXCEPTION)); + }) + .join(Duration.ofSeconds(1000)); + + String result = resultReference.get(); + var json = Hson.parse(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8))) + .asStruct(); + + assertThat(json.stringValue("date"), optionalValue(is(DATE_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("time"), optionalValue(is(TIME_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("logger"), optionalValue(is("LoggerName"))); + assertThat(json.stringValue("level"), optionalValue(is("WARNING"))); + assertThat(json.stringValue("thread"), optionalValue(containsString(threadName))); + assertThat(json.stringValue("message"), optionalValue(is("Message content"))); + assertThat(json.stringValue("exception"), optionalValue(containsString("Thrown"))); + assertThat(json.stringValue("X.test"), optionalValue(containsString("testValue"))); + } + + @Test + public void testJsonWithExceptionCustomFormat() throws InterruptedException { + String format = "timestamp:%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL,loglevel:%4$s,test:%X{test},msg:%5$s,source:%2$s"; + HelidonJsonFormatter formatter = new HelidonJsonFormatter(format, true); + + String threadName = "logging-jul-test-thread"; + + AtomicReference resultReference = new AtomicReference<>(); + Thread.ofVirtual() + .name(threadName) + .start(() -> { + HelidonMdc.set("test", "testValue"); + resultReference.set(formatter.format(LOG_RECORD_WITH_EXCEPTION)); + }) + .join(Duration.ofSeconds(1000)); + + String result = resultReference.get(); + var json = Hson.parse(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8))) + .asStruct(); + + assertThat(json.stringValue("timestamp"), optionalValue(is(DATE_FORMAT.format(DATE_TIME) + " " + + TIME_FORMAT.format(DATE_TIME)))); + assertThat(json.stringValue("loglevel"), optionalValue(is("WARNING"))); + assertThat(json.stringValue("msg"), optionalValue(is("Message content"))); + assertThat(json.stringValue("test"), optionalValue(is("testValue"))); + assertThat(json.stringValue("source"), optionalValue(is("io.helidon.logging.jul.JulJsonTest" + + " testJsonDefaultFormat"))); + } +} + diff --git a/metadata/hson/src/main/java/io/helidon/metadata/hson/HsonValues.java b/metadata/hson/src/main/java/io/helidon/metadata/hson/HsonValues.java index 6c94cb8e497..54095e99492 100644 --- a/metadata/hson/src/main/java/io/helidon/metadata/hson/HsonValues.java +++ b/metadata/hson/src/main/java/io/helidon/metadata/hson/HsonValues.java @@ -76,13 +76,15 @@ private String quote(String value) { } private String escape(String string) { - return string.replaceAll("\n", "\\\\n") - .replaceAll("\"", "\\\\\"") - .replaceAll("\t", "\\\\\t") - .replaceAll("\r", "\\\\\r") - // replace two backslashes with four backslashes - .replaceAll("\\\\\\\\", "\\\\\\\\\\\\\\\\") - .replaceAll("\f", "\\\\\f"); + String result = string.replaceAll("\n", "\\\\n"); + + result = result.replaceAll("\"", "\\\\\""); + result = result.replaceAll("\t", "\\\\t"); + result = result.replaceAll("\r", "\\\\r"); + // replace two backslashes with four backslashes + result = result.replaceAll("\\\\\\\\", "\\\\\\\\\\\\\\\\"); + result = result.replaceAll("\f", "\\\\f"); + return result; } } diff --git a/tests/integration/packaging/se-1/src/main/resources/logging.properties b/tests/integration/packaging/se-1/src/main/resources/logging.properties index 48878bf93d9..c19a3babf9c 100644 --- a/tests/integration/packaging/se-1/src/main/resources/logging.properties +++ b/tests/integration/packaging/se-1/src/main/resources/logging.properties @@ -19,18 +19,11 @@ # Send messages to the console handlers=io.helidon.logging.jul.HelidonConsoleHandler - -# HelidonConsoleHandler uses a SimpleFormatter subclass that replaces "!thread!" with the current thread -java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n +# Use JSON formatter with default format +io.helidon.logging.jul.HelidonConsoleHandler.formatter=io.helidon.logging.jul.HelidonJsonFormatter # Global logging level. Can be overridden by specific loggers .level=INFO -# Component specific log levels -#io.helidon.webserver.level=INFO -#io.helidon.config.level=INFO -#io.helidon.security.level=INFO -#io.helidon.common.level=INFO - io.helidon.webserver.staticcontent.ClassPathContentHandler.level=FINEST io.helidon.webserver.staticcontent.FileSystemContentHandler.level=FINEST