001package io.jstach.rainbowgum;
002
003import java.io.PrintWriter;
004import java.lang.System.Logger.Level;
005import java.net.URI;
006import java.nio.charset.StandardCharsets;
007import java.time.Instant;
008import java.time.ZoneId;
009import java.time.ZoneOffset;
010import java.time.format.DateTimeFormatter;
011import java.util.ArrayList;
012import java.util.Arrays;
013import java.util.List;
014
015import org.eclipse.jdt.annotation.Nullable;
016
017import io.jstach.rainbowgum.KeyValues.KeyValuesConsumer;
018import io.jstach.rainbowgum.LogFormatter.EventFormatter;
019import io.jstach.rainbowgum.LogFormatter.LevelFormatter;
020import io.jstach.rainbowgum.LogFormatter.ThrowableFormatter;
021import io.jstach.rainbowgum.LogFormatter.TimestampFormatter;
022
023/**
024 * Formats a log event using a {@link StringBuilder}. <strong>All formatters should be
025 * thread-safe!</strong>.
026 * <p>
027 * The appender will make sure the {@link StringBuilder} is not shared with multiple
028 * threads so the formatter does not have to synchronize/lock on and should definitely not
029 * do that.
030 * <p>
031 * Because of various invariants the preferred way to compose formatters is to use the
032 * {@linkplain #builder() builder} which will do some optimization like combining static
033 * formatters etc.
034 *
035 * @see #builder()
036 * @see LogFormatter.EventFormatter
037 * @see LogEncoder#of(LogFormatter)
038 * @apiNote This class is sealed. An interface that has the same contract that can be
039 * implemented is {@link EventFormatter}.
040 */
041public sealed interface LogFormatter {
042
043        /**
044         * Formats a log event.
045         * @param output buffer.
046         * @param event log event.
047         * @see EventFormatter
048         */
049        public void format(StringBuilder output, LogEvent event);
050
051        /**
052         * See {@link EventFormatter#builder()}.
053         * @return builder.
054         */
055        public static Builder builder() {
056                return new Builder();
057        }
058
059        /**
060         * Create a formatter using event formatter that is a lambda so that
061         * {@code LogFormater.of((o, e) -> ...); } works.
062         * @param e will be returned.
063         * @return passed in formatter.
064         * @apiNote this is for ergonomics because LogFormatter is sealed.
065         */
066        public static EventFormatter of(EventFormatter e) {
067                return e;
068        }
069
070        /**
071         * Ask the formatter if will do anything.
072         * @return true if promises not to write to builder.
073         * @see #noop()
074         */
075        default boolean isNoop() {
076                return NoopFormatter.INSTANCE == this;
077        }
078
079        /**
080         * A special formatter that will do nothing. It is a singleton so identity comparison
081         * can be used.
082         * @return a formatter that implements all formatting interfaces but does nothing.
083         */
084        public static NoopFormatter noop() {
085                return NoopFormatter.INSTANCE;
086        }
087
088        /**
089         * A special formatter that will append static text.
090         *
091         * @param content static text.
092         */
093        public record StaticFormatter(String content) implements LogFormatter {
094                @Override
095                public void format(StringBuilder output, LogEvent event) {
096                        output.append(content);
097                }
098
099                /**
100                 * Creates a new formatter by concat the {@link #content()}. This is mainly used
101                 * by the {@link EventFormatter#builder()} to coalesce multiple static text.
102                 * @param next the text that will follow this formatter.
103                 * @return new formatter.
104                 */
105                public StaticFormatter concat(StaticFormatter next) {
106                        return new StaticFormatter(this.content + next.content);
107                }
108
109                /**
110                 * Coalesce formatters that can be such as {@link StaticFormatter}.
111                 * @param formatters list of formatters in the order of which they will be
112                 * executed.
113                 * @return an array of formatters where static formatters next to each other will
114                 * be coalesced.
115                 */
116                private static LogFormatter[] coalesce(List<? extends LogFormatter> formatters) {
117                        var flattened = CompositeFormatter.flatten(formatters);
118                        List<LogFormatter> resolved = new ArrayList<>();
119                        StaticFormatter current = null;
120                        for (var f : flattened) {
121                                if (f.isNoop()) {
122                                        continue;
123                                }
124                                else if (current == null && f instanceof StaticFormatter sf) {
125                                        current = sf;
126                                }
127                                else if (current != null && f instanceof StaticFormatter sf) {
128                                        current = current.concat(sf);
129                                }
130                                else if (current != null) {
131                                        resolved.add(current);
132                                        resolved.add(f);
133                                        current = null;
134                                }
135                                else {
136                                        resolved.add(f);
137                                }
138                        }
139                        if (current != null) {
140                                resolved.add(current);
141                        }
142                        return resolved.toArray(new LogFormatter[] {});
143                }
144
145                @Override
146                public String toString() {
147                        return "STATIC['" + content + "']";
148                }
149        }
150
151        /**
152         * Log formatter builder that is composed of other formatters. The
153         * {@link #add(LogFormatter)} are executed in insertion order. <strong> This builder
154         * is smart and will coalesce and consolidate formatters! </strong> For example if
155         * only formatter is added to the builder it will be returned instead of a new
156         * formatter.
157         */
158        public final static class Builder {
159
160                private List<LogFormatter> formatters = new ArrayList<>();
161
162                private Builder() {
163                }
164
165                /**
166                 * Adds a formatter.
167                 * @param formatter formatter to be added the list of formatters.
168                 * @return this builder.
169                 */
170                public Builder add(LogFormatter formatter) {
171                        formatters.add(formatter);
172                        return this;
173                }
174
175                /**
176                 * Adds an event formatter.
177                 * @param formatter event formatter to be added the list of formatters.
178                 * @return this builder.
179                 */
180                public Builder event(LogFormatter.EventFormatter formatter) {
181                        formatters.add(formatter);
182                        return this;
183                }
184
185                /**
186                 * Append the timestamp in ISO format.
187                 * @return this builder.
188                 */
189                public Builder timeStamp() {
190                        formatters.add(DefaultInstantFormatter.ISO);
191                        return this;
192                }
193
194                /**
195                 * Formatter for {@link LogEvent#timestamp()} derived from standard
196                 * {@link DateTimeFormatter}.
197                 * @param dateTimeFormatter formatter for {@link LogEvent#timestamp()}
198                 * @return this builder.
199                 */
200                public Builder timeStamp(DateTimeFormatter dateTimeFormatter) {
201                        formatters.add(new DateTimeFormatterInstantFormatter(dateTimeFormatter));
202                        return this;
203                }
204
205                /**
206                 * Adds the default level formatter.
207                 * @return this builder.
208                 * @see LevelFormatter
209                 */
210                public Builder level() {
211                        formatters.add(LogFormatter.LevelFormatter.of());
212                        return this;
213                }
214
215                /**
216                 * Adds the default logger name formatter.
217                 * @return this builder.
218                 */
219                public Builder loggerName() {
220                        formatters.add(DefaultNameFormatter.LOGGER_NAME_FORMATTER);
221                        return this;
222                }
223
224                /**
225                 * Formats the message by calling
226                 * {@link LogEvent#formattedMessage(StringBuilder)}.
227                 * @return this builder.
228                 */
229                public Builder message() {
230                        formatters.add(DefaultMessageFormatter.MESSAGE_FORMATTER);
231                        return this;
232                }
233
234                /**
235                 * Appends static text.
236                 * @param content static text.
237                 * @return this builder.
238                 * @see StaticFormatter
239                 */
240                public Builder text(String content) {
241                        formatters.add(new StaticFormatter(content));
242                        return this;
243                }
244
245                /**
246                 * Creates a formatter that will print <strong>ALL</strong> of the key values by
247                 * percent encoding (RFC 3986 URI aka the format usually used in
248                 * {@link URI#getQuery()}).
249                 * @return formatter.
250                 */
251                public Builder keyValues() {
252                        return add(DefaultKeyValuesFormatter.INSTANCE);
253                }
254
255                /**
256                 * Creates a formatter that will print the key values in order of the passed in
257                 * keys if they exist in percent encoding (RFC 3986 URI aka the format usually
258                 * used in {@link URI#getQuery()}). <strong>An empty list is considered a noop and
259                 * no keys will be ommitted!</strong> If you want to all keys use
260                 * {@link #keyValues()}.
261                 * @param keys keys where order is important.
262                 * @return this.
263                 */
264                public Builder keyValues(List<String> keys) {
265                        if (keys.isEmpty()) {
266                                return this;
267                        }
268                        return add(new ListKeyValuesFormatter(keys));
269                }
270
271                /**
272                 * Creates a formatter that will print a single key value in percent encoding (RFC
273                 * 3986 URI aka the format usually used in {@link URI#getQuery()}).
274                 * @param key key to select.
275                 * @param fallback if the value is null the fallback will be used.
276                 * @return this.
277                 */
278                public Builder keyValue(String key, @Nullable String fallback) {
279                        return add(new SingleKeyValueFormatter(key, fallback));
280                }
281
282                /**
283                 * Appends a space.
284                 * @return this builder.
285                 */
286                public Builder space() {
287                        formatters.add(new StaticFormatter(" "));
288                        return this;
289                }
290
291                /**
292                 * Appends a newline using the platforms line separator.
293                 * @return this builder.
294                 */
295                public Builder newline() {
296                        text(System.lineSeparator());
297                        return this;
298                }
299
300                /**
301                 * Appends a thread name : {@link Thread#getName()}.
302                 * @return this builder.
303                 */
304                public Builder threadName() {
305                        formatters.add(DefaultThreadFormatter.THREAD_NAME_FORMATTER);
306                        return this;
307                }
308
309                /**
310                 * Appends a thread ID : {@link Thread#threadId()}.
311                 * @return this builder.
312                 */
313                public Builder threadId() {
314                        formatters.add(DefaultThreadFormatter.THREAD_ID_FORMATTER);
315                        return this;
316                }
317
318                /**
319                 * Appends the events throwable stack trace.
320                 * @return this.
321                 */
322                public Builder throwable() {
323                        formatters.add(ThrowableFormatter.of());
324                        return this;
325                }
326
327                /**
328                 * Will create a generic log formatter that has the inner formatters coalesced if
329                 * possible and will noop if there are no formatters.
330                 * @return flattened formatter.
331                 */
332                public LogFormatter build() {
333                        var array = StaticFormatter.coalesce(formatters);
334                        if (array.length == 0) {
335                                return NoopFormatter.INSTANCE;
336                        }
337                        if (array.length == 1) {
338                                return array[0];
339                        }
340                        return EventFormatter.of(formatters);
341                }
342
343                /**
344                 * Creates the formatter and converts it to an encoder.
345                 * @return encoder.
346                 * @apiNote for ergonomics
347                 */
348                public LogEncoder encoder() {
349                        return LogEncoder.of(build());
350                }
351
352        }
353
354        /**
355         * Generic event formatting that is lambda friendly.
356         */
357        @FunctionalInterface
358        public non-sealed interface EventFormatter extends LogFormatter {
359
360                @Override
361                public void format(StringBuilder output, LogEvent event);
362
363                private static EventFormatter of(List<? extends LogFormatter> formatters) {
364                        return new CompositeFormatter(StaticFormatter.coalesce(formatters));
365                }
366
367        }
368
369        /**
370         * Formats a {@link Level}.
371         */
372        public sealed interface LevelFormatter extends LogFormatter {
373
374                /**
375                 * Formats the level.
376                 * @param output buffer.
377                 * @param level level.
378                 */
379                void formatLevel(StringBuilder output, Level level);
380
381                @Override
382                default void format(StringBuilder output, LogEvent event) {
383                        formatLevel(output, event.level());
384                }
385
386                /**
387                 * Formats a level.
388                 * @param level level
389                 * @return formatted level as a string.
390                 */
391                default String formatLevel(Level level) {
392                        StringBuilder sb = new StringBuilder();
393                        formatLevel(sb, level);
394                        return sb.toString();
395                }
396
397                /**
398                 * Default implementation calls {@link LevelFormatter#toString(Level)}
399                 * @return formatter.
400                 */
401                public static LevelFormatter of() {
402                        return DefaultLevelFormatter.LEVEL_FORMATTER;
403                }
404
405                /**
406                 * Default implementation calls {@link LevelFormatter#rightPadded(Level)}
407                 * @return formatter.
408                 */
409                public static LevelFormatter ofRightPadded() {
410                        return DefaultLevelFormatter.RIGHT_PAD_LEVEL_FORMATTER;
411                }
412
413                /**
414                 * Turns a Level into a SLF4J like level String that is all upper case.
415                 * {@link Level#ALL} is "<code>TRACE</code>", {@link Level#OFF} is
416                 * "<code>ERROR</code>" and {@link Level#WARNING} is "<code>WARN</code>".
417                 * @param level system logger level.
418                 * @return upper case string of level.
419                 */
420                public static String toString(Level level) {
421                        return switch (level) {
422                                case DEBUG -> "DEBUG";
423                                case ALL -> "TRACE";
424                                case ERROR -> "ERROR";
425                                case INFO -> "INFO";
426                                case OFF -> "ERROR";
427                                case TRACE -> "TRACE";
428                                case WARNING -> "WARN";
429                        };
430                }
431
432                /**
433                 * Turns a Level into a SLF4J like level String that is all upper case and same
434                 * length with right padding. {@link Level#ALL} is "<code>TRACE</code>",
435                 * {@link Level#OFF} is "<code>ERROR</code>" and {@link Level#WARNING} is
436                 * "<code>WARN</code>".
437                 * @param level system logger level.
438                 * @return upper case string of level.
439                 */
440                public static String rightPadded(Level level) {
441                        return switch (level) {
442                                case DEBUG -> /*   */ "DEBUG";
443                                case ALL -> /*     */ "TRACE";
444                                case ERROR -> /*   */ "ERROR";
445                                case INFO -> /*    */ "INFO ";
446                                case OFF -> /*     */ "ERROR";
447                                case TRACE -> /*   */ "TRACE";
448                                case WARNING -> /* */ "WARN ";
449                        };
450                }
451
452        }
453
454        /**
455         * Formats event timestamps.
456         */
457        public sealed interface TimestampFormatter extends LogFormatter {
458
459                /**
460                 * The default timestamp format used in many logging frameworks which does not
461                 * have dates and only time at millisecond precision.
462                 * <p>
463                 * It is called TTLL as that is the name of the format where it is used in
464                 * logback, log4j etc.
465                 */
466                public static String TTLL_TIME_FORMAT = "HH:mm:ss.SSS";
467
468                /**
469                 * Format timestamp.
470                 * @param output buffer.
471                 * @param instant timestamp.
472                 */
473                void formatTimestamp(StringBuilder output, Instant instant);
474
475                @Override
476                default void format(StringBuilder output, LogEvent event) {
477                        formatTimestamp(output, event.timestamp());
478                }
479
480                /**
481                 * Formats timestamp using {@link #TTLL_TIME_FORMAT}.
482                 * @return formatter.
483                 */
484                public static TimestampFormatter of() {
485                        return DefaultInstantFormatter.TTLL;
486                }
487
488                /**
489                 * Formats a timestamp using ISO format.
490                 * @return formatter.
491                 */
492                public static TimestampFormatter ofISO() {
493                        return DefaultInstantFormatter.ISO;
494                }
495
496                /**
497                 * Micro seconds over the events last second. This is for logback compatibility.
498                 * @return microseconds zero padded.
499                 */
500                public static TimestampFormatter ofMicros() {
501                        return DefaultInstantFormatter.MICROS;
502                }
503
504                /**
505                 * Formats a timestamp using standard JDK date time formatter.
506                 * @param dateTimeFormatter date time formatter.
507                 * @return timestamp formatter.
508                 */
509                public static TimestampFormatter of(DateTimeFormatter dateTimeFormatter) {
510                        return new DateTimeFormatterInstantFormatter(dateTimeFormatter);
511                }
512
513        }
514
515        /**
516         * Formats a throwable.
517         */
518        public non-sealed interface ThrowableFormatter extends LogFormatter {
519
520                /**
521                 * Formats a throwable and appends.
522                 * @param output buffer.
523                 * @param throwable throwable.
524                 */
525                void formatThrowable(StringBuilder output, Throwable throwable);
526
527                @Override
528                default void format(StringBuilder output, LogEvent event) {
529                        var t = event.throwableOrNull();
530                        if (t != null) {
531                                formatThrowable(output, t);
532                        }
533                }
534
535                /**
536                 * Default implementation uses {@link Throwable#printStackTrace(PrintWriter)}.
537                 * @return formatter.
538                 */
539                public static ThrowableFormatter of() {
540                        return DefaultThrowableFormatter.INSTANT;
541                }
542
543                /**
544                 * Convenience to append a throwable to string builder.
545                 * @param b buffer.
546                 * @param t throwable.
547                 * @apiNote this call creates garbage.
548                 */
549                public static void appendThrowable(StringBuilder b, Throwable t) {
550                        /*
551                         * TODO optimize
552                         */
553                        t.printStackTrace(Internal.StringBuilderPrintWriter.of(b));
554                }
555
556        }
557
558        /**
559         * Tests if the log formatter is noop or is null which will be considered as noop.
560         * @param logFormatter formatter which <strong>can be <code>null</code></strong>!
561         * @return true if the formatter should not be used.
562         */
563        public static boolean isNoopOrNull(@Nullable LogFormatter logFormatter) {
564                return logFormatter == null || logFormatter.isNoop();
565        }
566
567        /**
568         * Pads the right hand side of text with space.
569         * @param sb buffer.
570         * @param s string that will be appended first (left hand) and will not be longer than
571         * the <code>n</code> parameter.
572         * @param n the size of string. If the size is bigger than passed in string the result
573         * will have padding otherwise the passed in string will be cut to the size of this
574         * parameter.
575         */
576        public static void padRight(StringBuilder sb, CharSequence s, int n) {
577                int length = s.length();
578                if (length >= n) {
579                        sb.append(s, 0, n);
580                        return;
581                }
582                sb.append(s);
583                spacePad(sb, n - length);
584        }
585
586        /**
587         * Pads the left hand side of text with space.
588         * @param sb buffer.
589         * @param s string that will be appended second (right hand) and will not be longer
590         * than the <code>n</code> parameter.
591         * @param n the size of string. If the size is bigger than passed in string the result
592         * will have padding otherwise the passed in string will be cut to the size of this
593         * parameter.
594         */
595        public static void padLeft(StringBuilder sb, CharSequence s, int n) {
596                int length = s.length();
597                if (length >= n) {
598                        sb.append(s, 0, n);
599                        return;
600                }
601                spacePad(sb, n - length);
602                sb.append(s);
603        }
604
605        /**
606         * Fast space padding method.
607         */
608        private static void spacePad(final StringBuilder sbuf, final int length) {
609                int l = length;
610                while (l >= 32) {
611                        sbuf.append(CompositeFormatter.SPACES[5]);
612                        l -= 32;
613                }
614
615                for (int i = 4; i >= 0; i--) {
616                        if ((l & (1 << i)) != 0) {
617                                sbuf.append(CompositeFormatter.SPACES[i]);
618                        }
619                }
620        }
621
622        /**
623         * A special formatter that will do nothing.
624         */
625        enum NoopFormatter implements TimestampFormatter, ThrowableFormatter, LevelFormatter {
626
627                /**
628                 * instance.
629                 */
630                INSTANCE;
631
632                @Override
633                public void formatThrowable(StringBuilder output, Throwable throwable) {
634                }
635
636                @Override
637                public void formatTimestamp(StringBuilder output, Instant instant) {
638                }
639
640                @Override
641                public void formatLevel(StringBuilder output, Level level) {
642                }
643
644                @Override
645                public void format(StringBuilder output, LogEvent event) {
646                }
647
648        }
649
650}
651
652@SuppressWarnings("ArrayRecordComponent")
653record CompositeFormatter(LogFormatter[] formatters) implements EventFormatter {
654
655        static String[] SPACES = { " ", "  ", "    ", "        ", // 1,2,4,8 spaces
656                        "                ", // 16 spaces
657                        "                                " }; // 32 spaces
658
659        @Override
660        public void format(StringBuilder output, LogEvent event) {
661                for (var formatter : formatters) {
662                        formatter.format(output, event);
663                }
664        }
665
666        @Override
667        public String toString() {
668                return getClass().getSimpleName() + Arrays.toString(formatters);
669        }
670
671        public static List<LogFormatter> flatten(List<? extends LogFormatter> formatters) {
672                return List.copyOf(_flatten(formatters));
673        }
674
675        private static List<LogFormatter> _flatten(List<? extends LogFormatter> formatters) {
676                List<LogFormatter> result = new ArrayList<>();
677                for (var f : formatters) {
678                        if (f instanceof CompositeFormatter cf) {
679                                result.addAll(_flatten(cf));
680                        }
681                        else {
682                                result.add(f);
683                        }
684                }
685                return result;
686        }
687
688        private static List<LogFormatter> _flatten(CompositeFormatter formatter) {
689                var formatters = formatter.formatters;
690                return _flatten(Arrays.asList(formatters));
691        }
692
693}
694
695enum DefaultMessageFormatter implements LogFormatter {
696
697        MESSAGE_FORMATTER;
698
699        @Override
700        public void format(StringBuilder output, LogEvent event) {
701                event.formattedMessage(output);
702
703        }
704
705}
706
707enum DefaultNameFormatter implements LogFormatter {
708
709        LOGGER_NAME_FORMATTER;
710
711        @Override
712        public void format(StringBuilder output, LogEvent event) {
713                output.append(event.loggerName());
714
715        }
716
717}
718
719enum DefaultLevelFormatter implements LevelFormatter {
720
721        LEVEL_FORMATTER {
722                @Override
723                public void formatLevel(StringBuilder output, Level level) {
724                        output.append(LevelFormatter.toString(level));
725                }
726        },
727        RIGHT_PAD_LEVEL_FORMATTER {
728                @Override
729                public void formatLevel(StringBuilder output, Level level) {
730                        output.append(LevelFormatter.rightPadded(level));
731                }
732        }
733
734}
735
736enum DefaultThreadFormatter implements LogFormatter {
737
738        THREAD_NAME_FORMATTER() {
739                @Override
740                public void format(StringBuilder output, LogEvent event) {
741                        output.append(event.threadName());
742                }
743        },
744        THREAD_ID_FORMATTER() {
745                @Override
746                public void format(StringBuilder output, LogEvent event) {
747                        output.append(event.threadId());
748                }
749        }
750
751}
752
753enum DefaultInstantFormatter implements TimestampFormatter {
754
755        TTLL(DateTimeFormatter.ofPattern(TTLL_TIME_FORMAT).withZone(ZoneId.from(ZoneOffset.UTC))),
756        ISO(DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneId.from(ZoneOffset.UTC))),
757        MICROS(DateTimeFormatter.ISO_DATE_TIME) {
758                @Override
759                @SuppressWarnings("JavaInstantGetSecondsGetNano")
760                public void formatTimestamp(StringBuilder output, Instant instant) {
761                        int nanos = instant.getNano();
762
763                        int millis_and_micros = nanos / 1000;
764                        int micros = millis_and_micros % 1000;
765
766                        if (micros >= 100) {
767                                output.append(micros);
768                        }
769                        else if (micros >= 10) {
770                                output.append("0").append(micros);
771                        }
772                        else {
773                                output.append("00").append(micros);
774                        }
775                }
776        };
777
778        private final DateTimeFormatter formatter;
779
780        DefaultInstantFormatter(DateTimeFormatter formatter) {
781                this.formatter = formatter;
782        }
783
784        @Override
785        public void formatTimestamp(StringBuilder output, Instant instant) {
786                formatter.formatTo(instant, output);
787        }
788
789}
790
791record DateTimeFormatterInstantFormatter(DateTimeFormatter dateTimeFormatter) implements TimestampFormatter {
792
793        @Override
794        public void formatTimestamp(StringBuilder output, Instant instant) {
795                dateTimeFormatter.formatTo(instant, output);
796        }
797}
798
799enum DefaultThrowableFormatter implements ThrowableFormatter {
800
801        INSTANT;
802
803        @Override
804        public void formatThrowable(StringBuilder output, Throwable throwable) {
805                ThrowableFormatter.appendThrowable(output, throwable);
806        }
807
808}
809
810enum DefaultKeyValuesFormatter implements LogFormatter, KeyValuesConsumer<StringBuilder> {
811
812        INSTANCE;
813
814        @Override
815        public void format(StringBuilder output, LogEvent event) {
816                var keyValues = event.keyValues();
817                keyValues.forEach(this, 0, output);
818        }
819
820        static void formatKeyValue(StringBuilder output, String k, @Nullable String v) {
821                PercentCodec.encode(output, k, StandardCharsets.UTF_8);
822                if (v != null) {
823                        output.append("=");
824                        PercentCodec.encode(output, v, StandardCharsets.UTF_8);
825                }
826        }
827
828        @Override
829        public int accept(KeyValues values, String key, @Nullable String value, int index, StringBuilder storage) {
830                if (index > 0) {
831                        storage.append("&");
832                }
833                formatKeyValue(storage, key, value);
834                return index + 1;
835        }
836
837}
838
839final class ListKeyValuesFormatter implements LogFormatter {
840
841        private final String[] keys;
842
843        @SuppressWarnings("nullness")
844        ListKeyValuesFormatter(List<String> keys) {
845                var ks = List.copyOf(keys);
846                this.keys = ks.toArray(new String[] {});
847        }
848
849        @Override
850        public void format(StringBuilder output, LogEvent event) {
851                var kvs = event.keyValues();
852                formatKeyValues(output, kvs);
853        }
854
855        void formatKeyValues(StringBuilder output, KeyValues keyValues) {
856                boolean first = true;
857                for (String k : keys) {
858                        String v = keyValues.getValueOrNull(k);
859                        if (v == null) {
860                                continue;
861                        }
862                        if (first) {
863                                first = false;
864                        }
865                        else {
866                                output.append("&");
867                        }
868                        DefaultKeyValuesFormatter.formatKeyValue(output, k, v);
869                }
870        }
871
872}
873
874record SingleKeyValueFormatter(String key, @Nullable String fallback) implements LogFormatter {
875        @Override
876        public void format(StringBuilder output, LogEvent event) {
877                var kvs = event.keyValues();
878                formatKeyValues(output, kvs);
879        }
880
881        void formatKeyValues(StringBuilder output, KeyValues keyValues) {
882                String v = keyValues.getValueOrNull(key);
883                if (v == null) {
884                        v = fallback;
885                }
886                DefaultKeyValuesFormatter.formatKeyValue(output, key, v);
887        }
888
889}