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
23 changes: 17 additions & 6 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,8 +680,13 @@ func (o OopsError) LogValue() slog.Value { //nolint:gocyclo
attrs = append(attrs, slog.String("stacktrace", stacktrace))
}

if sources := o.Sources(); sources != "" && !SourceFragmentsHidden {
attrs = append(attrs, slog.String("sources", sources))
// Check the flag before calling Sources(): formatting source fragments
// reads source files from disk and builds the full output string, all of
// which would be discarded here when fragments are hidden (the default).
if !SourceFragmentsHidden {
if sources := o.Sources(); sources != "" {
attrs = append(attrs, slog.String("sources", sources))
}
}

return slog.GroupValue(attrs...)
Expand Down Expand Up @@ -780,8 +785,11 @@ func (o OopsError) ToMap() map[string]any { //nolint:gocyclo
payload["stacktrace"] = stacktrace
}

if sources := o.Sources(); sources != "" && !SourceFragmentsHidden {
payload["sources"] = sources
// See LogValue: only format source fragments when they will be used.
if !SourceFragmentsHidden {
if sources := o.Sources(); sources != "" {
payload["sources"] = sources
}
}

return payload
Expand Down Expand Up @@ -910,8 +918,11 @@ func (o *OopsError) formatVerbose() string { //nolint:gocyclo
_, _ = fmt.Fprintf(&output, "Stacktrace:\n%s\n", stacktrace)
}

if sources := o.Sources(); sources != "" && !SourceFragmentsHidden {
_, _ = fmt.Fprintf(&output, "Sources:\n%s\n", sources)
// See LogValue: only format source fragments when they will be used.
if !SourceFragmentsHidden {
if sources := o.Sources(); sources != "" {
_, _ = fmt.Fprintf(&output, "Sources:\n%s\n", sources)
}
}

return output.String()
Expand Down
60 changes: 60 additions & 0 deletions oops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1756,3 +1756,63 @@ func TestOopsJoinNilHandling(t *testing.T) {
result2 := Join(nil, nil)
is.NoError(result2)
}

// TestOopsSourceFragmentsVisible verifies that ToMap, LogValue and
// formatVerbose include the "sources" entry when SourceFragmentsHidden is
// false, and that its content matches OopsError.Sources().
func TestOopsSourceFragmentsVisible(t *testing.T) { //nolint:paralleltest
// Modifies the global SourceFragmentsHidden flag — do not run in parallel.
is := assert.New(t)

original := SourceFragmentsHidden
SourceFragmentsHidden = false
defer func() { SourceFragmentsHidden = original }()

err := New("boom")
oopsErr := err.(OopsError)

sources := oopsErr.Sources()
is.NotEmpty(sources, "Sources() should be non-empty when SourceFragmentsHidden is false")

m := oopsErr.ToMap()
is.Equal(sources, m["sources"])

group := oopsErr.LogValue().Group()
var got string
var found bool
for _, a := range group {
if a.Key == "sources" {
found = true
got = a.Value.String()
}
}
is.True(found, "expected a 'sources' attr in LogValue when SourceFragmentsHidden is false")
is.Equal(sources, got)

verbose := oopsErr.formatVerbose()
is.Contains(verbose, "Sources:\n"+sources)
}

// TestOopsSourceFragmentsHiddenByDefault verifies that ToMap, LogValue and
// formatVerbose omit "sources" when SourceFragmentsHidden is true (the
// default), even though the error carries a resolvable stacktrace.
//
// Not parallel: asserts on the SourceFragmentsHidden default, which
// TestOopsSourceFragmentsVisible flips for its own duration.
func TestOopsSourceFragmentsHiddenByDefault(t *testing.T) { //nolint:paralleltest
is := assert.New(t)

is.True(SourceFragmentsHidden, "this test assumes the package default")

err := New("boom")
oopsErr := err.(OopsError)

_, hasSources := oopsErr.ToMap()["sources"]
is.False(hasSources)

for _, a := range oopsErr.LogValue().Group() {
is.NotEqual("sources", a.Key)
}

is.NotContains(oopsErr.formatVerbose(), "Sources:")
}
5 changes: 2 additions & 3 deletions sources.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package oops

import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
Expand Down Expand Up @@ -166,8 +166,7 @@ func getSourceFromFrame(frame oopsStacktraceFrame) []string {

line := lines[i]

// Format the line with line number
message := fmt.Sprintf("%d\t%s", i+1, line)
message := strconv.Itoa(i+1) + "\t" + line
output = append(output, message)

// Add visual indicator for the error line
Expand Down
8 changes: 2 additions & 6 deletions stacktrace.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package oops

import (
"fmt"
"reflect"
"runtime"
"slices"
Expand Down Expand Up @@ -386,10 +385,7 @@ func framesToStacktraceBlocks(blocks []outputBlock) []string {
firstFrame = false
}

stacktraceStr := strings.Join(frameLines, "\n")
block := fmt.Sprintf("%s\n%s", msg, stacktraceStr)

output = append(output, block)
output = append(output, msg+"\n"+strings.Join(frameLines, "\n"))
}

slices.Reverse(output)
Expand All @@ -404,7 +400,7 @@ func framesToSourceBlocks(blocks []outputBlock) []string {
header, body := st.Source()

if b.msg != "" {
header = fmt.Sprintf("%s\n%s", b.msg, header)
header = b.msg + "\n" + header
}

if header != "" && len(body) > 0 {
Expand Down
92 changes: 92 additions & 0 deletions stacktrace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,98 @@ func TestOopsStacktraceString(t *testing.T) {
}
}

func TestOopsStacktraceSource(t *testing.T) {
t.Parallel()

_, currentFile, currentLine, _ := runtime.Caller(0)

t.Run("empty_frames", func(t *testing.T) {
t.Parallel()
is := assert.New(t)

st := &oopsStacktrace{span: "test"}
header, body := st.Source()
is.Empty(header)
is.Empty(body)
})

t.Run("with_frames", func(t *testing.T) { //nolint:paralleltest
t.Cleanup(func() {
mutex.Lock()
cache = map[string][]string{}
mutex.Unlock()
})
is := assert.New(t)

frame := oopsStacktraceFrame{file: currentFile, line: currentLine, function: "TestOopsStacktraceSource"}
st := &oopsStacktrace{span: "test", frames: []oopsStacktraceFrame{frame}}

header, body := st.Source()
is.Equal(frame.String(), header)
is.NotEmpty(body)
})
}

func TestFramesToSourceBlocks(t *testing.T) {
t.Parallel()

_, currentFile, currentLine, _ := runtime.Caller(0)

t.Run("no_blocks", func(t *testing.T) {
t.Parallel()
is := assert.New(t)

is.Empty(framesToSourceBlocks(nil))
})

t.Run("message_and_frames_produce_a_block", func(t *testing.T) { //nolint:paralleltest
t.Cleanup(func() {
mutex.Lock()
cache = map[string][]string{}
mutex.Unlock()
})
is := assert.New(t)

frame := oopsStacktraceFrame{file: currentFile, line: currentLine, function: "TestFramesToSourceBlocks"}
blocks := []outputBlock{{msg: "boom", frames: []oopsStacktraceFrame{frame}}}

out := framesToSourceBlocks(blocks)
is.Len(out, 1)
is.Contains(out[0], "boom")
is.Contains(out[0], frame.String())
})

t.Run("unresolvable_frame_produces_no_block", func(t *testing.T) {
t.Parallel()
is := assert.New(t)

frame := oopsStacktraceFrame{file: "/nonexistent/path/file.go", line: 1, function: "x"}
blocks := []outputBlock{{msg: "boom", frames: []oopsStacktraceFrame{frame}}}

is.Empty(framesToSourceBlocks(blocks))
})

t.Run("multiple_blocks_are_reversed", func(t *testing.T) { //nolint:paralleltest
t.Cleanup(func() {
mutex.Lock()
cache = map[string][]string{}
mutex.Unlock()
})
is := assert.New(t)

frame := oopsStacktraceFrame{file: currentFile, line: currentLine, function: "TestFramesToSourceBlocks"}
blocks := []outputBlock{
{msg: "first", frames: []oopsStacktraceFrame{frame}},
{msg: "second", frames: []oopsStacktraceFrame{frame}},
}

out := framesToSourceBlocks(blocks)
is.Len(out, 2)
is.Contains(out[0], "second")
is.Contains(out[1], "first")
})
}

func TestOopsStacktraceFrameString(t *testing.T) {
t.Parallel()

Expand Down
Loading