From 217cf8367f09d050873c0469cff77b49816185f2 Mon Sep 17 00:00:00 2001 From: Sai Sridhar Date: Tue, 30 Jun 2026 19:08:21 +0530 Subject: [PATCH] feat: implement slog.LogValuer on stacktrace error type (#16) Add LogValue() slog.Value method to the *stacktrace type so that stacktrace errors produce structured log output when used with log/slog (available since Go 1.21). The returned slog.GroupValue contains: - "message": the full human-readable error string (same as Error()) - "frames": a list of per-frame groups with "message", "function", and "file:line" for each frame in the error chain Also add go.mod and go.sum to enable module-aware builds, required for importing log/slog. --- go.mod | 11 ++++++ go.sum | 10 ++++++ slog.go | 64 ++++++++++++++++++++++++++++++++++ slog_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 slog.go create mode 100644 slog_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12e60a3 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/palantir/stacktrace + +go 1.26.1 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/slog.go b/slog.go new file mode 100644 index 0000000..434bec4 --- /dev/null +++ b/slog.go @@ -0,0 +1,64 @@ +// Copyright 2016 Palantir Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package stacktrace + +import ( + "fmt" + "log/slog" +) + +// LogValue implements the slog.LogValuer interface so that stacktrace errors +// appear as structured groups in structured log output rather than as a flat +// string. +// +// The returned group contains: +// - "message": the full human-readable error string (same as err.Error()) +// - "frames": a list of slog.Attr values, one per stack frame in the chain, +// each being a group with "function", "file", and "line" keys. +// +// Example slog output (JSON handler): +// +// {"error":{"message":"outer: inner","frames":[{"function":"pkg.Fn","file":"pkg/fn.go","line":42}]}} +func (st *stacktrace) LogValue() slog.Value { + return slog.GroupValue( + slog.String("message", st.Error()), + slog.Any("frames", collectFrames(st)), + ) +} + +// collectFrames walks the stacktrace chain and returns a slice of slog.Value, +// one per frame that has file/line information. +func collectFrames(st *stacktrace) []slog.Value { + var frames []slog.Value + for curr, ok := st, true; ok; curr, ok = curr.cause.(*stacktrace) { + if curr.file == "" { + continue + } + attrs := []slog.Attr{ + slog.String("file", fmt.Sprintf("%s:%d", curr.file, curr.line)), + } + if curr.function != "" { + attrs = append([]slog.Attr{slog.String("function", curr.function)}, attrs...) + } + if curr.message != "" { + attrs = append([]slog.Attr{slog.String("message", curr.message)}, attrs...) + } + frames = append(frames, slog.GroupValue(attrs...)) + } + return frames +} + +// Ensure *stacktrace implements slog.LogValuer at compile time. +var _ slog.LogValuer = (*stacktrace)(nil) diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..d0e6656 --- /dev/null +++ b/slog_test.go @@ -0,0 +1,98 @@ +// Copyright 2016 Palantir Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package stacktrace_test + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/palantir/stacktrace" +) + +// TestLogValue verifies that stacktrace errors implement slog.LogValuer and +// produce a structured group containing at least a "message" key and a +// "frames" key when logged. +func TestLogValue(t *testing.T) { + err := stacktrace.NewError("something went wrong") + + // Cast to slog.LogValuer — if the interface is not implemented this will panic. + lv, ok := err.(slog.LogValuer) + require.True(t, ok, "stacktrace error should implement slog.LogValuer") + + val := lv.LogValue() + assert.Equal(t, slog.KindGroup, val.Kind(), "LogValue should return a group value") + + attrs := val.Group() + keys := make([]string, 0, len(attrs)) + for _, a := range attrs { + keys = append(keys, a.Key) + } + assert.Contains(t, keys, "message", "group should contain a 'message' key") + assert.Contains(t, keys, "frames", "group should contain a 'frames' key") + + // Verify the message value equals the error string. + for _, a := range attrs { + if a.Key == "message" { + assert.Equal(t, err.Error(), a.Value.String()) + } + } +} + +// TestLogValuePropagated verifies that a propagated (wrapped) stacktrace error +// also implements slog.LogValuer and that all frames are captured. +func TestLogValuePropagated(t *testing.T) { + inner := stacktrace.NewError("inner error") + outer := stacktrace.Propagate(inner, "outer context") + + lv, ok := outer.(slog.LogValuer) + require.True(t, ok, "propagated stacktrace error should implement slog.LogValuer") + + val := lv.LogValue() + assert.Equal(t, slog.KindGroup, val.Kind()) + + // The message should reflect the full chain. + attrs := val.Group() + for _, a := range attrs { + if a.Key == "message" { + msg := a.Value.String() + assert.Contains(t, msg, "outer context", "message should include outer context") + assert.Contains(t, msg, "inner error", "message should include inner error text") + } + } +} + +// TestLogValueInSlogHandler verifies that a stacktrace error is emitted as +// structured fields (not a flat string) when passed to a slog.Logger with a +// JSON handler. +func TestLogValueInSlogHandler(t *testing.T) { + err := stacktrace.NewError("disk full") + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + logger.Error("operation failed", "error", err) + + output := buf.String() + // The JSON output should contain the "message" key inside the error group, + // not just a flat string value for the error key. + assert.True(t, + strings.Contains(output, `"message"`) && strings.Contains(output, "disk full"), + "JSON log output should contain structured 'message' field; got: %s", output, + ) +}