Repository: uber-go/zap (Go)

Issue:
Extend `WithLazy` method to `SugaredLogger`
Hi, `zap` maintainers!

**Is your feature request related to a problem? Please describe.**

The recently added `WithLazy` method is amazing (PR: https://github.com/uber-go/zap/pull/1319). This capability is not yet available for `SugaredLogger`

**Describe the solution you'd like**
The straightforward idea is to expose a similar signature from `SugaredLogger` using `WithLazy` method :

```go
func (s *SugaredLogger) WithLazy(args ...interface{}) *SugaredLogger {
	return &SugaredLogger{base: s.base.WithLazy(s.sweetenFields(args)...)}
}
```

There is still overhead from the `sweetenFields` call, but it's nonetheless much less than using `With()`.

**Is this a breaking change?**
Nope

If you approve, I'm ready to work on that because it's somewhat blocking us



Discussion:
Hey @defval, this feature makes sense to me. Feel free to send us something for review!
@r-hang Hey, mate! The PR is ready. Could you take a look at it soon?
@r-hang, sorry for the ping, but can you take a look at the implementation?



Patch:
diff --git a/sugar.go b/sugar.go
index 00ac5fe3a..dce9341db 100644
--- a/sugar.go
+++ b/sugar.go
@@ -115,6 +115,21 @@ func (s *SugaredLogger) With(args ...interface{}) *SugaredLogger {
 	return &SugaredLogger{base: s.base.With(s.sweetenFields(args)...)}
 }
 
+// WithLazy adds a variadic number of fields to the logging context lazily.
+// The fields are evaluated only if the logger is further chained with [With]
+// or is written to with any of the log level methods.
+// Until that occurs, the logger may retain references to objects inside the fields,
+// and logging will reflect the state of an object at the time of logging,
+// not the time of WithLazy().
+//
+// Similar to [With], fields added to the child don't affect the parent,
+// and vice versa. Also, the keys in key-value pairs should be strings. In development,
+// passing a non-string key panics, while in production it logs an error and skips the pair.
+// Passing an orphaned key has the same behavior.
+func (s *SugaredLogger) WithLazy(args ...interface{}) *SugaredLogger {
+	return &SugaredLogger{base: s.base.WithLazy(s.sweetenFields(args)...)}
+}
+
 // Level reports the minimum enabled level for this logger.
 //
 // For NopLoggers, this is [zapcore.InvalidLevel].


Test patch:
diff --git a/sugar_test.go b/sugar_test.go
index 9e914ecf8..0f608cb50 100644
--- a/sugar_test.go
+++ b/sugar_test.go
@@ -22,6 +22,8 @@ package zap
 
 import (
 	"errors"
+	"fmt"
+	"strconv"
 	"testing"
 
 	"go.uber.org/zap/internal/exit"
@@ -54,6 +56,9 @@ func TestSugarWith(t *testing.T) {
 		}
 	}
 
+	type withAny func(*SugaredLogger, ...interface{}) *SugaredLogger
+	withMethods := []withAny{(*SugaredLogger).With, (*SugaredLogger).WithLazy}
+
 	tests := []struct {
 		desc     string
 		args     []interface{}
@@ -141,16 +146,94 @@ func TestSugarWith(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		withSugar(t, DebugLevel, nil, func(logger *SugaredLogger, logs *observer.ObservedLogs) {
-			logger.With(tt.args...).Info("")
-			output := logs.AllUntimed()
-			if len(tt.errLogs) > 0 {
-				for i := range tt.errLogs {
-					assert.Equal(t, tt.errLogs[i], output[i], "Unexpected error log at position %d for scenario %s.", i, tt.desc)
+		for _, withMethod := range withMethods {
+			withSugar(t, DebugLevel, nil, func(logger *SugaredLogger, logs *observer.ObservedLogs) {
+				withMethod(logger, tt.args...).Info("")
+				output := logs.AllUntimed()
+				if len(tt.errLogs) > 0 {
+					for i := range tt.errLogs {
+						assert.Equal(t, tt.errLogs[i], output[i], "Unexpected error log at position %d for scenario %s.", i, tt.desc)
+					}
+				}
+				assert.Equal(t, len(tt.errLogs)+1, len(output), "Expected only one non-error message to be logged in scenario %s.", tt.desc)
+				assert.Equal(t, tt.expected, output[len(tt.errLogs)].Context, "Unexpected message context in scenario %s.", tt.desc)
+			})
+		}
+	}
+}
+
+func TestSugarWithCaptures(t *testing.T) {
+	type withAny func(*SugaredLogger, ...interface{}) *SugaredLogger
+
+	tests := []struct {
+		name        string
+		withMethods []withAny
+		wantJSON    []string
+	}{
+		{
+			name:        "with captures arguments at time of With",
+			withMethods: []withAny{(*SugaredLogger).With},
+			wantJSON: []string{
+				`{
+					"m": "hello 0",
+					"a0": [0],
+					"b0": [1]
+				}`,
+				`{
+					"m": "world 0",
+					"a0": [0],
+					"c0": [2]
+				}`,
+			},
+		},
+		{
+			name:        "lazy with captures arguments at time of Logging",
+			withMethods: []withAny{(*SugaredLogger).WithLazy},
+			wantJSON: []string{
+				`{
+					"m": "hello 0",
+					"a0": [1],
+					"b0": [1]
+				}`,
+				`{
+					"m": "world 0",
+					"a0": [1],
+					"c0": [2]
+				}`,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			enc := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
+				MessageKey: "m",
+			})
+
+			var bs ztest.Buffer
+			logger := New(zapcore.NewCore(enc, &bs, DebugLevel)).Sugar()
+
+			for i, withMethod := range tt.withMethods {
+				iStr := strconv.Itoa(i)
+				x := 10 * i
+				arr := zapcore.ArrayMarshalerFunc(func(enc zapcore.ArrayEncoder) error {
+					enc.AppendInt(x)
+					return nil
+				})
+
+				logger = withMethod(logger, Array("a"+iStr, arr))
+				x++
+				logger.Infow(fmt.Sprintf("hello %d", i), Array("b"+iStr, arr))
+				x++
+				logger = withMethod(logger, Array("c"+iStr, arr))
+				logger.Infow(fmt.Sprintf("world %d", i))
+			}
+
+			if lines := bs.Lines(); assert.Len(t, lines, len(tt.wantJSON)) {
+				for i, want := range tt.wantJSON {
+					assert.JSONEq(t, want, lines[i], "Unexpected output from the %d'th log.", i)
 				}
 			}
-			assert.Equal(t, len(tt.errLogs)+1, len(output), "Expected only one non-error message to be logged in scenario %s.", tt.desc)
-			assert.Equal(t, tt.expected, output[len(tt.errLogs)].Context, "Unexpected message context in scenario %s.", tt.desc)
 		})
 	}
 }