diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Fields.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Fields.java new file mode 100644 index 000000000000..45beeaa8e3b0 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Fields.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.logging.json; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * Collection of {@link Field fields}. + * + * @author Moritz Halbritter + * @since 3.4.0 + */ +public class Fields implements Iterable { + + private final List fields = new ArrayList<>(); + + /** + * Adds the given field. + * @param field the field to add + */ + public void add(Field field) { + this.fields.add(field); + } + + /** + * Adds the given key and value. + * @param key the key to add + * @param value the value to add + */ + public void add(Key key, Value value) { + this.fields.add(Field.of(key, value)); + } + + /** + * Adds all given fields. + * @param fields the fields to add + */ + public void addAll(Iterable fields) { + for (Field field : fields) { + this.fields.add(field); + } + } + + /** + * Adds all given fields. + * @param fields the fields to add + */ + public void addAll(Field... fields) { + this.fields.addAll(Arrays.asList(fields)); + } + + @Override + public Iterator iterator() { + return this.fields.iterator(); + } + + /** + * Creates a new instance with the given fields + * @param fields the fields + * @return the new instance + */ + public static Fields of(Iterable fields) { + Fields result = new Fields(); + result.addAll(fields); + return result; + } + + /** + * Creates a new instance with the given fields. + * @param fields the fields + * @return the new instance + */ + public static Fields of(Field... fields) { + Fields result = new Fields(); + result.addAll(fields); + return result; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonFormat.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonFormat.java index 2c3cf6f886df..663ccb1b60b2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonFormat.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonFormat.java @@ -45,6 +45,6 @@ default void setServiceEnvironment(String serviceEnvironment) { * @param event the event to log * @return the fields to write */ - Iterable getFields(E event); + Fields getFields(E event); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonHelper.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonHelper.java index 990ae00c873b..b43a791ab4b9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonHelper.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/JsonHelper.java @@ -34,13 +34,14 @@ static void escape(char c, StringBuilder output) { // int, // java.lang.StringBuilder) switch (c) { + case '"' -> output.append("\\\""); + case '\\' -> output.append("\\\\"); + case '/' -> output.append("\\/"); case '\b' -> output.append("\\b"); - case '\t' -> output.append("\\t"); case '\f' -> output.append("\\f"); case '\n' -> output.append("\\n"); case '\r' -> output.append("\\r"); - case '"' -> output.append("\\\""); - case '\\' -> output.append("\\\\"); + case '\t' -> output.append("\\t"); default -> output.append(c); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Value.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Value.java index 5c35a7ab6924..786270def662 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Value.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/json/Value.java @@ -78,4 +78,22 @@ static Value of(long value) { return (output) -> output.append(value); } + /** + * Creates a value of the given {@code boolean}. + * @param value the boolean value + * @return the created value + */ + static Value of(boolean value) { + return (output) -> output.append(value); + } + + /** + * Creates a value of the given {@code double}. + * @param value the double value + * @return the created value + */ + static Value of(double value) { + return (output) -> output.append(value); + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CommonJsonFormats.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CommonJsonFormats.java index c70279c5d662..e0729e873740 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CommonJsonFormats.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CommonJsonFormats.java @@ -19,7 +19,6 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; @@ -37,6 +36,7 @@ import org.slf4j.event.KeyValuePair; import org.springframework.boot.logging.json.Field; +import org.springframework.boot.logging.json.Fields; import org.springframework.boot.logging.json.Key; import org.springframework.boot.logging.json.Value; import org.springframework.util.Assert; @@ -161,42 +161,42 @@ String getServiceEnvironment() { private static final class EcsJsonFormat extends BaseLogbackJsonFormat { @Override - public Iterable getFields(ILoggingEvent event) { - List fields = new ArrayList<>(); - fields.add(Field.of(Key.verbatim("@timestamp"), Value.verbatim(event.getInstant().toString()))); - fields.add(Field.of(Key.verbatim("log.level"), Value.verbatim(event.getLevel().toString()))); + public Fields getFields(ILoggingEvent event) { + Fields fields = new Fields(); + fields.add(Key.verbatim("@timestamp"), Value.verbatim(event.getInstant().toString())); + fields.add(Key.verbatim("log.level"), Value.verbatim(event.getLevel().toString())); if (getPid() != null) { - fields.add(Field.of(Key.verbatim("process.pid"), Value.of(getPid()))); + fields.add(Key.verbatim("process.pid"), Value.of(getPid())); } - fields.add(Field.of(Key.verbatim("process.thread.name"), Value.escaped(event.getThreadName()))); + fields.add(Key.verbatim("process.thread.name"), Value.escaped(event.getThreadName())); if (getServiceName() != null) { - fields.add(Field.of(Key.verbatim("service.name"), Value.escaped(getServiceName()))); + fields.add(Key.verbatim("service.name"), Value.escaped(getServiceName())); } if (getServiceVersion() != null) { - fields.add(Field.of(Key.verbatim("service.version"), Value.escaped(getServiceVersion()))); + fields.add(Key.verbatim("service.version"), Value.escaped(getServiceVersion())); } if (getServiceEnvironment() != null) { - fields.add(Field.of(Key.verbatim("service.environment"), Value.escaped(getServiceEnvironment()))); + fields.add(Key.verbatim("service.environment"), Value.escaped(getServiceEnvironment())); } if (getServiceNodeName() != null) { - fields.add(Field.of(Key.verbatim("service.node.name"), Value.escaped(getServiceNodeName()))); + fields.add(Key.verbatim("service.node.name"), Value.escaped(getServiceNodeName())); } - fields.add(Field.of(Key.verbatim("log.logger"), Value.escaped(event.getLoggerName()))); - fields.add(Field.of(Key.verbatim("message"), Value.escaped(event.getFormattedMessage()))); + fields.add(Key.verbatim("log.logger"), Value.escaped(event.getLoggerName())); + fields.add(Key.verbatim("message"), Value.escaped(event.getFormattedMessage())); addMdc(event, fields); addKeyValuePairs(event, fields); IThrowableProxy throwable = event.getThrowableProxy(); if (throwable != null) { - fields.add(Field.of(Key.verbatim("error.type"), Value.verbatim(throwable.getClassName()))); - fields.add(Field.of(Key.verbatim("error.message"), Value.escaped(throwable.getMessage()))); - fields.add(Field.of(Key.verbatim("error.stack_trace"), - Value.escaped(getThrowableProxyConverter().convert(event)))); + fields.add(Key.verbatim("error.type"), Value.verbatim(throwable.getClassName())); + fields.add(Key.verbatim("error.message"), Value.escaped(throwable.getMessage())); + fields.add(Key.verbatim("error.stack_trace"), + Value.escaped(getThrowableProxyConverter().convert(event))); } - fields.add(Field.of(Key.verbatim("ecs.version"), Value.verbatim("8.11"))); + fields.add(Key.verbatim("ecs.version"), Value.verbatim("8.11")); return fields; } - private void addKeyValuePairs(ILoggingEvent event, List fields) { + private void addKeyValuePairs(ILoggingEvent event, Fields fields) { List keyValuePairs = event.getKeyValuePairs(); if (CollectionUtils.isEmpty(keyValuePairs)) { return; @@ -207,7 +207,7 @@ private void addKeyValuePairs(ILoggingEvent event, List fields) { } } - private static void addMdc(ILoggingEvent event, List fields) { + private static void addMdc(ILoggingEvent event, Fields fields) { Map mdc = event.getMDCPropertyMap(); if (CollectionUtils.isEmpty(mdc)) { return; @@ -222,29 +222,27 @@ private static void addMdc(ILoggingEvent event, List fields) { private static final class LogstashJsonFormat extends BaseLogbackJsonFormat { @Override - public Iterable getFields(ILoggingEvent event) { - List fields = new ArrayList<>(); + public Fields getFields(ILoggingEvent event) { + Fields fields = new Fields(); OffsetDateTime time = OffsetDateTime.ofInstant(event.getInstant(), ZoneId.systemDefault()); - fields.add(Field.of(Key.verbatim("@timestamp"), - Value.verbatim(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(time)))); - fields.add(Field.of(Key.verbatim("@version"), Value.verbatim("1"))); - fields.add(Field.of(Key.verbatim("message"), Value.escaped(event.getFormattedMessage()))); - fields.add(Field.of(Key.verbatim("logger_name"), Value.escaped(event.getLoggerName()))); - fields.add(Field.of(Key.verbatim("thread_name"), Value.escaped(event.getThreadName()))); - fields.add(Field.of(Key.verbatim("level"), Value.escaped(event.getLevel().toString()))); - fields.add(Field.of(Key.verbatim("level_value"), Value.of(event.getLevel().toInt()))); + fields.add(Key.verbatim("@timestamp"), Value.verbatim(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(time))); + fields.add(Key.verbatim("@version"), Value.verbatim("1")); + fields.add(Key.verbatim("message"), Value.escaped(event.getFormattedMessage())); + fields.add(Key.verbatim("logger_name"), Value.escaped(event.getLoggerName())); + fields.add(Key.verbatim("thread_name"), Value.escaped(event.getThreadName())); + fields.add(Key.verbatim("level"), Value.escaped(event.getLevel().toString())); + fields.add(Key.verbatim("level_value"), Value.of(event.getLevel().toInt())); addMdc(event, fields); addKeyValuePairs(event, fields); addMarkers(event, fields); IThrowableProxy throwable = event.getThrowableProxy(); if (throwable != null) { - fields.add(Field.of(Key.verbatim("stack_trace"), - Value.escaped(getThrowableProxyConverter().convert(event)))); + fields.add(Key.verbatim("stack_trace"), Value.escaped(getThrowableProxyConverter().convert(event))); } return fields; } - private void addMarkers(ILoggingEvent event, List fields) { + private void addMarkers(ILoggingEvent event, Fields fields) { List markers = event.getMarkerList(); if (CollectionUtils.isEmpty(markers)) { return; @@ -266,7 +264,7 @@ private void addTag(Marker marker, Collection tags) { } } - private void addKeyValuePairs(ILoggingEvent event, List fields) { + private void addKeyValuePairs(ILoggingEvent event, Fields fields) { List keyValuePairs = event.getKeyValuePairs(); if (CollectionUtils.isEmpty(keyValuePairs)) { return; @@ -277,7 +275,7 @@ private void addKeyValuePairs(ILoggingEvent event, List fields) { } } - private static void addMdc(ILoggingEvent event, List fields) { + private static void addMdc(ILoggingEvent event, Fields fields) { Map mdc = event.getMDCPropertyMap(); if (CollectionUtils.isEmpty(mdc)) { return; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/JsonEncoder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/JsonEncoder.java index 0b8440085880..9a42e4b26e39 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/JsonEncoder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/JsonEncoder.java @@ -137,22 +137,22 @@ public byte[] headerBytes() { public byte[] encode(ILoggingEvent event) { StringBuilder output = new StringBuilder(); output.append('{'); + boolean appendedComma = false; for (Field field : this.format.getFields(event)) { field.write(output); output.append(','); + appendedComma = true; + } + if (appendedComma) { + removeTrailingComma(output); } - removeTrailingComma(output); output.append('}'); output.append('\n'); return output.toString().getBytes(StandardCharsets.UTF_8); } private void removeTrailingComma(StringBuilder output) { - int length = output.length(); - char end = output.charAt(length - 1); - if (end == ',') { - output.setLength(length - 1); - } + output.setLength(output.length() - 1); } @Override diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-simple/src/main/java/smoketest/simple/CustomJsonFormat.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-simple/src/main/java/smoketest/simple/CustomJsonFormat.java index 62810ce52399..59891d17b5cb 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-simple/src/main/java/smoketest/simple/CustomJsonFormat.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-simple/src/main/java/smoketest/simple/CustomJsonFormat.java @@ -16,11 +16,9 @@ package smoketest.simple; -import java.util.List; - import ch.qos.logback.classic.spi.ILoggingEvent; -import org.springframework.boot.logging.json.Field; +import org.springframework.boot.logging.json.Fields; import org.springframework.boot.logging.json.Key; import org.springframework.boot.logging.json.Value; import org.springframework.boot.logging.logback.LogbackJsonFormat; @@ -33,9 +31,11 @@ class CustomJsonFormat implements LogbackJsonFormat { @Override - public Iterable getFields(ILoggingEvent event) { - return List.of(Field.of(Key.verbatim("epoch"), Value.of(event.getInstant().toEpochMilli())), - Field.of(Key.verbatim("message"), Value.escaped(event.getFormattedMessage()))); + public Fields getFields(ILoggingEvent event) { + Fields fields = new Fields(); + fields.add(Key.verbatim("epoch"), Value.of(event.getInstant().toEpochMilli())); + fields.add(Key.verbatim("message"), Value.escaped(event.getFormattedMessage())); + return fields; } }