From 42bc65abad3d7d0633fe525e1a0013bdc2c99780 Mon Sep 17 00:00:00 2001 From: Brandur Date: Sun, 7 Sep 2025 14:25:56 +0200 Subject: [PATCH] Add lazy slice formatters to `slogutil` Adds a couple slice helpers to `slogutil` that implement `slog.LogValue` to achieve the effect of formatting slices that are used as values in slog attributes, but only when we actually need to do so because a slog log line is being printed. Otherwise, `LogValue` is never called and we avoid the associated work and allocations. --- rivershared/util/slogutil/slog_util.go | 25 ++++++++ rivershared/util/slogutil/slog_util_test.go | 70 +++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 rivershared/util/slogutil/slog_util_test.go diff --git a/rivershared/util/slogutil/slog_util.go b/rivershared/util/slogutil/slog_util.go index b452b698..bc341ee4 100644 --- a/rivershared/util/slogutil/slog_util.go +++ b/rivershared/util/slogutil/slog_util.go @@ -6,8 +6,33 @@ import ( "io" "log/slog" "os" + "strconv" + "strings" + + "github.com/riverqueue/river/rivershared/util/sliceutil" ) +// SliceInt64 is a type that implements slog.LogValue and which will format a +// slice for inclusion in logging, but lazily so that no work is done unless a +// log line is actually emitted. +type SliceInt64 []int64 + +func (s SliceInt64) LogValue() slog.Value { + return slog.StringValue(strings.Join( + sliceutil.Map(s, func(i int64) string { return strconv.FormatInt(i, 10) }), + ",", + )) +} + +// SliceString is a type that implements slog.LogValue and which will format a +// slice for inclusion in logging, but lazily so that no work is done unless a +// log line is actually emitted. +type SliceString []string + +func (s SliceString) LogValue() slog.Value { + return slog.StringValue(strings.Join(s, ",")) +} + // SlogMessageOnlyHandler is a trivial slog handler that prints only messages. // All attributes and groups are ignored. It's useful in example tests where it // produces output that's normalized so we match against it (normally, all log diff --git a/rivershared/util/slogutil/slog_util_test.go b/rivershared/util/slogutil/slog_util_test.go new file mode 100644 index 00000000..e6af4601 --- /dev/null +++ b/rivershared/util/slogutil/slog_util_test.go @@ -0,0 +1,70 @@ +package slogutil + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSliceInt64(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + logger, buf := plainLoggerAndBuffer() + logger.Info("log_entry", slog.Any("values", SliceInt64(nil))) + + require.Equal(t, `msg=log_entry values=""`+"\n", buf.String()) + }) + + t.Run("Values", func(t *testing.T) { + t.Parallel() + + logger, buf := plainLoggerAndBuffer() + logger.Info("log_entry", slog.Any("values", SliceInt64([]int64{1, 2, 3}))) + + require.Equal(t, "msg=log_entry values=1,2,3\n", buf.String()) + }) +} + +func TestSliceString(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + logger, buf := plainLoggerAndBuffer() + logger.Info("log_entry", slog.Any("values", SliceString(nil))) + + require.Equal(t, `msg=log_entry values=""`+"\n", buf.String()) + }) + + t.Run("Values", func(t *testing.T) { + t.Parallel() + + logger, buf := plainLoggerAndBuffer() + logger.Info("log_entry", slog.Any("values", SliceString([]string{"foo", "bar"}))) + + require.Equal(t, "msg=log_entry values=foo,bar\n", buf.String()) + }) +} + +func plainLoggerAndBuffer() (*slog.Logger, *bytes.Buffer) { + var buf bytes.Buffer + return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{ + // Removes the `level` and `time` keys so that we have clean and stable + // output to match against in assertions. + ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { + if len(groups) < 1 { + switch attr.Key { + case slog.LevelKey, slog.TimeKey: + return slog.Attr{} + } + } + return attr + }, + })), &buf +}