├── go.mod ├── go.sum ├── trace.go ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── internal ├── slogtest │ ├── capture.go │ └── error_handler.go └── require │ └── require.go ├── README.md ├── labels_test.go ├── labels.go ├── handler.go └── handler_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jussi-kalliokoski/slogdriver 2 | 3 | go 1.21 4 | 5 | require github.com/jussi-kalliokoski/goldjson v1.0.0 // indirect 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/jussi-kalliokoski/goldjson v0.0.0-20230613091353-057ded0698bb h1:7XODZ2DjK3gHJc+52VmwMzvU5gDcqgW0nDk0iQwZ0v0= 2 | github.com/jussi-kalliokoski/goldjson v0.0.0-20230613091353-057ded0698bb/go.mod h1:KHjhomAO4vlPukhBzc5nwIJ2nNL39TLnEgoIsBd8bnY= 3 | github.com/jussi-kalliokoski/goldjson v1.0.0 h1:XqiUNujQ3e9mjFPsqEBTzaMVPNnMUlXa+yDEVT4Xla0= 4 | github.com/jussi-kalliokoski/goldjson v1.0.0/go.mod h1:KHjhomAO4vlPukhBzc5nwIJ2nNL39TLnEgoIsBd8bnY= 5 | -------------------------------------------------------------------------------- /trace.go: -------------------------------------------------------------------------------- 1 | package slogdriver 2 | 3 | import "context" 4 | 5 | // Trace contains tracing information used in logging. 6 | type Trace struct { 7 | ID string 8 | SpanID string 9 | Sampled bool 10 | } 11 | 12 | func traceFromContext(ctx context.Context) Trace { 13 | v, _ := ctx.Value(traceContextKeyT{}).(Trace) 14 | return v 15 | } 16 | 17 | // Context returns a Context that stores the Trace. 18 | func (trace Trace) Context(ctx context.Context) context.Context { 19 | return context.WithValue(ctx, traceContextKeyT{}, trace) 20 | } 21 | 22 | type traceContextKeyT struct{} 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: [push] 3 | permissions: 4 | contents: read 5 | jobs: 6 | test: 7 | name: Test on go ${{ matrix.go_version }} ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | go_version: ["1.21"] 12 | os: [ubuntu-latest] 13 | steps: 14 | - name: Setup go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version: ${{ matrix.go_version }} 18 | cache: false 19 | id: go 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | - name: Test 23 | run: go test -v -cover ./... 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 Jussi Kalliokoski 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /internal/slogtest/capture.go: -------------------------------------------------------------------------------- 1 | package slogtest 2 | 3 | import ( 4 | "encoding/json" 5 | "sync" 6 | ) 7 | 8 | // Capture is an io.Writer that unmarshals the written data into entries of 9 | // type T, to be later retrieved with Entries(). Written buffers must be valid 10 | // JSON by themselves, and if the unmarshaling errors, Write will return an 11 | // error. 12 | type Capture[T any] struct { 13 | m sync.Mutex 14 | entries []T 15 | } 16 | 17 | // Write implements io.Writer. 18 | func (c *Capture[T]) Write(data []byte) (n int, err error) { 19 | n = len(data) 20 | 21 | var entry T 22 | if err = json.Unmarshal(data, &entry); err != nil { 23 | return n, err 24 | } 25 | 26 | c.m.Lock() 27 | defer c.m.Unlock() 28 | c.entries = append(c.entries, entry) 29 | 30 | return n, nil 31 | } 32 | 33 | // Entries returns the captured entries. 34 | func (c *Capture[T]) Entries() []T { 35 | c.m.Lock() 36 | defer c.m.Unlock() 37 | return c.entries 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slogdriver 2 | 3 | [![GoDoc](https://godoc.org/github.com/jussi-kalliokoski/slogdriver?status.svg)](https://godoc.org/github.com/jussi-kalliokoski/slogdriver) 4 | [![CI status](https://github.com/jussi-kalliokoski/slogdriver/workflows/CI/badge.svg)](https://github.com/jussi-kalliokoski/slogdriver/actions) 5 | 6 | Stackdriver Logging / GCP Cloud Logging handler for go the [slog](https://pkg.go.dev/log/slog) package for structured logging introduced in the standard library of go 1.21. 7 | 8 | NOTE: slogdriver requires go 1.21. 9 | 10 | ## Design Goals 11 | 12 | - Improved performance compared to using the `JSONHandler` with `ReplaceAttr` to achieve the same purpose. This is achieved by using [goldjson](https://github.com/jussi-kalliokoski/goldjson) under the hood. 13 | - Batteries included, e.g. builtin support for labels and traces. The trace information still needs to be provided separately as the library is agnostic as to which telemetry libraries (or versions) you choose to use. It is still highly advised to use [OpenTelemetry](https://opentelemetry.io/docs/instrumentation/go/). 14 | - Minimal dependencies. 15 | -------------------------------------------------------------------------------- /labels_test.go: -------------------------------------------------------------------------------- 1 | package slogdriver 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestLabels(t *testing.T) { 9 | t.Run("nested labels", func(t *testing.T) { 10 | ctx := context.Background() 11 | l1 := NewLabel("key1", "value1") 12 | l2 := NewLabel("key2", "value2") 13 | l3 := NewLabel("key3", "value3") 14 | l4 := NewLabel("key4", "value4") 15 | ctx = AddLabels(ctx, l1, l2) 16 | ctx = AddLabels(ctx, l3, l4) 17 | expected := []Label{l1, l2, l3, l4} 18 | received := make([]Label, 0, len(expected)) 19 | 20 | labelsFromContext(ctx).Iterate(func(l Label) { 21 | received = append(received, l) 22 | }) 23 | 24 | requireEqualSlices(t, expected, received) 25 | }) 26 | } 27 | 28 | func requireEqualSlices[T comparable](tb testing.TB, expected, received []T) { 29 | if len(expected) != len(received) { 30 | tb.Fatalf("expected a slice of len() %d, got a slice of len() %d", len(expected), len(received)) 31 | } 32 | for i := range expected { 33 | if expected[i] != received[i] { 34 | tb.Fatalf("expected a slice with value %#v at index %d, got %#v", expected[i], i, received[i]) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /labels.go: -------------------------------------------------------------------------------- 1 | package slogdriver 2 | 3 | import "context" 4 | 5 | // Label represents a key-value string pair. 6 | type Label struct { 7 | Key string 8 | Value string 9 | } 10 | 11 | // NewLabel returns a new Label from a key and a value. 12 | func NewLabel(key, value string) Label { 13 | return Label{Key: key, Value: value} 14 | } 15 | 16 | // AddLabels returns a new Context with additional labels to be used in the log 17 | // entries produced using that context. 18 | // 19 | // See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#FIELDS.labels 20 | func AddLabels(ctx context.Context, labels ...Label) context.Context { 21 | return context.WithValue(ctx, labelsContextKeyT{}, &labelContainer{ 22 | Labels: labels, 23 | Parent: labelsFromContext(ctx), 24 | }) 25 | } 26 | 27 | func labelsFromContext(ctx context.Context) *labelContainer { 28 | v, _ := ctx.Value(labelsContextKeyT{}).(*labelContainer) 29 | return v 30 | } 31 | 32 | type labelsContextKeyT struct{} 33 | 34 | type labelContainer struct { 35 | Labels []Label 36 | Parent *labelContainer 37 | } 38 | 39 | func (l *labelContainer) Iterate(f func(Label)) { 40 | if l == nil { 41 | return 42 | } 43 | l.Parent.Iterate(f) 44 | for _, label := range l.Labels { 45 | f(label) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/slogtest/error_handler.go: -------------------------------------------------------------------------------- 1 | package slogtest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "sync" 8 | ) 9 | 10 | // ErrorHandler is used for capturing errors from a slog.Handler as slog.Logger 11 | // swallows them. 12 | type ErrorHandler struct { 13 | inner slog.Handler 14 | errorCapture *errorCapture 15 | } 16 | 17 | // NewErrorHandler returns a new ErrorHandler. 18 | func NewErrorHandler(inner slog.Handler) *ErrorHandler { 19 | return &ErrorHandler{ 20 | inner: inner, 21 | errorCapture: &errorCapture{}, 22 | } 23 | } 24 | 25 | // NewWithErrorHandler wraps the passed slog.Handler into an ErrorHandler, 26 | // creates a new slog.Logger, then returns the Logger and the ErrorHandler. 27 | func NewWithErrorHandler(h slog.Handler) (*slog.Logger, *ErrorHandler) { 28 | errorHandler := NewErrorHandler(h) 29 | logger := slog.New(errorHandler) 30 | return logger, errorHandler 31 | } 32 | 33 | // Enabled implements slog.Handler. 34 | func (h *ErrorHandler) Enabled(ctx context.Context, level slog.Level) bool { 35 | return h.inner.Enabled(ctx, level) 36 | } 37 | 38 | // Handle implements slog.Handler. 39 | func (h *ErrorHandler) Handle(ctx context.Context, r slog.Record) error { 40 | return h.errorCapture.capture(h.inner.Handle(ctx, r)) 41 | } 42 | 43 | // WithAttrs implements slog.Handler. 44 | func (h *ErrorHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 45 | return &ErrorHandler{ 46 | inner: h.inner.WithAttrs(attrs), 47 | errorCapture: h.errorCapture, 48 | } 49 | } 50 | 51 | // WithGroup implements slog.Handler. 52 | func (h *ErrorHandler) WithGroup(name string) slog.Handler { 53 | return &ErrorHandler{ 54 | inner: h.inner.WithGroup(name), 55 | errorCapture: h.errorCapture, 56 | } 57 | } 58 | 59 | // Err returns the captured error(s). 60 | func (h *ErrorHandler) Err() error { 61 | return h.errorCapture.Err() 62 | } 63 | 64 | type errorCapture struct { 65 | m sync.Mutex 66 | err error 67 | } 68 | 69 | func (c *errorCapture) capture(err error) error { 70 | c.m.Lock() 71 | defer c.m.Unlock() 72 | c.err = errors.Join(c.err, err) 73 | return err 74 | } 75 | 76 | func (c *errorCapture) Err() error { 77 | c.m.Lock() 78 | defer c.m.Unlock() 79 | return c.err 80 | } 81 | -------------------------------------------------------------------------------- /internal/require/require.go: -------------------------------------------------------------------------------- 1 | package require 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Equal[T any](tb testing.TB, expected, received T, message ...string) { 11 | tb.Helper() 12 | 13 | if err := equal(tb, expected, received); !isEqual(err) { 14 | if len(message) != 0 { 15 | tb.Fatal(message) 16 | } else { 17 | tb.Fatal(err) 18 | } 19 | } 20 | } 21 | 22 | func NoError(tb testing.TB, err error) { 23 | tb.Helper() 24 | if err != nil { 25 | tb.Fatalf("expected no error, got %##v", err) 26 | } 27 | } 28 | 29 | func Error(tb testing.TB, err error) { 30 | tb.Helper() 31 | if err == nil { 32 | tb.Fatalf("expected error, got ") 33 | } 34 | } 35 | 36 | func equal[T any](tb testing.TB, expected, received T) error { 37 | if err := equalEqualer(tb, expected, received); err != nil { 38 | return err 39 | } 40 | 41 | return equalReflect(tb, expected, received) 42 | } 43 | 44 | func equalEqualer[T any](tb testing.TB, expected, received T) error { 45 | type Equaler interface { 46 | Equal(T) bool 47 | } 48 | 49 | if eq, ok := any(expected).(Equaler); ok { 50 | if !eq.Equal(received) { 51 | return fmt.Errorf("expected %##v, got %##v", expected, received) 52 | } 53 | return errEqual 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func equalReflect(tb testing.TB, expected, received any) error { 60 | t := reflect.TypeOf(expected) 61 | if t != reflect.TypeOf(received) { 62 | return fmt.Errorf("expected %##v, got %##v: mismatched types", expected, received) 63 | } 64 | 65 | switch t.Kind() { 66 | case 67 | reflect.Bool, 68 | reflect.Int, 69 | reflect.Int8, 70 | reflect.Int16, 71 | reflect.Int32, 72 | reflect.Int64, 73 | reflect.Uint, 74 | reflect.Uint8, 75 | reflect.Uint16, 76 | reflect.Uint32, 77 | reflect.Uint64, 78 | reflect.Uintptr, 79 | reflect.Float32, 80 | reflect.Float64, 81 | reflect.Complex64, 82 | reflect.Complex128, 83 | reflect.String, 84 | reflect.UnsafePointer: 85 | if expected != received { 86 | return fmt.Errorf("expected %##v, got %##v", expected, received) 87 | } 88 | return errEqual 89 | case reflect.Array, reflect.Slice: 90 | return equalIndexed(tb, expected, received) 91 | case reflect.Map: 92 | return equalMaps(tb, expected, received) 93 | case reflect.Pointer: 94 | return equalPointers(tb, expected, received) 95 | case reflect.Struct: 96 | return equalStructs(tb, expected, received) 97 | case reflect.Chan, reflect.Func: 98 | ep := reflect.ValueOf(expected) 99 | rp := reflect.ValueOf(received) 100 | if ep.IsNil() == rp.IsNil() { 101 | return errEqual 102 | } 103 | } 104 | 105 | return fmt.Errorf("expected %##v, got %##v: values are not comparable", expected, received) 106 | } 107 | 108 | func equalIndexed(tb testing.TB, expected, received any) error { 109 | es := reflect.ValueOf(expected) 110 | rs := reflect.ValueOf(received) 111 | if es.IsNil() != rs.IsNil() { 112 | return fmt.Errorf("expected %##v, got %##v", expected, received) 113 | } 114 | var errs error 115 | elen := es.Len() 116 | rlen := rs.Len() 117 | for i := 0; i < elen; i++ { 118 | ev := es.Index(i).Interface() 119 | if i >= rlen { 120 | errs = errors.Join(errs, fmt.Errorf("missing value %##v at index %d", ev, i)) 121 | } else { 122 | rv := rs.Index(i).Interface() 123 | if err := equalReflect(tb, ev, rv); !isEqual(err) { 124 | errs = errors.Join(errs, fmt.Errorf("values at index %##v differ: %w", i, err)) 125 | } 126 | } 127 | } 128 | for i := elen; i < rlen; i++ { 129 | rv := rs.Index(i).Interface() 130 | if i >= elen { 131 | errs = errors.Join(errs, fmt.Errorf("extra value %##v at index %d", rv, i)) 132 | } else { 133 | ev := es.Index(i).Interface() 134 | if err := equalReflect(tb, ev, rv); !isEqual(err) { 135 | errs = errors.Join(errs, fmt.Errorf("values at index %d differ: %w", i, err)) 136 | } 137 | } 138 | } 139 | 140 | if errs != nil { 141 | return fmt.Errorf("expected %##v, got %##v: %w", expected, received, errs) 142 | } 143 | 144 | return errEqual 145 | } 146 | 147 | func equalMaps(tb testing.TB, expected, received any) error { 148 | em := reflect.ValueOf(expected) 149 | rm := reflect.ValueOf(received) 150 | if em.IsNil() != rm.IsNil() { 151 | return fmt.Errorf("expected %##v, got %##v", expected, received) 152 | } 153 | var errs error 154 | ekeys := map[interface{}]struct{}{} 155 | eiter := em.MapRange() 156 | var zeroValue reflect.Value 157 | for eiter.Next() { 158 | i := eiter.Key() 159 | k := i.Interface() 160 | ekeys[k] = struct{}{} 161 | ev := eiter.Value().Interface() 162 | rvi := rm.MapIndex(i) 163 | if rvi == zeroValue { 164 | errs = errors.Join(errs, fmt.Errorf("missing value %##v at index %##v", ev, i)) 165 | } else { 166 | rv := rvi.Interface() 167 | if err := equalReflect(tb, ev, rv); !isEqual(err) { 168 | errs = errors.Join(errs, fmt.Errorf("values at index %##v differ: %w", k, err)) 169 | } 170 | } 171 | } 172 | riter := em.MapRange() 173 | for riter.Next() { 174 | i := riter.Key() 175 | k := i.Interface() 176 | if _, ok := ekeys[k]; ok { 177 | continue 178 | } 179 | rv := riter.Value().Interface() 180 | evi := em.MapIndex(i) 181 | if evi == zeroValue { 182 | errs = errors.Join(errs, fmt.Errorf("extra value %##v at index %##v", rv, i)) 183 | } else { 184 | ev := evi.Interface() 185 | if err := equalReflect(tb, ev, rv); !isEqual(err) { 186 | errs = errors.Join(errs, fmt.Errorf("values at index %##v differ: %w", k, err)) 187 | } 188 | } 189 | } 190 | 191 | if errs != nil { 192 | return fmt.Errorf("expected %##v, got %##v: %w", expected, received, errs) 193 | } 194 | 195 | return errEqual 196 | } 197 | 198 | func equalPointers(tb testing.TB, expected, received any) error { 199 | ep := reflect.ValueOf(expected) 200 | rp := reflect.ValueOf(received) 201 | if ep.IsNil() != rp.IsNil() { 202 | return fmt.Errorf("expected %##v, got %##v", expected, received) 203 | } 204 | if expected == received { 205 | return errEqual 206 | } 207 | ev := ep.Elem().Interface() 208 | rv := rp.Elem().Interface() 209 | return equalReflect(tb, ev, rv) 210 | } 211 | 212 | func equalStructs(tb testing.TB, expected, received any) error { 213 | es := reflect.ValueOf(expected) 214 | rs := reflect.ValueOf(received) 215 | t := es.Type() 216 | n := t.NumField() 217 | var errs error 218 | for i := 0; i < n; i++ { 219 | ev := es.Field(i).Interface() 220 | rv := rs.Field(i).Interface() 221 | if err := equalReflect(tb, ev, rv); !isEqual(err) { 222 | errs = errors.Join(errs, fmt.Errorf("values in field %q differ: %w", t.Field(i).Name, err)) 223 | } 224 | } 225 | 226 | if errs != nil { 227 | return fmt.Errorf("expected %##v, got %##v: %w", expected, received, errs) 228 | } 229 | 230 | return errEqual 231 | } 232 | 233 | var errEqual = errors.New("equal") 234 | 235 | func isEqual(err error) bool { 236 | return err == errEqual || err == nil 237 | } 238 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package slogdriver 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "runtime" 11 | 12 | "github.com/jussi-kalliokoski/goldjson" 13 | ) 14 | 15 | // Config defines the Stackdriver configuration. 16 | type Config struct { 17 | ProjectID string 18 | Level slog.Leveler 19 | } 20 | 21 | // Handler is a handler that writes the log entries in the stackdriver logging 22 | // JSON format. 23 | type Handler struct { 24 | encoder *goldjson.Encoder 25 | config Config 26 | attrBuilders []func(ctx context.Context, h *Handler, l *goldjson.LineWriter, next func(context.Context) error) error 27 | } 28 | 29 | // NewHandler returns a new Handler. 30 | func NewHandler(w io.Writer, config Config) *Handler { 31 | encoder := goldjson.NewEncoder(w) 32 | encoder.PrepareKey(fieldMessage) 33 | encoder.PrepareKey(fieldTimestamp) 34 | encoder.PrepareKey(fieldSeverity) 35 | encoder.PrepareKey(fieldSourceLocation) 36 | encoder.PrepareKey(fieldSourceFile) 37 | encoder.PrepareKey(fieldSourceLine) 38 | encoder.PrepareKey(fieldSourceFunction) 39 | encoder.PrepareKey(fieldTraceID) 40 | encoder.PrepareKey(fieldTraceSpanID) 41 | encoder.PrepareKey(fieldTraceSampled) 42 | encoder.PrepareKey(fieldLabels) 43 | return &Handler{ 44 | encoder: encoder, 45 | config: config, 46 | } 47 | } 48 | 49 | // Handle implements slog.Handler. 50 | func (h *Handler) Handle(ctx context.Context, r slog.Record) error { 51 | l := h.encoder.NewLine() 52 | 53 | h.addMessage(ctx, l, &r) 54 | h.addTimestamp(ctx, l, &r) 55 | h.addSeverity(ctx, l, &r) 56 | h.addSourceLocation(ctx, l, &r) 57 | h.addTrace(ctx, l, &r) 58 | h.addLabels(ctx, l, &r) 59 | 60 | err := h.addAttrs(ctx, l, &r) 61 | err = errors.Join(err, l.End()) 62 | 63 | return err 64 | } 65 | 66 | // WithAttrs implements slog.Handler. 67 | func (h *Handler) WithAttrs(as []slog.Attr) slog.Handler { 68 | clone := *h 69 | staticFields, w := goldjson.NewStaticFields() 70 | var err error 71 | for _, attr := range as { 72 | err = errors.Join(err, h.addAttr(w, attr)) 73 | } 74 | clone.attrBuilders = cloneAppend( 75 | h.attrBuilders, 76 | func(ctx context.Context, h *Handler, l *goldjson.LineWriter, next func(context.Context) error) error { 77 | l.AddStaticFields(staticFields) 78 | return errors.Join(err, next(ctx)) 79 | }, 80 | ) 81 | err = w.End() 82 | return &clone 83 | } 84 | 85 | // WithGroup implements slog.Handler. 86 | func (h *Handler) WithGroup(name string) slog.Handler { 87 | clone := *h 88 | clone.encoder = h.encoder.Clone() 89 | clone.encoder.PrepareKey(name) 90 | clone.attrBuilders = cloneAppend( 91 | h.attrBuilders, 92 | func(ctx context.Context, h *Handler, l *goldjson.LineWriter, next func(context.Context) error) error { 93 | l.StartRecord(name) 94 | defer l.EndRecord() 95 | return next(ctx) 96 | }, 97 | ) 98 | return &clone 99 | } 100 | 101 | // Enabled implements slog.Handler. 102 | func (h *Handler) Enabled(ctx context.Context, l slog.Level) bool { 103 | minLevel := slog.LevelInfo 104 | if h.config.Level != nil { 105 | minLevel = h.config.Level.Level() 106 | } 107 | return l >= minLevel 108 | } 109 | 110 | func (h *Handler) addMessage(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 111 | l.AddString(fieldMessage, r.Message) 112 | } 113 | 114 | func (h *Handler) addTimestamp(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 115 | time := r.Time.Round(0) // strip monotonic to match Attr behavior 116 | l.AddTime(fieldTimestamp, time) 117 | } 118 | 119 | func (h *Handler) addSeverity(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 120 | switch { 121 | case r.Level >= slog.LevelError: 122 | l.AddString(fieldSeverity, "ERROR") 123 | case r.Level >= slog.LevelWarn: 124 | l.AddString(fieldSeverity, "WARN") 125 | case r.Level >= slog.LevelInfo: 126 | l.AddString(fieldSeverity, "INFO") 127 | default: 128 | l.AddString(fieldSeverity, "DEBUG") 129 | } 130 | } 131 | 132 | func (h *Handler) addSourceLocation(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 133 | fs := runtime.CallersFrames([]uintptr{r.PC}) 134 | f, _ := fs.Next() 135 | 136 | l.StartRecord(fieldSourceLocation) 137 | defer l.EndRecord() 138 | 139 | l.AddString(fieldSourceFile, f.File) 140 | l.AddInt64(fieldSourceLine, int64(f.Line)) 141 | l.AddString(fieldSourceFunction, f.Function) 142 | } 143 | 144 | func (h *Handler) addTrace(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 145 | trace := traceFromContext(ctx) 146 | if trace.ID == "" { 147 | return 148 | } 149 | 150 | l.AddString(fieldTraceID, fmt.Sprintf("projects/%s/traces/%s", h.config.ProjectID, trace.ID)) 151 | if trace.SpanID != "" { 152 | l.AddString(fieldTraceSpanID, trace.SpanID) 153 | } 154 | l.AddBool(fieldTraceSampled, trace.Sampled) 155 | } 156 | 157 | func (h *Handler) addLabels(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) { 158 | opened := false 159 | labelsFromContext(ctx).Iterate(func(label Label) { 160 | if !opened { 161 | opened = true 162 | l.StartRecord("logging.googleapis.com/labels") 163 | } 164 | l.AddString(label.Key, label.Value) 165 | }) 166 | if opened { 167 | l.EndRecord() 168 | } 169 | } 170 | 171 | func (h *Handler) addAttrs(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) error { 172 | if len(h.attrBuilders) == 0 { 173 | return h.addAttrsRaw(ctx, l, r) 174 | } 175 | 176 | b := func(ctx context.Context) error { 177 | return h.addAttrsRaw(ctx, l, r) 178 | } 179 | 180 | for i := range h.attrBuilders { 181 | attrBuilder := h.attrBuilders[len(h.attrBuilders)-1-i] 182 | next := b 183 | b = func(ctx context.Context) error { 184 | return attrBuilder(ctx, h, l, next) 185 | } 186 | } 187 | 188 | return b(ctx) 189 | } 190 | 191 | func (h *Handler) addAttrsRaw(ctx context.Context, l *goldjson.LineWriter, r *slog.Record) error { 192 | var err error 193 | r.Attrs(func(attr slog.Attr) bool { 194 | err = errors.Join(err, h.addAttr(l, attr)) 195 | return true 196 | }) 197 | return err 198 | } 199 | 200 | func (h *Handler) addAttr(l *goldjson.LineWriter, a slog.Attr) error { 201 | v := a.Value.Resolve() 202 | switch v.Kind() { 203 | case slog.KindGroup: 204 | return h.addGroup(l, a, v) 205 | case slog.KindString: 206 | l.AddString(a.Key, v.String()) 207 | return nil 208 | case slog.KindInt64: 209 | l.AddInt64(a.Key, v.Int64()) 210 | return nil 211 | case slog.KindUint64: 212 | l.AddUint64(a.Key, v.Uint64()) 213 | return nil 214 | case slog.KindFloat64: 215 | l.AddFloat64(a.Key, v.Float64()) 216 | return nil 217 | case slog.KindBool: 218 | l.AddBool(a.Key, v.Bool()) 219 | return nil 220 | case slog.KindDuration: 221 | l.AddInt64(a.Key, int64(v.Duration())) 222 | return nil 223 | case slog.KindTime: 224 | return l.AddTime(a.Key, v.Time()) 225 | case slog.KindAny: 226 | return h.addAny(l, a, v) 227 | } 228 | return fmt.Errorf("bad kind: %s", v.Kind()) 229 | } 230 | 231 | func (h *Handler) addGroup(l *goldjson.LineWriter, a slog.Attr, v slog.Value) error { 232 | attrs := v.Group() 233 | if len(attrs) == 0 { 234 | return nil 235 | } 236 | l.StartRecord(a.Key) 237 | defer l.EndRecord() 238 | var err error 239 | for _, a := range attrs { 240 | err = errors.Join(err, h.addAttr(l, a)) 241 | } 242 | return err 243 | } 244 | 245 | func (h *Handler) addAny(l *goldjson.LineWriter, a slog.Attr, v slog.Value) error { 246 | val := v.Any() 247 | _, jm := val.(json.Marshaler) 248 | if err, ok := val.(error); ok && !jm { 249 | l.AddString(a.Key, err.Error()) 250 | return nil 251 | } 252 | return l.AddMarshal(a.Key, val) 253 | } 254 | 255 | const ( 256 | fieldMessage = "message" 257 | fieldTimestamp = "timestamp" 258 | fieldSeverity = "severity" 259 | fieldSourceLocation = "logging.googleapis.com/sourceLocation" 260 | fieldSourceFile = "file" 261 | fieldSourceLine = "line" 262 | fieldSourceFunction = "function" 263 | fieldTraceID = "logging.googleapis.com/trace" 264 | fieldTraceSpanID = "logging.googleapis.com/spanId" 265 | fieldTraceSampled = "logging.googleapis.com/trace_sampled" 266 | fieldLabels = "logging.googleapis.com/labels" 267 | ) 268 | 269 | func cloneSlice[T any](slice []T, extraCap int) []T { 270 | return append(make([]T, 0, len(slice)+extraCap), slice...) 271 | } 272 | 273 | func cloneAppend[T any](slice []T, values ...T) []T { 274 | return append(cloneSlice(slice, len(values)), values...) 275 | } 276 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package slogdriver_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "runtime" 11 | "testing" 12 | "time" 13 | "unsafe" 14 | 15 | "github.com/jussi-kalliokoski/slogdriver" 16 | "github.com/jussi-kalliokoski/slogdriver/internal/require" 17 | "github.com/jussi-kalliokoski/slogdriver/internal/slogtest" 18 | ) 19 | 20 | func TestHandler(t *testing.T) { 21 | t.Run("Enabled", func(t *testing.T) { 22 | tests := []struct { 23 | name string 24 | leveler slog.Leveler 25 | level slog.Level 26 | expected bool 27 | }{ 28 | {"nil info", nil, slog.LevelInfo, true}, 29 | {"nil debug", nil, slog.LevelDebug, false}, 30 | {"debug info", slog.LevelDebug, slog.LevelInfo, true}, 31 | {"debug debug", slog.LevelDebug, slog.LevelDebug, true}, 32 | {"error warn", slog.LevelError, slog.LevelWarn, false}, 33 | {"error error", slog.LevelError, slog.LevelError, true}, 34 | } 35 | 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | type Entry struct{} 39 | 40 | ctx := context.Background() 41 | var capture slogtest.Capture[Entry] 42 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{ 43 | Level: tt.leveler, 44 | })) 45 | 46 | logger.LogAttrs(ctx, tt.level, "level") 47 | entries := capture.Entries() 48 | received := len(entries) == 1 49 | err := errs.Err() 50 | 51 | require.NoError(t, err) 52 | require.Equal(t, tt.expected, received) 53 | }) 54 | } 55 | }) 56 | 57 | t.Run("message", func(t *testing.T) { 58 | type Entry struct { 59 | Message string `json:"message"` 60 | } 61 | 62 | var capture slogtest.Capture[Entry] 63 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 64 | 65 | logger.Info("hello") 66 | logger.Warn("world") 67 | entries := capture.Entries() 68 | err := errs.Err() 69 | 70 | require.NoError(t, err) 71 | require.Equal(t, "hello", entries[0].Message) 72 | require.Equal(t, "world", entries[1].Message) 73 | }) 74 | 75 | t.Run("severity", func(t *testing.T) { 76 | tests := []struct { 77 | name string 78 | level slog.Level 79 | expected string 80 | }{ 81 | {"debug", slog.LevelDebug, "DEBUG"}, 82 | {"info", slog.LevelInfo, "INFO"}, 83 | {"warn", slog.LevelWarn, "WARN"}, 84 | {"error", slog.LevelError, "ERROR"}, 85 | {"below debug", slog.LevelDebug - 1, "DEBUG"}, 86 | {"below info", slog.LevelInfo - 1, "DEBUG"}, 87 | {"below warn", slog.LevelWarn - 1, "INFO"}, 88 | {"below error", slog.LevelError - 1, "WARN"}, 89 | {"above error", slog.LevelError + 1, "ERROR"}, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | type Entry struct { 95 | Severity string `json:"severity"` 96 | } 97 | ctx := context.Background() 98 | var capture slogtest.Capture[Entry] 99 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{ 100 | Level: slog.Level(-1e6), 101 | })) 102 | 103 | logger.LogAttrs(ctx, tt.level, "level") 104 | entries := capture.Entries() 105 | err := errs.Err() 106 | 107 | require.NoError(t, err) 108 | require.Equal(t, tt.expected, entries[0].Severity) 109 | }) 110 | } 111 | }) 112 | 113 | t.Run("source location", func(t *testing.T) { 114 | type Entry struct { 115 | SourceLocation struct { 116 | File string `json:"file"` 117 | Line int `json:"line"` 118 | Function string `json:"function"` 119 | } `json:"logging.googleapis.com/sourceLocation"` 120 | } 121 | 122 | var capture slogtest.Capture[Entry] 123 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 124 | 125 | logger.Info("hello") 126 | fs := runtime.CallersFrames([]uintptr{getPC()}) 127 | expected, _ := fs.Next() 128 | entries := capture.Entries() 129 | received := entries[0].SourceLocation 130 | err := errs.Err() 131 | 132 | require.NoError(t, err) 133 | require.Equal(t, expected.File, received.File) 134 | require.Equal(t, expected.Line-1, received.Line) 135 | require.Equal(t, expected.Function, received.Function) 136 | }) 137 | 138 | t.Run("trace", func(t *testing.T) { 139 | type TraceInfo struct { 140 | TraceID *string `json:"logging.googleapis.com/trace"` 141 | SpanID *string `json:"logging.googleapis.com/spanId"` 142 | TraceSampled *bool `json:"logging.googleapis.com/trace_sampled"` 143 | } 144 | 145 | tests := []struct { 146 | name string 147 | config slogdriver.Config 148 | ctx context.Context 149 | expected TraceInfo 150 | }{ 151 | { 152 | "no trace info", 153 | slogdriver.Config{}, 154 | context.Background(), 155 | TraceInfo{}, 156 | }, 157 | { 158 | "span ID unavailable", 159 | slogdriver.Config{ 160 | ProjectID: "jectpro", 161 | }, 162 | slogdriver.Trace{ 163 | ID: "abc", 164 | }.Context(context.Background()), 165 | TraceInfo{ 166 | TraceID: vptr("projects/jectpro/traces/abc"), 167 | TraceSampled: vptr(false), 168 | }, 169 | }, 170 | { 171 | "sampled", 172 | slogdriver.Config{ 173 | ProjectID: "ectproj", 174 | }, 175 | slogdriver.Trace{ 176 | ID: "bcd", 177 | Sampled: true, 178 | }.Context(context.Background()), 179 | TraceInfo{ 180 | TraceID: vptr("projects/ectproj/traces/bcd"), 181 | TraceSampled: vptr(true), 182 | }, 183 | }, 184 | { 185 | "span ID", 186 | slogdriver.Config{ 187 | ProjectID: "ctproje", 188 | }, 189 | slogdriver.Trace{ 190 | ID: "cde", 191 | SpanID: "foobar", 192 | }.Context(context.Background()), 193 | TraceInfo{ 194 | TraceID: vptr("projects/ctproje/traces/cde"), 195 | SpanID: vptr("foobar"), 196 | TraceSampled: vptr(false), 197 | }, 198 | }, 199 | } 200 | 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | 204 | ctx := tt.ctx 205 | var capture slogtest.Capture[TraceInfo] 206 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, tt.config)) 207 | 208 | logger.InfoContext(ctx, "trace") 209 | entries := capture.Entries() 210 | received := entries[0] 211 | err := errs.Err() 212 | 213 | require.NoError(t, err) 214 | require.Equal(t, tt.expected, received) 215 | }) 216 | } 217 | }) 218 | 219 | t.Run("labels", func(t *testing.T) { 220 | type Entry struct { 221 | Labels map[string]string `json:"logging.googleapis.com/labels"` 222 | } 223 | 224 | tests := []struct { 225 | name string 226 | ctx context.Context 227 | expected map[string]string 228 | }{ 229 | { 230 | "no labels", 231 | context.Background(), 232 | nil, 233 | }, 234 | { 235 | "empty labels", 236 | slogdriver.AddLabels( 237 | context.Background(), 238 | ), 239 | nil, 240 | }, 241 | { 242 | "some labels", 243 | slogdriver.AddLabels( 244 | context.Background(), 245 | slogdriver.NewLabel("foo", "bar"), 246 | slogdriver.NewLabel("voo", "doo"), 247 | ), 248 | map[string]string{ 249 | "foo": "bar", 250 | "voo": "doo", 251 | }, 252 | }, 253 | { 254 | "extended labels", 255 | slogdriver.AddLabels( 256 | slogdriver.AddLabels( 257 | context.Background(), 258 | slogdriver.NewLabel("first", "1"), 259 | slogdriver.NewLabel("second", "2"), 260 | ), 261 | slogdriver.NewLabel("second", "changed"), 262 | slogdriver.NewLabel("third", "3"), 263 | ), 264 | map[string]string{ 265 | "first": "1", 266 | "second": "changed", 267 | "third": "3", 268 | }, 269 | }, 270 | } 271 | 272 | for _, tt := range tests { 273 | t.Run(tt.name, func(t *testing.T) { 274 | 275 | ctx := tt.ctx 276 | var capture slogtest.Capture[Entry] 277 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 278 | 279 | logger.InfoContext(ctx, "labels") 280 | entries := capture.Entries() 281 | received := entries[0].Labels 282 | err := errs.Err() 283 | 284 | require.NoError(t, err) 285 | require.Equal(t, tt.expected, received) 286 | }) 287 | } 288 | }) 289 | 290 | t.Run("groups and attrs", func(t *testing.T) { 291 | t.Run("nested", func(t *testing.T) { 292 | type Nested2 struct { 293 | CustomPrepared3 int 294 | CustomAdded int 295 | } 296 | 297 | type Nested1 struct { 298 | CustomPrepared2 int 299 | Nested2 Nested2 300 | } 301 | 302 | type Entry struct { 303 | CustomPrepared1 int 304 | Nested1 Nested1 305 | } 306 | 307 | ctx := context.Background() 308 | var capture slogtest.Capture[Entry] 309 | var h slog.Handler = slogdriver.NewHandler(&capture, slogdriver.Config{}) 310 | h = h.WithAttrs([]slog.Attr{slog.Int64("CustomPrepared1", 1)}) 311 | h = h.WithGroup("Nested1") 312 | h = h.WithAttrs([]slog.Attr{slog.Int64("CustomPrepared2", 2)}) 313 | h = h.WithGroup("Nested2") 314 | h = h.WithAttrs([]slog.Attr{slog.Int64("CustomPrepared3", 3)}) 315 | logger, errs := slogtest.NewWithErrorHandler(h) 316 | expected := Entry{ 317 | CustomPrepared1: 1, 318 | Nested1: Nested1{ 319 | CustomPrepared2: 2, 320 | Nested2: Nested2{ 321 | CustomPrepared3: 3, 322 | CustomAdded: 4, 323 | }, 324 | }, 325 | } 326 | 327 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Int64("CustomAdded", 4)) 328 | entries := capture.Entries() 329 | received := entries[0] 330 | err := errs.Err() 331 | 332 | require.NoError(t, err) 333 | require.Equal(t, expected, received) 334 | }) 335 | 336 | t.Run("group", func(t *testing.T) { 337 | type Group struct { 338 | Val1 string 339 | Val2 int 340 | } 341 | 342 | type Entry struct { 343 | Group Group 344 | } 345 | 346 | ctx := context.Background() 347 | var capture slogtest.Capture[Entry] 348 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 349 | expected := Entry{ 350 | Group: Group{ 351 | Val1: "abc", 352 | Val2: 123, 353 | }, 354 | } 355 | 356 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Group("Group", 357 | slog.String("Val1", "abc"), 358 | slog.Int64("Val2", 123), 359 | )) 360 | entries := capture.Entries() 361 | received := entries[0] 362 | err := errs.Err() 363 | 364 | require.NoError(t, err) 365 | require.Equal(t, expected, received) 366 | }) 367 | 368 | t.Run("empty group", func(t *testing.T) { 369 | type Entry struct { 370 | Group *struct{} 371 | } 372 | 373 | ctx := context.Background() 374 | var capture slogtest.Capture[Entry] 375 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 376 | expected := Entry{Group: nil} 377 | 378 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Group("Group")) 379 | entries := capture.Entries() 380 | received := entries[0] 381 | err := errs.Err() 382 | 383 | require.NoError(t, err) 384 | require.Equal(t, expected, received) 385 | }) 386 | 387 | t.Run("string", func(t *testing.T) { 388 | type Entry struct { 389 | StringVal string 390 | } 391 | 392 | ctx := context.Background() 393 | var capture slogtest.Capture[Entry] 394 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 395 | expected := Entry{"cbd"} 396 | 397 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.String("StringVal", "cbd")) 398 | entries := capture.Entries() 399 | received := entries[0] 400 | err := errs.Err() 401 | 402 | require.NoError(t, err) 403 | require.Equal(t, expected, received) 404 | }) 405 | 406 | t.Run("int64", func(t *testing.T) { 407 | type Entry struct { 408 | Int64Val int64 409 | } 410 | 411 | ctx := context.Background() 412 | var capture slogtest.Capture[Entry] 413 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 414 | expected := Entry{-1234} 415 | 416 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Int64("Int64Val", -1234)) 417 | entries := capture.Entries() 418 | received := entries[0] 419 | err := errs.Err() 420 | 421 | require.NoError(t, err) 422 | require.Equal(t, expected, received) 423 | }) 424 | 425 | t.Run("uint64", func(t *testing.T) { 426 | type Entry struct { 427 | Uint64Val uint64 428 | } 429 | 430 | ctx := context.Background() 431 | var capture slogtest.Capture[Entry] 432 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 433 | expected := Entry{1234} 434 | 435 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Uint64("Uint64Val", 1234)) 436 | entries := capture.Entries() 437 | received := entries[0] 438 | err := errs.Err() 439 | 440 | require.NoError(t, err) 441 | require.Equal(t, expected, received) 442 | }) 443 | 444 | t.Run("float64", func(t *testing.T) { 445 | type Entry struct { 446 | Float64Val float64 447 | } 448 | 449 | ctx := context.Background() 450 | var capture slogtest.Capture[Entry] 451 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 452 | expected := Entry{12.34} 453 | 454 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Float64("Float64Val", 12.34)) 455 | entries := capture.Entries() 456 | received := entries[0] 457 | err := errs.Err() 458 | 459 | require.NoError(t, err) 460 | require.Equal(t, expected, received) 461 | }) 462 | 463 | t.Run("bool", func(t *testing.T) { 464 | type Entry struct { 465 | BoolVal1 bool 466 | BoolVal2 bool 467 | } 468 | 469 | ctx := context.Background() 470 | var capture slogtest.Capture[Entry] 471 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 472 | expected := Entry{true, false} 473 | 474 | logger.LogAttrs(ctx, slog.LevelError, "attrs", 475 | slog.Bool("BoolVal1", true), 476 | slog.Bool("BoolVal2", false), 477 | ) 478 | entries := capture.Entries() 479 | received := entries[0] 480 | err := errs.Err() 481 | 482 | require.NoError(t, err) 483 | require.Equal(t, expected, received) 484 | }) 485 | 486 | t.Run("duration", func(t *testing.T) { 487 | type Entry struct { 488 | DurationVal1 time.Duration 489 | DurationVal2 time.Duration 490 | } 491 | 492 | ctx := context.Background() 493 | var capture slogtest.Capture[Entry] 494 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 495 | expected := Entry{123456, -234567} 496 | 497 | logger.LogAttrs(ctx, slog.LevelError, "attrs", 498 | slog.Duration("DurationVal1", 123456), 499 | slog.Duration("DurationVal2", -234567), 500 | ) 501 | entries := capture.Entries() 502 | received := entries[0] 503 | err := errs.Err() 504 | 505 | require.NoError(t, err) 506 | require.Equal(t, expected, received) 507 | }) 508 | 509 | t.Run("time", func(t *testing.T) { 510 | type Entry struct { 511 | TimeVal1 string 512 | TimeVal2 string 513 | } 514 | 515 | ctx := context.Background() 516 | now := time.Now() 517 | var capture slogtest.Capture[Entry] 518 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 519 | expected := Entry{ 520 | "2023-06-15T19:24:13.123456789Z", 521 | now.Round(0).Format(time.RFC3339Nano), 522 | } 523 | 524 | logger.LogAttrs(ctx, slog.LevelError, "attrs", 525 | slog.Time("TimeVal1", time.Date(2023, 6, 15, 19, 24, 13, 123456789, time.FixedZone("gcp", 0))), 526 | slog.Time("TimeVal2", now), 527 | ) 528 | entries := capture.Entries() 529 | received := entries[0] 530 | err := errs.Err() 531 | 532 | require.NoError(t, err) 533 | require.Equal(t, expected, received) 534 | }) 535 | 536 | t.Run("LogValuer", func(t *testing.T) { 537 | type CustomValuer struct { 538 | Foo string `json:"foo"` 539 | Bar string `json:"bar"` 540 | } 541 | 542 | type Entry struct { 543 | CustomValuer CustomValuer 544 | } 545 | 546 | ctx := context.Background() 547 | var capture slogtest.Capture[Entry] 548 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 549 | expected := Entry{CustomValuer{"abc", "def"}} 550 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Any("CustomValuer", ValuerFunc(func() slog.Value { 551 | return slog.GroupValue( 552 | slog.String("foo", "abc"), 553 | slog.String("bar", "def"), 554 | ) 555 | }))) 556 | received := capture.Entries()[0] 557 | err := errs.Err() 558 | 559 | require.NoError(t, err) 560 | require.Equal(t, expected, received) 561 | }) 562 | 563 | t.Run("error", func(t *testing.T) { 564 | type Entry struct { 565 | ErrorVal string 566 | } 567 | 568 | ctx := context.Background() 569 | var capture slogtest.Capture[Entry] 570 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 571 | expected := Entry{"unknown error"} 572 | 573 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Any("ErrorVal", errors.New("unknown error"))) 574 | entries := capture.Entries() 575 | received := entries[0] 576 | err := errs.Err() 577 | 578 | require.NoError(t, err) 579 | require.Equal(t, expected, received) 580 | }) 581 | 582 | t.Run("error with custom marshal", func(t *testing.T) { 583 | type Entry struct { 584 | JSONErrorVal JSONError 585 | } 586 | 587 | ctx := context.Background() 588 | var capture slogtest.Capture[Entry] 589 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 590 | expected := Entry{JSONError{"foo"}} 591 | 592 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Any("JSONErrorVal", JSONError{"foo"})) 593 | entries := capture.Entries() 594 | received := entries[0] 595 | err := errs.Err() 596 | 597 | require.NoError(t, err) 598 | require.Equal(t, expected, received) 599 | }) 600 | 601 | t.Run("json value", func(t *testing.T) { 602 | type JSONVal struct { 603 | Val1 string 604 | Val2 int 605 | } 606 | 607 | type Entry struct { 608 | JSONVal JSONVal 609 | } 610 | 611 | ctx := context.Background() 612 | var capture slogtest.Capture[Entry] 613 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 614 | expected := Entry{JSONVal{"bcd", 234}} 615 | 616 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.Any("JSONVal", JSONVal{"bcd", 234})) 617 | entries := capture.Entries() 618 | received := entries[0] 619 | err := errs.Err() 620 | 621 | require.NoError(t, err) 622 | require.Equal(t, expected, received) 623 | }) 624 | 625 | t.Run("error", func(t *testing.T) { 626 | type Entry struct { 627 | Correct string 628 | Erroring *struct{} 629 | } 630 | 631 | ctx := context.Background() 632 | var capture slogtest.Capture[Entry] 633 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 634 | expected := Entry{"correct", nil} 635 | 636 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.String("Correct", "correct"), slog.Any("erroring", ErroringMarshal{})) 637 | entries := capture.Entries() 638 | received := entries[0] 639 | err := errs.Err() 640 | 641 | require.Error(t, err) 642 | require.Equal(t, expected, received) 643 | }) 644 | 645 | t.Run("WithAttrs error", func(t *testing.T) { 646 | type Entry struct { 647 | Correct string 648 | Erroring *struct{} 649 | } 650 | 651 | ctx := context.Background() 652 | var capture slogtest.Capture[Entry] 653 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 654 | expected := Entry{"correct", nil} 655 | 656 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.String("Correct", "correct"), slog.Any("Erroring", ErroringMarshal{})) 657 | entries := capture.Entries() 658 | received := entries[0] 659 | err := errs.Err() 660 | 661 | require.Error(t, err) 662 | require.Equal(t, expected, received) 663 | }) 664 | 665 | t.Run("Invalid Attr Kind", func(t *testing.T) { 666 | type Entry struct { 667 | Correct string 668 | Erroring *struct{} 669 | } 670 | 671 | type FakeValue struct { 672 | num uint64 673 | any any 674 | } 675 | 676 | ctx := context.Background() 677 | var capture slogtest.Capture[Entry] 678 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&capture, slogdriver.Config{})) 679 | expected := Entry{"correct", nil} 680 | invalidAttr := slog.Attr{ 681 | Key: "Erroring", 682 | Value: *(*slog.Value)(unsafe.Pointer(&FakeValue{ 683 | any: slog.Kind(0xDEADBEEF), 684 | })), 685 | } 686 | 687 | logger.LogAttrs(ctx, slog.LevelError, "attrs", slog.String("Correct", "correct"), invalidAttr) 688 | entries := capture.Entries() 689 | received := entries[0] 690 | err := errs.Err() 691 | 692 | require.Error(t, err) 693 | require.Equal(t, expected, received) 694 | }) 695 | }) 696 | 697 | t.Run("Writer error", func(t *testing.T) { 698 | ctx := context.Background() 699 | var w ErrorWriter 700 | logger, errs := slogtest.NewWithErrorHandler(slogdriver.NewHandler(&w, slogdriver.Config{})) 701 | 702 | logger.LogAttrs(ctx, slog.LevelError, "write error") 703 | err := errs.Err() 704 | 705 | require.Error(t, err) 706 | }) 707 | } 708 | 709 | func Benchmark(b *testing.B) { 710 | w := &IgnoreWriter{} 711 | level := slog.Level(-1e6) 712 | slogdriverLogger := slog.New(slogdriver.NewHandler(w, slogdriver.Config{ 713 | Level: level, 714 | })) 715 | jsonLogger := slog.New(NewCloudLoggingJSONHandler(w, level)) 716 | 717 | b.Run("slogdriver", func(b *testing.B) { 718 | for n := 0; n < b.N; n++ { 719 | slogdriverLogger.Info("hello world") 720 | } 721 | }) 722 | 723 | b.Run("cloud logging JSONHandler", func(b *testing.B) { 724 | for n := 0; n < b.N; n++ { 725 | jsonLogger.Info("hello world") 726 | } 727 | }) 728 | } 729 | 730 | func NewCloudLoggingJSONHandler(w io.Writer, level slog.Leveler) *slog.JSONHandler { 731 | const ( 732 | fieldMessage = "message" 733 | fieldTimestamp = "timestamp" 734 | fieldSeverity = "severity" 735 | fieldSourceLocation = "logging.googleapis.com/sourceLocation" 736 | ) 737 | 738 | const ( 739 | slogFieldMessage = iota + 1 740 | slogFieldTimestamp 741 | slogFieldLevel 742 | slogFieldSource 743 | ) 744 | 745 | const ( 746 | severityError = 500 747 | severityWarn = 400 748 | severityInfo = 300 749 | severityDebug = 200 750 | ) 751 | 752 | mappings := map[string]int{ 753 | slog.MessageKey: slogFieldMessage, 754 | slog.TimeKey: slogFieldTimestamp, 755 | slog.LevelKey: slogFieldLevel, 756 | slog.SourceKey: slogFieldSource, 757 | } 758 | 759 | return slog.NewJSONHandler(w, &slog.HandlerOptions{ 760 | AddSource: true, 761 | Level: level, 762 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 763 | if len(groups) == 0 { 764 | switch mappings[a.Key] { 765 | case slogFieldMessage: 766 | return slog.Attr{Key: fieldMessage, Value: a.Value} 767 | case slogFieldTimestamp: 768 | return slog.Attr{Key: fieldTimestamp, Value: a.Value} 769 | case slogFieldSource: 770 | return slog.Attr{Key: fieldSourceLocation, Value: a.Value} 771 | case slogFieldLevel: 772 | level := a.Value.Any().(slog.Level) 773 | switch { 774 | case level >= slog.LevelError: 775 | return slog.Int64(fieldSeverity, severityError) 776 | case level >= slog.LevelWarn: 777 | return slog.Int64(fieldSeverity, severityWarn) 778 | case level >= slog.LevelInfo: 779 | return slog.Int64(fieldSeverity, severityInfo) 780 | default: 781 | return slog.Int64(fieldSeverity, severityDebug) 782 | } 783 | } 784 | } 785 | return a 786 | }, 787 | }) 788 | } 789 | 790 | type ValuerFunc func() slog.Value 791 | 792 | func (fn ValuerFunc) LogValue() slog.Value { 793 | return fn() 794 | } 795 | 796 | type JSONError struct { 797 | Message string 798 | } 799 | 800 | func (e JSONError) Error() string { 801 | return e.Message 802 | } 803 | 804 | func (e JSONError) MarshalJSON() ([]byte, error) { 805 | f := e.jsonFormat() 806 | f.Message = e.Message 807 | return json.Marshal(f) 808 | } 809 | 810 | func (e *JSONError) UnmarshalJSON(data []byte) error { 811 | f := e.jsonFormat() 812 | if err := json.Unmarshal(data, &f); err != nil { 813 | return err 814 | } 815 | e.Message = f.Message 816 | return nil 817 | } 818 | 819 | func (JSONError) jsonFormat() (v struct { 820 | Message string `json:"message"` 821 | }) { 822 | return v 823 | } 824 | 825 | type IgnoreWriter struct{} 826 | 827 | func (*IgnoreWriter) Write(data []byte) (n int, err error) { 828 | return len(data), nil 829 | } 830 | 831 | type ErrorWriter struct{} 832 | 833 | func (*ErrorWriter) Write(data []byte) (n int, err error) { 834 | return 0, fmt.Errorf("error writing") 835 | } 836 | 837 | type ErroringMarshal struct{} 838 | 839 | func (ErroringMarshal) MarshalJSON() ([]byte, error) { 840 | return nil, fmt.Errorf("cannot be marshaled") 841 | } 842 | 843 | func getPC() uintptr { 844 | var pcs [1]uintptr 845 | runtime.Callers(2, pcs[:]) 846 | return pcs[0] 847 | } 848 | 849 | func vptr[T any](v T) *T { 850 | return &v 851 | } 852 | --------------------------------------------------------------------------------