Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions slog_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func (h *subsystemAwareHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
}
}

// TODO: same no-op as zapToSlogBridge.WithGroup; see TODO there.
func (h *subsystemAwareHandler) WithGroup(name string) slog.Handler {
return &subsystemAwareHandler{
bridge: &zapToSlogBridge{
Expand Down Expand Up @@ -191,9 +192,13 @@ func (h *zapToSlogBridge) WithAttrs(attrs []slog.Attr) slog.Handler {
}

// WithGroup implements slog.Handler.
//
// TODO: Handler.WithGroup is a no-op. Inline slog.Group(...) attrs render as
// nested objects via slogGroup, but attrs added after a Handler.WithGroup
// call are not nested under the group name. Full support needs deferred
// attr conversion plus a group-frame stack walked at Handle time, with the
// subsystem-key filter applied only at depth 0.
func (h *zapToSlogBridge) WithGroup(name string) slog.Handler {
// Groups are currently not implemented - just return a handler preserving the subsystem.
// A more sophisticated implementation would nest fields under the group name.
return &zapToSlogBridge{
core: h.core,
subsystemName: h.subsystemName, // Preserve subsystem
Expand All @@ -218,7 +223,8 @@ func slogLevelToZap(level slog.Level) zapcore.Level {
// slogAttrToZapField converts slog.Attr to zapcore.Field.
func slogAttrToZapField(attr slog.Attr) zapcore.Field {
key := attr.Key
value := attr.Value
// slog: handlers must resolve LogValuer at the leaf.
value := attr.Value.Resolve()

switch value.Kind() {
case slog.KindString:
Expand All @@ -235,6 +241,17 @@ func slogAttrToZapField(attr slog.Attr) zapcore.Field {
return zapcore.Field{Key: key, Type: zapcore.DurationType, Integer: value.Duration().Nanoseconds()}
case slog.KindTime:
return zapcore.Field{Key: key, Type: zapcore.TimeType, Integer: value.Time().UnixNano(), Interface: value.Time().Location()}
case slog.KindGroup:
g := value.Group()
if len(g) == 0 {
// slog: a Group with no Attrs is ignored.
return zap.Skip()
}
if key == "" {
// slog: a Group with an empty key is inlined into its parent.
return zap.Inline(slogGroup(g))
}
return zap.Object(key, slogGroup(g))
case slog.KindAny:
return zapcore.Field{Key: key, Type: zapcore.ReflectType, Interface: value.Any()}
default:
Expand All @@ -243,6 +260,57 @@ func slogAttrToZapField(attr slog.Attr) zapcore.Field {
}
}

// slogGroup adapts a slog group's attrs as a zapcore.ObjectMarshaler so nested
// fields render as a structured object rather than reflected []slog.Attr.
type slogGroup []slog.Attr

func (g slogGroup) MarshalLogObject(enc zapcore.ObjectEncoder) error {
for _, attr := range g {
v := attr.Value.Resolve()
// slog inlines a Group whose key is empty into the enclosing object.
if attr.Key == "" && v.Kind() == slog.KindGroup {
if err := slogGroup(v.Group()).MarshalLogObject(enc); err != nil {
return err
}
continue
}
addSlogAttrToObjectEncoder(enc, slog.Attr{Key: attr.Key, Value: v})
}
return nil
}

// addSlogAttrToObjectEncoder mirrors slogAttrToZapField for nested object
// encoding; keep the two switches in sync when adding new slog.Kind cases.
func addSlogAttrToObjectEncoder(enc zapcore.ObjectEncoder, attr slog.Attr) {
key := attr.Key
// slog: handlers must resolve LogValuer at the leaf.
value := attr.Value.Resolve()
switch value.Kind() {
case slog.KindString:
enc.AddString(key, value.String())
case slog.KindInt64:
enc.AddInt64(key, value.Int64())
case slog.KindUint64:
enc.AddUint64(key, value.Uint64())
case slog.KindFloat64:
enc.AddFloat64(key, value.Float64())
case slog.KindBool:
enc.AddBool(key, value.Bool())
case slog.KindDuration:
enc.AddDuration(key, value.Duration())
case slog.KindTime:
enc.AddTime(key, value.Time())
case slog.KindGroup:
g := value.Group()
if len(g) == 0 {
return
}
_ = enc.AddObject(key, slogGroup(g))
default:
_ = enc.AddReflected(key, value.Any())
}
}

func boolToInt64(b bool) int64 {
if b {
return 1
Expand Down
158 changes: 158 additions & 0 deletions slog_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package log
import (
"bufio"
"bytes"
"encoding/json"
"log/slog"
"os"
"strings"
Expand All @@ -18,6 +19,11 @@ type goLogBridge interface {
GoLogBridge()
}

// logValuerFunc adapts a function to slog.LogValuer for tests.
type logValuerFunc func() slog.Value

func (f logValuerFunc) LogValue() slog.Value { return f() }

func TestSlogInterop(t *testing.T) {
// Save initial state
originalDefault := slog.Default()
Expand Down Expand Up @@ -168,6 +174,158 @@ func TestSlogAttrFieldConversions(t *testing.T) {
}
}

func TestSlogGroupConversion(t *testing.T) {
var buf bytes.Buffer
ws := zapcore.AddSync(&buf)
testCore := newCore(JSONOutput, ws, LevelDebug)
logger := slog.New(newZapToSlogBridge(testCore))

decode := func(t *testing.T) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, buf.String())
}
return m
}

t.Run("nested groups with mixed types", func(t *testing.T) {
buf.Reset()
logger.Info("msg", slog.Group("outer",
slog.String("s", "hello"),
slog.Int("n", 42),
slog.Bool("b", true),
slog.Group("inner", slog.String("k", "v")),
))
got := decode(t)
outer, ok := got["outer"].(map[string]any)
if !ok {
t.Fatalf("expected outer to be an object, got %T: %v", got["outer"], got["outer"])
}
if outer["s"] != "hello" || outer["n"] != float64(42) || outer["b"] != true {
t.Errorf("unexpected scalar contents: %v", outer)
}
inner, ok := outer["inner"].(map[string]any)
if !ok {
t.Fatalf("expected inner to be an object, got %T", outer["inner"])
}
if inner["k"] != "v" {
t.Errorf("unexpected inner contents: %v", inner)
}
})

t.Run("fxevent-style synthetic-array group", func(t *testing.T) {
// fxevent.slogStrings packs []string as a Group with numeric-string keys.
// Without group support this rendered as [{"Key":"0","Value":{}},...].
buf.Reset()
logger.Info("msg", slog.Group("stacktrace",
slog.String("0", "frame0"),
slog.String("1", "frame1"),
))
got := decode(t)
st, ok := got["stacktrace"].(map[string]any)
if !ok {
t.Fatalf("expected stacktrace to be an object, got %T: %v", got["stacktrace"], got["stacktrace"])
}
if st["0"] != "frame0" || st["1"] != "frame1" {
t.Errorf("unexpected stacktrace contents: %v", st)
}
})

t.Run("empty-key group is inlined", func(t *testing.T) {
buf.Reset()
logger.Info("msg", slog.Group("wrap",
slog.Group("", slog.String("inlined", "yes")),
))
got := decode(t)
wrap, ok := got["wrap"].(map[string]any)
if !ok {
t.Fatalf("expected wrap to be an object, got %T", got["wrap"])
}
if wrap["inlined"] != "yes" {
t.Errorf("expected inlined attr at parent level, got: %v", wrap)
}
})

t.Run("empty group is skipped", func(t *testing.T) {
buf.Reset()
logger.Info("msg", slog.Group("ignored"), slog.String("kept", "v"))
got := decode(t)
if _, present := got["ignored"]; present {
t.Errorf("empty group should be omitted, got: %v", got)
}
if got["kept"] != "v" {
t.Errorf("expected sibling attr to survive, got: %v", got)
}
})

t.Run("group with time, duration, float, uint", func(t *testing.T) {
buf.Reset()
when := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
logger.Info("msg", slog.Group("g",
slog.Time("t", when),
slog.Duration("d", 1500*time.Millisecond),
slog.Float64("f", 2.5),
slog.Uint64("u", uint64(1)<<33),
))
got := decode(t)
g, ok := got["g"].(map[string]any)
if !ok {
t.Fatalf("expected g to be an object, got %T: %v", got["g"], got["g"])
}
if g["t"] != "2025-01-02T03:04:05.000Z" {
t.Errorf("unexpected time: %v", g["t"])
}
if g["d"] != 1.5 {
t.Errorf("unexpected duration: %v", g["d"])
}
if g["f"] != 2.5 {
t.Errorf("unexpected float: %v", g["f"])
}
if g["u"] != float64(uint64(1)<<33) {
t.Errorf("unexpected uint: %v", g["u"])
}
})

t.Run("LogValuer returning group is resolved and rendered", func(t *testing.T) {
buf.Reset()
logger.Info("msg",
slog.Any("user", logValuerFunc(func() slog.Value {
return slog.GroupValue(
slog.String("name", "alice"),
slog.Int("age", 30),
)
})),
)
got := decode(t)
user, ok := got["user"].(map[string]any)
if !ok {
t.Fatalf("expected user to be an object, got %T: %v", got["user"], got["user"])
}
if user["name"] != "alice" || user["age"] != float64(30) {
t.Errorf("unexpected user contents: %v", user)
}
})

t.Run("top-level empty-key group inlines into record", func(t *testing.T) {
buf.Reset()
logger.Info("msg", slog.Group("",
slog.String("inlined", "yes"),
slog.Int("n", 7),
))
got := decode(t)
if got["inlined"] != "yes" {
t.Errorf("expected inlined string at record level: %v", got)
}
if got["n"] != float64(7) {
t.Errorf("expected inlined int at record level: %v", got)
}
if _, present := got[""]; present {
t.Errorf("empty-key group should not produce an empty-key field, got: %v", got)
}
})
}

func TestSubsystemAwareLevelControl(t *testing.T) {
// Save and restore global state
originalDefault := slog.Default()
Expand Down
Loading