Skip to content

Commit ee66efa

Browse files
committed
feat: context attributes
1 parent 9ae8593 commit ee66efa

4 files changed

Lines changed: 200 additions & 0 deletions

File tree

bench_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package logger_test
22

33
import (
4+
"context"
45
"log/slog"
56
"os"
67
"testing"
@@ -102,6 +103,32 @@ func BenchmarkLogger_JsonCtx(b *testing.B) {
102103
})
103104
}
104105

106+
func BenchmarkLogger_WithContext(b *testing.B) {
107+
log := logger.New(discard{}, logger.LogfmtFormat(), logger.Debug)
108+
goCtx := context.Background()
109+
110+
b.ReportAllocs()
111+
b.ResetTimer()
112+
b.RunParallel(func(pb *testing.PB) {
113+
for pb.Next() {
114+
_ = logger.WithContext(goCtx, log, ctx.Str("req_id", "abc123"), ctx.Int("attempt", 1))
115+
}
116+
})
117+
}
118+
119+
func BenchmarkLogger_FromContext(b *testing.B) {
120+
log := logger.New(discard{}, logger.LogfmtFormat(), logger.Debug).With(ctx.Str("_n", "bench"))
121+
goCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc123"), ctx.Int("attempt", 1))
122+
123+
b.ReportAllocs()
124+
b.ResetTimer()
125+
b.RunParallel(func(pb *testing.PB) {
126+
for pb.Next() {
127+
log.FromContext(goCtx).Info("some message")
128+
}
129+
})
130+
}
131+
105132
func BenchmarkHandler_Logfmt(b *testing.B) {
106133
h := logger.NewHandler(discard{}, logger.LogfmtFormat(), slog.LevelDebug)
107134
log := slog.New(h)

context.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package logger
2+
3+
import "context"
4+
5+
type contextKey struct{}
6+
7+
type ctxFields struct {
8+
b []byte
9+
}
10+
11+
// WithContext returns a new context carrying the given fields.
12+
// Any fields previously attached via WithContext are preserved;
13+
// the new fields are appended after them. Any fields already
14+
// added to the logger are not attached to the context.
15+
//
16+
// If the logger discards output or no fields are given, ctx is returned
17+
// unchanged.
18+
func WithContext(ctx context.Context, log *Logger, fields ...Field) context.Context {
19+
if log.isDiscard || len(fields) == 0 {
20+
return ctx
21+
}
22+
23+
e := newEvent(log.fmtr)
24+
defer putEvent(e)
25+
26+
if existing, _ := ctx.Value(contextKey{}).(*ctxFields); existing != nil {
27+
e.buf.Write(existing.b)
28+
}
29+
30+
for _, field := range fields {
31+
field(e)
32+
}
33+
34+
b := make([]byte, e.buf.Len())
35+
copy(b, e.buf.Bytes())
36+
37+
return context.WithValue(ctx, contextKey{}, &ctxFields{b: b})
38+
}
39+
40+
// FromContext returns a new Logger extended with any fields attached to ctx
41+
// via WithContext. The context fields appear after the logger's own pre-rendered
42+
// fields (set via With) and before per-call fields.
43+
//
44+
// If the logger discards output or ctx carries no fields, the receiver is
45+
// returned unchanged.
46+
func (l *Logger) FromContext(ctx context.Context) *Logger {
47+
if l.isDiscard {
48+
return l
49+
}
50+
51+
fields, ok := ctx.Value(contextKey{}).(*ctxFields)
52+
if !ok || len(fields.b) == 0 {
53+
return l
54+
}
55+
56+
b := make([]byte, len(l.ctx)+len(fields.b))
57+
copy(b, l.ctx)
58+
copy(b[len(l.ctx):], fields.b)
59+
60+
return &Logger{
61+
w: l.w,
62+
isDiscard: l.isDiscard,
63+
fmtr: l.fmtr,
64+
timeFn: l.timeFn,
65+
lvl: l.lvl,
66+
ctx: b,
67+
}
68+
}

context_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package logger_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"testing"
8+
9+
"github.com/hamba/logger/v2"
10+
"github.com/hamba/logger/v2/ctx"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestLogger_WithContextAttachesFields_Logfmt(t *testing.T) {
15+
t.Parallel()
16+
17+
var buf bytes.Buffer
18+
log := logger.New(&buf, logger.LogfmtFormat(), logger.Info).With(ctx.Str("svc", "api"))
19+
20+
goCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc123"))
21+
log.FromContext(goCtx).Info("handled")
22+
23+
assert.Equal(t, "lvl=info msg=handled svc=api req_id=abc123\n", buf.String())
24+
}
25+
26+
func TestLogger_WithContextAttachesFields_JSON(t *testing.T) {
27+
t.Parallel()
28+
29+
var buf bytes.Buffer
30+
log := logger.New(&buf, logger.JSONFormat(), logger.Info).With(ctx.Str("svc", "api"))
31+
32+
goCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc123"))
33+
log.FromContext(goCtx).Info("handled")
34+
35+
assert.Equal(t, `{"lvl":"info","msg":"handled","svc":"api","req_id":"abc123"}`+"\n", buf.String())
36+
}
37+
38+
func TestLogger_WithContextCanLayersFields(t *testing.T) {
39+
t.Parallel()
40+
41+
var buf bytes.Buffer
42+
log := logger.New(&buf, logger.LogfmtFormat(), logger.Info)
43+
44+
goCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc123"))
45+
goCtx = logger.WithContext(goCtx, log, ctx.Str("user", "u456"))
46+
log.FromContext(goCtx).Info("handled")
47+
48+
assert.Equal(t, "lvl=info msg=handled req_id=abc123 user=u456\n", buf.String())
49+
}
50+
51+
func TestLogger_WithContextWithNoFieldsReturnsCtxUnchanged(t *testing.T) {
52+
t.Parallel()
53+
54+
log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Info)
55+
goCtx := context.Background()
56+
57+
got := logger.WithContext(goCtx, log)
58+
59+
assert.Equal(t, goCtx, got)
60+
}
61+
62+
func TestLogger_WithContextWithDiscardReturnsCtxUnchanged(t *testing.T) {
63+
t.Parallel()
64+
65+
log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Info)
66+
goCtx := context.Background()
67+
68+
got := logger.WithContext(goCtx, log, ctx.Str("req_id", "abc123"))
69+
70+
assert.Equal(t, goCtx, got)
71+
}
72+
73+
func TestLogger_FromContextWithNoContextFieldsReturnsSameLogger(t *testing.T) {
74+
t.Parallel()
75+
76+
var buf bytes.Buffer
77+
log := logger.New(&buf, logger.LogfmtFormat(), logger.Info)
78+
79+
got := log.FromContext(context.Background())
80+
81+
assert.Same(t, log, got)
82+
}
83+
84+
func TestLogger_FromContextWithDiscardReturnsSameLogger(t *testing.T) {
85+
t.Parallel()
86+
87+
log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Info)
88+
goCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc123"))
89+
90+
// Re-attach to a non-discard logger to prove the discard check fires first.
91+
discardLog := logger.New(io.Discard, logger.LogfmtFormat(), logger.Info)
92+
got := discardLog.FromContext(goCtx)
93+
94+
assert.Same(t, discardLog, got)
95+
}

example_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package logger_test
22

33
import (
4+
"context"
45
"log/slog"
56
"os"
67

@@ -35,3 +36,12 @@ func ExampleNewHandler_logfmt() {
3536

3637
log.Info("connected", slog.String("driver", "pgx"))
3738
}
39+
40+
func ExampleWithContext() {
41+
log := logger.New(os.Stdout, logger.LogfmtFormat(), logger.Info).With(ctx.Str("svc", "api"))
42+
43+
reqCtx := logger.WithContext(context.Background(), log, ctx.Str("req_id", "abc-123"), ctx.Str("method", "GET"))
44+
45+
reqLog := log.FromContext(reqCtx)
46+
reqLog.Info("request handled", ctx.Int("status", 200))
47+
}

0 commit comments

Comments
 (0)