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
2 changes: 1 addition & 1 deletion internal/logging/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Configure(ctx context.Context, config Config) (*slog.Logger, context.Contex
return a
}
}
handler = slog.NewJSONHandler(os.Stdout, options)
handler = &messageHandler{inner: slog.NewJSONHandler(os.Stdout, options)}
} else {
handler = tint.NewHandler(os.Stderr, &tint.Options{
Level: config.Level,
Expand Down
67 changes: 67 additions & 0 deletions internal/logging/messagehandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package logging

import (
"context"
"fmt"
"log/slog"
"strings"

"github.com/alecthomas/errors"
)

// messageHandler wraps a slog.Handler and appends record attributes to the
// message text for easier debugging (e.g. "My message (err=..., id=...)").
type messageHandler struct {
inner slog.Handler
}

func (h *messageHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}

func (h *messageHandler) Handle(ctx context.Context, r slog.Record) error {
if r.NumAttrs() > 0 {
var b strings.Builder
first := true
r.Attrs(func(a slog.Attr) bool {
if first {
first = false
} else {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%s=%s", a.Key, formatValue(a.Value))
return true
})
r.Message = r.Message + " (" + b.String() + ")"
}
return errors.Wrap(h.inner.Handle(ctx, r), "handle log record")
}

func needsQuoting(s string) bool {
for _, c := range s {
if c <= ' ' || c == '"' || c == ',' || c == '=' || c == '(' || c == ')' {
return true
}
}
return false
}

func formatValue(v slog.Value) string {
v = v.Resolve()
if v.Kind() == slog.KindString {
s := v.String()
if s == "" || needsQuoting(s) {
return fmt.Sprintf("%q", s)
}
return s
}
return v.String()
}

func (h *messageHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &messageHandler{inner: h.inner.WithAttrs(attrs)}
}

func (h *messageHandler) WithGroup(name string) slog.Handler {
return &messageHandler{inner: h.inner.WithGroup(name)}
}
115 changes: 115 additions & 0 deletions internal/logging/messagehandler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package logging //nolint:testpackage

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"testing"

"github.com/alecthomas/assert/v2"
)

func TestMessageHandler(t *testing.T) {
type logEntry struct {
Level string `json:"level"`
Msg string `json:"msg"`
Err string `json:"err,omitempty"`
ID int `json:"id,omitempty"`
Request string `json:"request,omitempty"`
}

tests := []struct {
name string
msg string
attrs []slog.Attr
wantMsg string
}{
{
name: "NoAttrs",
msg: "simple message",
wantMsg: "simple message",
},
{
name: "SingleAttr",
msg: "failed",
attrs: []slog.Attr{slog.String("err", "timeout")},
wantMsg: "failed (err=timeout)",
},
{
name: "MultipleAttrs",
msg: "request handled",
attrs: []slog.Attr{
slog.String("request", "/foo"),
slog.Int("id", 42),
},
wantMsg: "request handled (request=/foo, id=42)",
},
{
name: "QuotedStringWithSpaces",
msg: "failed",
attrs: []slog.Attr{slog.String("err", "connection refused, try again")},
wantMsg: `failed (err="connection refused, try again")`,
},
{
name: "EmptyString",
msg: "failed",
attrs: []slog.Attr{slog.String("reason", "")},
wantMsg: `failed (reason="")`,
},
{
name: "SimpleWordUnquoted",
msg: "done",
attrs: []slog.Attr{slog.String("status", "ok")},
wantMsg: "done (status=ok)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})
handler := &messageHandler{inner: inner}
logger := slog.New(handler)

args := make([]any, 0, len(tt.attrs)*2)
for _, a := range tt.attrs {
args = append(args, a.Key, a.Value)
}
logger.Info(tt.msg, args...)

var entry logEntry
assert.NoError(t, json.Unmarshal(buf.Bytes(), &entry))
assert.Equal(t, tt.wantMsg, entry.Msg)
})
}
}

func TestMessageHandlerWithContextAttrs(t *testing.T) {
var buf bytes.Buffer
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})
handler := &messageHandler{inner: inner}
logger := slog.New(handler).With("client", "10.0.0.1")
logger.InfoContext(context.Background(), "connected", "id", 7)

var entry map[string]any
assert.NoError(t, json.Unmarshal(buf.Bytes(), &entry))
// Only record-level attrs appear in the message suffix; context attrs do not.
assert.Equal(t, "connected (id=7)", entry["msg"])
assert.Equal(t, "10.0.0.1", entry["client"])
assert.Equal(t, float64(7), entry["id"].(float64))
}