slog: backport changes from stdlib

The standard library version of slog has diverged from this
version somewhat; bring them back into sync, as much as possible.

Since moving to the standard library, a Handler rule has changed: a
Handler should ignore the zero slog.Attr, but not an Attr whose key is
empty but whose value is non-zero. Change the check in slogtest to
reflect that.

Fixes golang/go#59727.

Change-Id: Idd2084b84a3faf9b1796051af1578aee6558ed02
Reviewed-on: https://go-review.googlesource.com/c/exp/+/486795
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/slog/attr.go b/slog/attr.go
index 29431cb..cd3bacc 100644
--- a/slog/attr.go
+++ b/slog/attr.go
@@ -82,3 +82,9 @@
 func (a Attr) String() string {
 	return fmt.Sprintf("%s=%s", a.Key, a.Value)
 }
+
+// isEmpty reports whether a has an empty key and a nil value.
+// That can be written as Attr{} or Any("", nil).
+func (a Attr) isEmpty() bool {
+	return a.Key == "" && a.Value.num == 0 && a.Value.any == nil
+}
diff --git a/slog/benchmarks/handlers.go b/slog/benchmarks/handlers.go
index 6bacee0..90c7aed 100644
--- a/slog/benchmarks/handlers.go
+++ b/slog/benchmarks/handlers.go
@@ -48,11 +48,12 @@
 	buf.WriteByte(' ')
 	buf.WriteString("msg=")
 	buf.WriteString(r.Message)
-	r.Attrs(func(a slog.Attr) {
+	r.Attrs(func(a slog.Attr) bool {
 		buf.WriteByte(' ')
 		buf.WriteString(a.Key)
 		buf.WriteByte('=')
 		h.appendValue(buf, a.Value)
+		return true
 	})
 	buf.WriteByte('\n')
 	_, err := h.w.Write(*buf)
diff --git a/slog/benchmarks/handlers_test.go b/slog/benchmarks/handlers_test.go
index f29eae9..9847f67 100644
--- a/slog/benchmarks/handlers_test.go
+++ b/slog/benchmarks/handlers_test.go
@@ -38,6 +38,6 @@
 
 func attrSlice(r slog.Record) []slog.Attr {
 	var as []slog.Attr
-	r.Attrs(func(a slog.Attr) { as = append(as, a) })
+	r.Attrs(func(a slog.Attr) bool { as = append(as, a); return true })
 	return as
 }
diff --git a/slog/example_depth_test.go b/slog/example_wrap_test.go
similarity index 73%
rename from slog/example_depth_test.go
rename to slog/example_wrap_test.go
index 599cf16..c7bfbb7 100644
--- a/slog/example_depth_test.go
+++ b/slog/example_wrap_test.go
@@ -17,24 +17,21 @@
 
 // Infof is an example of a user-defined logging function that wraps slog.
 // The log record contains the source position of the caller of Infof.
-func Infof(format string, args ...any) {
-	l := slog.Default()
-	if !l.Enabled(context.Background(), slog.LevelInfo) {
+func Infof(logger *slog.Logger, format string, args ...any) {
+	if !logger.Enabled(context.Background(), slog.LevelInfo) {
 		return
 	}
 	var pcs [1]uintptr
 	runtime.Callers(2, pcs[:]) // skip [Callers, Infof]
 	r := slog.NewRecord(time.Now(), slog.LevelInfo, fmt.Sprintf(format, args...), pcs[0])
-	_ = l.Handler().Handle(context.Background(), r)
+	_ = logger.Handler().Handle(context.Background(), r)
 }
 
 func Example_wrapping() {
-	defer func(l *slog.Logger) { slog.SetDefault(l) }(slog.Default())
-
 	replace := func(groups []string, a slog.Attr) slog.Attr {
 		// Remove time.
 		if a.Key == slog.TimeKey && len(groups) == 0 {
-			a.Key = ""
+			return slog.Attr{}
 		}
 		// Remove the directory from the source's filename.
 		if a.Key == slog.SourceKey {
@@ -43,9 +40,8 @@
 		return a
 	}
 	logger := slog.New(slog.HandlerOptions{AddSource: true, ReplaceAttr: replace}.NewTextHandler(os.Stdout))
-	slog.SetDefault(logger)
-	Infof("message, %s", "formatted")
+	Infof(logger, "message, %s", "formatted")
 
 	// Output:
-	// level=INFO source=example_depth_test.go:47 msg="message, formatted"
+	// level=INFO source=example_wrap_test.go:43 msg="message, formatted"
 }
diff --git a/slog/handler.go b/slog/handler.go
index c1b9037..862fb3a 100644
--- a/slog/handler.go
+++ b/slog/handler.go
@@ -51,8 +51,9 @@
 	// Handle methods that produce output should observe the following rules:
 	//   - If r.Time is the zero time, ignore the time.
 	//   - If r.PC is zero, ignore it.
-	//   - If an Attr's key is the empty string and the value is not a group,
-	//     ignore the Attr.
+	//   - Attr's values should be resolved.
+	//   - If an Attr's key and value are both the zero value, ignore the Attr.
+	//     This can be tested with attr.Equal(Attr{}).
 	//   - If a group's key is empty, inline the group's Attrs.
 	//   - If a group has no Attrs (even if it has a non-empty key),
 	//     ignore it.
@@ -61,7 +62,6 @@
 	// WithAttrs returns a new Handler whose attributes consist of
 	// both the receiver's attributes and the arguments.
 	// The Handler owns the slice: it may retain, modify or discard it.
-	// [Logger.With] will resolve the Attrs.
 	WithAttrs(attrs []Attr) Handler
 
 	// WithGroup returns a new Handler with the given group appended to
@@ -333,8 +333,9 @@
 	defer s.prefix.Free()
 	s.prefix.WriteString(s.h.groupPrefix)
 	s.openGroups()
-	r.Attrs(func(a Attr) {
+	r.Attrs(func(a Attr) bool {
 		s.appendAttr(a)
+		return true
 	})
 	if s.h.json {
 		// Close all open groups.
@@ -440,26 +441,22 @@
 // It handles replacement and checking for an empty key.
 // after replacement).
 func (s *handleState) appendAttr(a Attr) {
-	v := a.Value
-	// Elide a non-group with an empty key.
-	if a.Key == "" && v.Kind() != KindGroup {
-		return
-	}
-	if rep := s.h.opts.ReplaceAttr; rep != nil && v.Kind() != KindGroup {
+	if rep := s.h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != KindGroup {
 		var gs []string
 		if s.groups != nil {
 			gs = *s.groups
 		}
-		a = rep(gs, Attr{a.Key, v})
-		if a.Key == "" {
-			return
-		}
-		// Although all attributes in the Record are already resolved,
-		// This one came from the user, so it may not have been.
-		v = a.Value.Resolve()
+		// Resolve before calling ReplaceAttr, so the user doesn't have to.
+		a.Value = a.Value.Resolve()
+		a = rep(gs, a)
 	}
-	if v.Kind() == KindGroup {
-		attrs := v.Group()
+	a.Value = a.Value.Resolve()
+	// Elide empty Attrs.
+	if a.isEmpty() {
+		return
+	}
+	if a.Value.Kind() == KindGroup {
+		attrs := a.Value.Group()
 		// Output only non-empty groups.
 		if len(attrs) > 0 {
 			// Inline a group with an empty key.
@@ -475,7 +472,7 @@
 		}
 	} else {
 		s.appendKey(a.Key)
-		s.appendValue(v)
+		s.appendValue(a.Value)
 	}
 }
 
diff --git a/slog/handler_test.go b/slog/handler_test.go
index 8b1b669..6c39025 100644
--- a/slog/handler_test.go
+++ b/slog/handler_test.go
@@ -109,12 +109,10 @@
 func TestJSONAndTextHandlers(t *testing.T) {
 	ctx := context.Background()
 
-	// ReplaceAttr functions
-
 	// remove all Attrs
 	removeAll := func(_ []string, a Attr) Attr { return Attr{} }
 
-	attrs := []Attr{String("a", "one"), Int("b", 2), Any("", "ignore me")}
+	attrs := []Attr{String("a", "one"), Int("b", 2), Any("", nil)}
 	preAttrs := []Attr{Int("pre", 3), String("x", "y")}
 
 	for _, test := range []struct {
@@ -133,6 +131,12 @@
 			wantJSON: `{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"message","a":"one","b":2}`,
 		},
 		{
+			name:     "empty key",
+			attrs:    append(slices.Clip(attrs), Any("", "v")),
+			wantText: `time=2000-01-02T03:04:05.000Z level=INFO msg=message a=one b=2 ""=v`,
+			wantJSON: `{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"message","a":"one","b":2,"":"v"}`,
+		},
+		{
 			name:     "cap keys",
 			replace:  upperCaseKey,
 			attrs:    attrs,
@@ -232,6 +236,16 @@
 			wantJSON: `{"msg":"message","a":1,"name":{"first":"Ren","last":"Hoek"},"b":2}`,
 		},
 		{
+			// Test resolution when there is no ReplaceAttr function.
+			name: "resolve",
+			attrs: []Attr{
+				Any("", &replace{Value{}}), // should be elided
+				Any("name", logValueName{"Ren", "Hoek"}),
+			},
+			wantText: "time=2000-01-02T03:04:05.000Z level=INFO msg=message name.first=Ren name.last=Hoek",
+			wantJSON: `{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"message","name":{"first":"Ren","last":"Hoek"}}`,
+		},
+		{
 			name:     "with-group",
 			replace:  removeKeys(TimeKey, LevelKey),
 			with:     func(h Handler) Handler { return h.WithAttrs(preAttrs).WithGroup("s") },
@@ -297,7 +311,7 @@
 			wantJSON: `{"msg":"message","a":1,"b":2,"c":3,"d":4}`,
 		},
 	} {
-		r := NewRecord(testTime, LevelInfo, "message", 1)
+		r := NewRecord(testTime, LevelInfo, "message", 0)
 		r.AddAttrs(test.attrs...)
 		var buf bytes.Buffer
 		opts := HandlerOptions{ReplaceAttr: test.replace}
diff --git a/slog/internal/testutil/testutil.go b/slog/internal/testutil/testutil.go
index 4f59d0a..540fefb 100644
--- a/slog/internal/testutil/testutil.go
+++ b/slog/internal/testutil/testutil.go
@@ -12,7 +12,7 @@
 // to make example output deterministic.
 func RemoveTime(groups []string, a slog.Attr) slog.Attr {
 	if a.Key == slog.TimeKey && len(groups) == 0 {
-		a.Key = ""
+		return slog.Attr{}
 	}
 	return a
 }
diff --git a/slog/json_handler.go b/slog/json_handler.go
index 1cd1f59..047cb74 100644
--- a/slog/json_handler.go
+++ b/slog/json_handler.go
@@ -80,7 +80,7 @@
 // Values are formatted as with encoding/json.Marshal, with the following
 // exceptions:
 //   - Floating-point NaNs and infinities are formatted as one of the strings
-//     "NaN", "+Inf" or "-Inf".
+//     "NaN", "Infinity" or "-Infinity".
 //   - Levels are formatted as with Level.String.
 //   - HTML characters are not escaped.
 //
@@ -114,9 +114,9 @@
 		// json.Marshal fails on special floats, so handle them here.
 		switch {
 		case math.IsInf(f, 1):
-			s.buf.WriteString(`"+Inf"`)
+			s.buf.WriteString(`"Infinity"`)
 		case math.IsInf(f, -1):
-			s.buf.WriteString(`"-Inf"`)
+			s.buf.WriteString(`"-Infinity"`)
 		case math.IsNaN(f):
 			s.buf.WriteString(`"NaN"`)
 		default:
@@ -136,13 +136,14 @@
 		s.appendTime(v.Time())
 	case KindAny:
 		a := v.Any()
-		if err, ok := a.(error); ok {
+		_, jm := a.(json.Marshaler)
+		if err, ok := a.(error); ok && !jm {
 			s.appendString(err.Error())
 		} else {
 			return appendJSONMarshal(s.buf, a)
 		}
 	default:
-		panic(fmt.Sprintf("bad kind: %d", v.Kind()))
+		panic(fmt.Sprintf("bad kind: %s", v.Kind()))
 	}
 	return nil
 }
diff --git a/slog/json_handler_test.go b/slog/json_handler_test.go
index 64b3b4d..fb93713 100644
--- a/slog/json_handler_test.go
+++ b/slog/json_handler_test.go
@@ -13,6 +13,7 @@
 	"io"
 	"math"
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 	"time"
@@ -67,6 +68,12 @@
 	return []byte(fmt.Sprintf(`[%q]`, j.s)), nil
 }
 
+type jsonMarshalerError struct {
+	jsonMarshaler
+}
+
+func (jsonMarshalerError) Error() string { return "oops" }
+
 func TestAppendJSONValue(t *testing.T) {
 	// On most values, jsonAppendAttrValue should agree with json.Marshal.
 	for _, value := range []any{
@@ -82,6 +89,7 @@
 		time.Minute,
 		testTime,
 		jsonMarshaler{"xyz"},
+		jsonMarshalerError{jsonMarshaler{"pqr"}},
 	} {
 		got := jsonValueString(t, AnyValue(value))
 		want, err := marshalJSON(value)
@@ -111,8 +119,8 @@
 		want  string
 	}{
 		{math.NaN(), `"NaN"`},
-		{math.Inf(+1), `"+Inf"`},
-		{math.Inf(-1), `"-Inf"`},
+		{math.Inf(+1), `"Infinity"`},
+		{math.Inf(-1), `"-Infinity"`},
 		{LevelWarn, `"WARN"`},
 	} {
 		got := jsonValueString(t, AnyValue(test.value))
@@ -202,7 +210,7 @@
 		}),
 	}
 
-	outFile, err := os.Create("/tmp/bench.log")
+	outFile, err := os.Create(filepath.Join(b.TempDir(), "bench.log"))
 	if err != nil {
 		b.Fatal(err)
 	}
diff --git a/slog/logger.go b/slog/logger.go
index 8f021e6..173b34f 100644
--- a/slog/logger.go
+++ b/slog/logger.go
@@ -89,7 +89,7 @@
 func (l *Logger) Handler() Handler { return l.handler }
 
 // With returns a new Logger that includes the given arguments, converted to
-// Attrs as in [Logger.Log] and resolved.
+// Attrs as in [Logger.Log].
 // The Attrs will be added to each output from the Logger.
 // The new Logger shares the old Logger's context.
 // The new Logger's handler is the result of calling WithAttrs on the receiver's
@@ -110,6 +110,8 @@
 
 // WithGroup returns a new Logger that starts a group. The keys of all
 // attributes added to the Logger will be qualified by the given name.
+// (How that qualification happens depends on the [Handler.WithGroup]
+// method of the Logger's Handler.)
 // The new Logger shares the old Logger's context.
 //
 // The new Logger's handler is the result of calling WithGroup on the receiver's
diff --git a/slog/logger_test.go b/slog/logger_test.go
index 2f04540..9694b54 100644
--- a/slog/logger_test.go
+++ b/slog/logger_test.go
@@ -13,6 +13,7 @@
 	"regexp"
 	"runtime"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -398,12 +399,15 @@
 }
 
 type captureHandler struct {
+	mu     sync.Mutex
 	r      Record
 	attrs  []Attr
 	groups []string
 }
 
 func (h *captureHandler) Handle(ctx context.Context, r Record) error {
+	h.mu.Lock()
+	defer h.mu.Unlock()
 	h.r = r
 	return nil
 }
@@ -411,14 +415,22 @@
 func (*captureHandler) Enabled(context.Context, Level) bool { return true }
 
 func (c *captureHandler) WithAttrs(as []Attr) Handler {
-	c2 := *c
-	c2.attrs = concat(c2.attrs, as)
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	var c2 captureHandler
+	c2.r = c.r
+	c2.groups = c.groups
+	c2.attrs = concat(c.attrs, as)
 	return &c2
 }
 
 func (c *captureHandler) WithGroup(name string) Handler {
-	c2 := *c
-	c2.groups = append(slices.Clip(c2.groups), name)
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	var c2 captureHandler
+	c2.r = c.r
+	c2.attrs = c.attrs
+	c2.groups = append(slices.Clip(c.groups), name)
 	return &c2
 }
 
diff --git a/slog/record.go b/slog/record.go
index 6911c6c..e791b2e 100644
--- a/slog/record.go
+++ b/slog/record.go
@@ -87,27 +87,29 @@
 }
 
 // Attrs calls f on each Attr in the Record.
-// The Attrs are already resolved.
-func (r Record) Attrs(f func(Attr)) {
+// Iteration stops if f returns false.
+func (r Record) Attrs(f func(Attr) bool) {
 	for i := 0; i < r.nFront; i++ {
-		f(r.front[i])
+		if !f(r.front[i]) {
+			return
+		}
 	}
 	for _, a := range r.back {
-		f(a)
+		if !f(a) {
+			return
+		}
 	}
 }
 
 // AddAttrs appends the given Attrs to the Record's list of Attrs.
-// It resolves the Attrs before doing so.
 func (r *Record) AddAttrs(attrs ...Attr) {
-	resolveAttrs(attrs)
 	n := copy(r.front[r.nFront:], attrs)
 	r.nFront += n
 	// Check if a copy was modified by slicing past the end
 	// and seeing if the Attr there is non-zero.
 	if cap(r.back) > len(r.back) {
 		end := r.back[:len(r.back)+1][len(r.back)]
-		if end != (Attr{}) {
+		if !end.isEmpty() {
 			panic("copies of a slog.Record were both modified")
 		}
 	}
@@ -116,7 +118,6 @@
 
 // Add converts the args to Attrs as described in [Logger.Log],
 // then appends the Attrs to the Record's list of Attrs.
-// It resolves the Attrs before doing so.
 func (r *Record) Add(args ...any) {
 	var a Attr
 	for len(args) > 0 {
@@ -150,7 +151,7 @@
 
 // argsToAttr turns a prefix of the nonempty args slice into an Attr
 // and returns the unconsumed portion of the slice.
-// If args[0] is an Attr, it returns it, resolved.
+// If args[0] is an Attr, it returns it.
 // If args[0] is a string, it treats the first two elements as
 // a key-value pair.
 // Otherwise, it treats args[0] as a value with a missing key.
@@ -160,12 +161,9 @@
 		if len(args) == 1 {
 			return String(badKey, x), nil
 		}
-		a := Any(x, args[1])
-		a.Value = a.Value.Resolve()
-		return a, args[2:]
+		return Any(x, args[1]), args[2:]
 
 	case Attr:
-		x.Value = x.Value.Resolve()
 		return x, args[1:]
 
 	default:
diff --git a/slog/record_test.go b/slog/record_test.go
index 4727a08..fa907f3 100644
--- a/slog/record_test.go
+++ b/slog/record_test.go
@@ -24,6 +24,17 @@
 	if got := attrsSlice(r); !attrsEqual(got, as) {
 		t.Errorf("got %v, want %v", got, as)
 	}
+
+	// Early return.
+	var got []Attr
+	r.Attrs(func(a Attr) bool {
+		got = append(got, a)
+		return len(got) < 2
+	})
+	want := as[:2]
+	if !attrsEqual(got, want) {
+		t.Errorf("got %v, want %v", got, want)
+	}
 }
 
 func TestRecordSourceLine(t *testing.T) {
@@ -103,7 +114,7 @@
 
 func attrsSlice(r Record) []Attr {
 	s := make([]Attr, 0, r.NumAttrs())
-	r.Attrs(func(a Attr) { s = append(s, a) })
+	r.Attrs(func(a Attr) bool { s = append(s, a); return true })
 	return s
 }
 
@@ -158,7 +169,7 @@
 		for j := 0; j < nAttrs; j++ {
 			r.AddAttrs(Int("k", j))
 		}
-		r.Attrs(func(b Attr) { a = b })
+		r.Attrs(func(b Attr) bool { a = b; return true })
 	}
 	_ = a
 }
diff --git a/slog/slogtest/slogtest.go b/slog/slogtest/slogtest.go
index 442a6c7..610575a 100644
--- a/slog/slogtest/slogtest.go
+++ b/slog/slogtest/slogtest.go
@@ -67,9 +67,9 @@
 			},
 		},
 		{
-			explanation: withSource("a Handler should ignore an Attr with an empty key"),
+			explanation: withSource("a Handler should ignore an empty Attr"),
 			f: func(l *slog.Logger) {
-				l.Info("msg", "a", "b", "", "ignore", "c", "d")
+				l.Info("msg", "a", "b", "", nil, "c", "d")
 			},
 			checks: []check{
 				hasAttr("a", "b"),
diff --git a/slog/text_handler.go b/slog/text_handler.go
index 0faa367..4981eb6 100644
--- a/slog/text_handler.go
+++ b/slog/text_handler.go
@@ -68,7 +68,7 @@
 // If the AddSource option is set and source information is available,
 // the key is "source" and the value is output as FILE:LINE.
 //
-// The message's key "msg".
+// The message's key is "msg".
 //
 // To modify these or other attributes, or remove them from the output, use
 // [HandlerOptions.ReplaceAttr].
@@ -80,9 +80,13 @@
 // characters, non-printing characters, '"' or '='.
 //
 // Keys inside groups consist of components (keys or group names) separated by
-// dots. No further escaping is performed. If it is necessary to reconstruct the
-// group structure of a key even in the presence of dots inside components, use
-// [HandlerOptions.ReplaceAttr] to escape the keys.
+// dots. No further escaping is performed.
+// Thus there is no way to determine from the key "a.b.c" whether there
+// are two groups "a" and "b" and a key "c", or a single group "a.b" and a key "c",
+// or single group "a" and a key "b.c".
+// If it is necessary to reconstruct the group structure of a key
+// even in the presence of dots inside components, use
+// [HandlerOptions.ReplaceAttr] to encode that information in the key.
 //
 // Each call to Handle results in a single serialized call to
 // io.Writer.Write.
@@ -134,10 +138,15 @@
 }
 
 func needsQuoting(s string) bool {
+	if len(s) == 0 {
+		return true
+	}
 	for i := 0; i < len(s); {
 		b := s[i]
 		if b < utf8.RuneSelf {
-			if needsQuotingSet[b] {
+			// Quote anything except a backslash that would need quoting in a
+			// JSON string, as well as space and '='
+			if b != '\\' && (b == ' ' || b == '=' || !safeSet[b]) {
 				return true
 			}
 			i++
@@ -151,17 +160,3 @@
 	}
 	return false
 }
-
-var needsQuotingSet = [utf8.RuneSelf]bool{
-	'"': true,
-	'=': true,
-}
-
-func init() {
-	for i := 0; i < utf8.RuneSelf; i++ {
-		r := rune(i)
-		if unicode.IsSpace(r) || !unicode.IsPrint(r) {
-			needsQuotingSet[i] = true
-		}
-	}
-}
diff --git a/slog/text_handler_test.go b/slog/text_handler_test.go
index 71f637a..6dbe402 100644
--- a/slog/text_handler_test.go
+++ b/slog/text_handler_test.go
@@ -183,7 +183,7 @@
 		in   string
 		want bool
 	}{
-		{"", false},
+		{"", true},
 		{"ab", false},
 		{"a=b", true},
 		{`"ab"`, true},
diff --git a/slog/value.go b/slog/value.go
index f331945..5719976 100644
--- a/slog/value.go
+++ b/slog/value.go
@@ -7,7 +7,9 @@
 import (
 	"fmt"
 	"math"
+	"runtime"
 	"strconv"
+	"strings"
 	"time"
 
 	"golang.org/x/exp/slices"
@@ -346,8 +348,10 @@
 		return append(dst, v.duration().String()...)
 	case KindTime:
 		return append(dst, v.time().String()...)
-	case KindAny, KindGroup, KindLogValuer:
-		return append(dst, fmt.Sprint(v.any)...)
+	case KindGroup:
+		return fmt.Append(dst, v.group())
+	case KindAny, KindLogValuer:
+		return fmt.Append(dst, v.any)
 	default:
 		panic(fmt.Sprintf("bad kind: %s", v.Kind()))
 	}
@@ -365,20 +369,19 @@
 
 // Resolve repeatedly calls LogValue on v while it implements LogValuer,
 // and returns the result.
-// If v resolves to a group, the group's attributes' values are also resolved.
+// If v resolves to a group, the group's attributes' values are not recursively
+// resolved.
 // If the number of LogValue calls exceeds a threshold, a Value containing an
 // error is returned.
 // Resolve's return value is guaranteed not to be of Kind KindLogValuer.
-func (v Value) Resolve() Value {
-	v = v.resolve()
-	if v.Kind() == KindGroup {
-		resolveAttrs(v.Group())
-	}
-	return v
-}
-
-func (v Value) resolve() Value {
+func (v Value) Resolve() (rv Value) {
 	orig := v
+	defer func() {
+		if r := recover(); r != nil {
+			rv = AnyValue(fmt.Errorf("LogValue panicked\n%s", stack(3, 5)))
+		}
+	}()
+
 	for i := 0; i < maxLogValues; i++ {
 		if v.Kind() != KindLogValuer {
 			return v
@@ -389,10 +392,26 @@
 	return AnyValue(err)
 }
 
-// resolveAttrs replaces the values of the given Attrs with their
-// resolutions.
-func resolveAttrs(as []Attr) {
-	for i, a := range as {
-		as[i].Value = a.Value.Resolve()
+func stack(skip, nFrames int) string {
+	pcs := make([]uintptr, nFrames+1)
+	n := runtime.Callers(skip+1, pcs)
+	if n == 0 {
+		return "(no stack)"
 	}
+	frames := runtime.CallersFrames(pcs[:n])
+	var b strings.Builder
+	i := 0
+	for {
+		frame, more := frames.Next()
+		fmt.Fprintf(&b, "called from %s (%s:%d)\n", frame.Function, frame.File, frame.Line)
+		if !more {
+			break
+		}
+		i++
+		if i >= nFrames {
+			fmt.Fprintf(&b, "(rest of stack elided)\n")
+			break
+		}
+	}
+	return b.String()
 }
diff --git a/slog/value_test.go b/slog/value_test.go
index e3c087c..1196e75 100644
--- a/slog/value_test.go
+++ b/slog/value_test.go
@@ -7,6 +7,7 @@
 import (
 	"fmt"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 	"unsafe"
@@ -59,6 +60,7 @@
 		{StringValue("foo"), "foo"},
 		{TimeValue(testTime), "2000-01-02 03:04:05 +0000 UTC"},
 		{AnyValue(time.Duration(3 * time.Second)), "3s"},
+		{GroupValue(Int("a", 1), Bool("b", true)), "[a=1 b=true]"},
 	} {
 		if got := test.v.String(); got != test.want {
 			t.Errorf("%#v:\ngot  %q\nwant %q", test.v, got, test.want)
@@ -173,18 +175,27 @@
 		t.Errorf("expected error, got %T", got)
 	}
 
-	// Test Resolve group.
-	r = &replace{GroupValue(
-		Int("a", 1),
-		Group("b", Any("c", &replace{StringValue("d")})),
-	)}
-	v = AnyValue(r)
+	// Groups are not recursively resolved.
+	c := Any("c", &replace{StringValue("d")})
+	v = AnyValue(&replace{GroupValue(Int("a", 1), Group("b", c))})
 	got2 := v.Resolve().Any().([]Attr)
-	want2 := []Attr{Int("a", 1), Group("b", String("c", "d"))}
+	want2 := []Attr{Int("a", 1), Group("b", c)}
 	if !attrsEqual(got2, want2) {
 		t.Errorf("got %v, want %v", got2, want2)
 	}
 
+	// Verify that panics in Resolve are caught and turn into errors.
+	v = AnyValue(panickingLogValue{})
+	got = v.Resolve().Any()
+	gotErr, ok := got.(error)
+	if !ok {
+		t.Errorf("expected error, got %T", got)
+	}
+	// The error should provide some context information.
+	// We'll just check that this function name appears in it.
+	if got, want := gotErr.Error(), "TestLogValue"; !strings.Contains(got, want) {
+		t.Errorf("got %q, want substring %q", got, want)
+	}
 }
 
 func TestZeroTime(t *testing.T) {
@@ -201,6 +212,10 @@
 
 func (r *replace) LogValue() Value { return r.v }
 
+type panickingLogValue struct{}
+
+func (panickingLogValue) LogValue() Value { panic("bad") }
+
 // A Value with "unsafe" strings is significantly faster:
 // safe:  1785 ns/op, 0 allocs
 // unsafe: 690 ns/op, 0 allocs