Skip to content

Commit e4d4715

Browse files
committed
[stacktrace] implement stacktrace.CaptureCaller function
New function captures single frame with specified skip. This is more performant for cases when only caller's frame required. Also: - Utilize Frame structure in Stack. - Rename `stacktrace.Capture` to `stacktrace.CaptureStack`. Change-Id: Ida92c73d800b4ff6b2fea72fb0b950cdb2985761
1 parent 6b86721 commit e4d4715

4 files changed

Lines changed: 113 additions & 44 deletions

File tree

logger/logger.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ func (l Logger) WithStackTrace(skip int) Logger {
387387
}
388388

389389
l.adapter = l.adapter.WithFields(l.mappers.stackTrace(
390-
stacktrace.Capture(skip+1, stackTraceDepth),
390+
stacktrace.CaptureStack(skip+1, stackTraceDepth),
391391
))
392392

393393
return l

stacktrace/doc.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,54 @@
77
//
88
// To capture the current stack trace:
99
//
10-
// stack := stacktrace.Capture(0, stacktrace.DefaultDepth)
10+
// stack := stacktrace.CaptureStack(0, stacktrace.DefaultDepth)
1111
// fmt.Println(stack.String())
1212
//
13+
// To capture only the immediate caller:
14+
//
15+
// frame := stacktrace.CaptureCaller(0)
16+
// fmt.Println(frame.FullPath()) // prints: /path/to/file.go:123
17+
//
18+
// # Frame Type
19+
//
20+
// The Frame type represents a single stack frame with program counter, file path,
21+
// line number, and function name. It provides several formatting methods:
22+
//
23+
// frame := stacktrace.CaptureCaller(0)
24+
// frame.FullPath() // Returns: "/path/to/file.go:123"
25+
// frame.String() // Returns: "function.name\n\t/path/to/file.go:123"
26+
//
27+
// Use CaptureCaller when you only need information about the immediate caller,
28+
// as it's more efficient than capturing a full stack trace.
29+
//
1330
// # Controlling Depth
1431
//
1532
// Capture a limited number of frames:
1633
//
17-
// stack := stacktrace.Capture(0, 10) // Capture up to 10 frames
34+
// stack := stacktrace.CaptureStack(0, 10) // Capture up to 10 frames
1835
//
1936
// Capture the complete stack trace:
2037
//
21-
// stack := stacktrace.Capture(0, math.MaxInt) // Unlimited depth
38+
// stack := stacktrace.CaptureStack(0, math.MaxInt) // Unlimited depth
2239
//
2340
// # Skipping Frames
2441
//
2542
// Skip frames to exclude wrapper functions from the trace:
2643
//
2744
// func myLogger(msg string) {
2845
// // Skip 1 frame to exclude myLogger from the trace
29-
// stack := stacktrace.Capture(1, stacktrace.DefaultDepth)
46+
// stack := stacktrace.CaptureStack(1, stacktrace.DefaultDepth)
3047
// log.Printf("%s\n%s", msg, stack.String())
3148
// }
3249
//
50+
// The same skip parameter works with CaptureCaller:
51+
//
52+
// func getCallerInfo() string {
53+
// // Skip 1 to get the caller of getCallerInfo, not getCallerInfo itself
54+
// frame := stacktrace.CaptureCaller(1)
55+
// return frame.FullPath()
56+
// }
57+
//
3358
// # Output Format
3459
//
3560
// The Stack.String() method formats stack traces as:
@@ -46,7 +71,7 @@
4671
//
4772
// Access individual frames using the iterator for custom processing or formatting:
4873
//
49-
// stack := stacktrace.Capture(0, stacktrace.DefaultDepth)
74+
// stack := stacktrace.CaptureStack(0, stacktrace.DefaultDepth)
5075
// for idx, frame := range stack.Frames() {
5176
// fmt.Printf("%d: %s at %s:%d\n", idx, frame.Function, frame.File, frame.Line)
5277
// }

stacktrace/stacktrace.go

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"math"
77
"runtime"
88
"strconv"
9-
"strings"
109
)
1110

1211
// Frame represents a single stack frame containing program counter, file path,
@@ -83,13 +82,13 @@ func (f Frame) Write(b *bytes.Buffer) {
8382

8483
// Stack represents a captured call stack consisting of program counter frames.
8584
type Stack struct {
86-
frames []runtime.Frame
85+
frames []Frame
8786
}
8887

8988
// Frames returns an iterator over the stack frames.
9089
// The iterator yields (index, frame) pairs for each frame in the stack.
91-
func (s *Stack) Frames() iter.Seq2[int, runtime.Frame] {
92-
return func(yield func(int, runtime.Frame) bool) {
90+
func (s *Stack) Frames() iter.Seq2[int, Frame] {
91+
return func(yield func(int, Frame) bool) {
9392
for i, frame := range s.frames {
9493
if !yield(i, frame) {
9594
break
@@ -111,38 +110,49 @@ func (s *Stack) String() string {
111110
}
112111

113112
// Estimate capacity: ~100 bytes per frame on average
114-
b := strings.Builder{}
115-
b.Grow(len(s.frames) * 100) //nolint:mnd
113+
b := bytes.NewBuffer(make([]byte, 0, len(s.frames)*100)) //nolint:mnd
116114

117115
for i, f := range s.frames {
118116
if i > 0 {
119117
b.WriteRune('\n')
120118
}
121119

122-
WriteFrameToBuffer(f, &b)
120+
f.Write(b)
123121
}
124122

125123
return b.String()
126124
}
127125

128-
// WriteFrameToBuffer formats a single runtime.Frame and writes it to the
129-
// provided buffer. The frame is formatted as "function\n\tfile:line".
130-
func WriteFrameToBuffer(f runtime.Frame, b *strings.Builder) {
131-
b.WriteString(f.Function)
132-
b.WriteString("\n\t")
133-
b.WriteString(f.File)
134-
b.WriteRune(':')
135-
b.WriteString(strconv.Itoa(f.Line))
136-
}
137-
138126
// DefaultDepth is the default maximum number of stack frames to capture.
139127
const DefaultDepth = 64
140128

141-
// Capture captures the current goroutine's call stack. The skip argument
129+
// CaptureCaller captures a single caller frame from the current goroutine's call
130+
// stack. The skip argument specifies the number of frames to skip before
131+
// recording (0 means the direct caller of CaptureCaller).
132+
//
133+
// This is more efficient than CaptureStack when only the immediate caller information
134+
// is needed, as it only captures and processes a single frame.
135+
func CaptureCaller(skip int) Frame {
136+
skip += 2 // we don't want current function and runtime.Callers to get to trace
137+
138+
var pcs [1]uintptr
139+
140+
n := runtime.Callers(skip, pcs[:])
141+
142+
if n == 0 {
143+
return Frame{} //nolint:exhaustruct
144+
}
145+
146+
runtimeFrame, _ := runtime.CallersFrames(pcs[:n]).Next()
147+
148+
return NewFrame(runtimeFrame)
149+
}
150+
151+
// CaptureStack captures the current goroutine's call stack. The skip argument
142152
// specifies the number of frames to skip before recording (0 means start at
143153
// caller). The depth argument specifies the maximum number of frames to capture
144154
// (use math.MaxInt for unlimited).
145-
func Capture(skip, depth int) *Stack {
155+
func CaptureStack(skip, depth int) *Stack {
146156
skip++ // we don't want current function to get to trace
147157

148158
var pcs []uintptr
@@ -152,13 +162,13 @@ func Capture(skip, depth int) *Stack {
152162
pcs = callersFinite(skip, depth)
153163
}
154164

155-
stack := &Stack{frames: make([]runtime.Frame, 0, len(pcs))}
165+
stack := &Stack{frames: make([]Frame, 0, len(pcs))}
156166
frames := runtime.CallersFrames(pcs)
157167

158168
for {
159169
frame, next := frames.Next()
160170

161-
stack.frames = append(stack.frames, frame)
171+
stack.frames = append(stack.frames, NewFrame(frame))
162172

163173
if !next {
164174
break

stacktrace/stacktrace_test.go

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package stacktrace_test
22

33
import (
4-
"bytes"
54
"math"
65
"runtime"
76
"strings"
@@ -63,7 +62,7 @@ func TestFrame(t *testing.T) {
6362
assert.Equal(t, "undefined", fullPath)
6463
})
6564

66-
t.Run("Write formats correctly", func(t *testing.T) {
65+
t.Run("String formats correctly", func(t *testing.T) {
6766
t.Parallel()
6867

6968
frame := stacktrace.Frame{
@@ -73,14 +72,10 @@ func TestFrame(t *testing.T) {
7372
Function: "main.run",
7473
}
7574

76-
buf := &bytes.Buffer{}
77-
frame.Write(buf)
78-
79-
expected := "main.run\n\t/Users/test/main.go:100"
80-
assert.Equal(t, expected, buf.String())
75+
assert.Equal(t, "main.run\n\t/Users/test/main.go:100", frame.String())
8176
})
8277

83-
t.Run("Write handles zero PC", func(t *testing.T) {
78+
t.Run("String handles zero PC", func(t *testing.T) {
8479
t.Parallel()
8580

8681
frame := stacktrace.Frame{
@@ -90,16 +85,13 @@ func TestFrame(t *testing.T) {
9085
Function: "package.Function",
9186
}
9287

93-
buf := &bytes.Buffer{}
94-
frame.Write(buf)
95-
96-
assert.Equal(t, "undefined", buf.String())
88+
assert.Equal(t, "undefined", frame.String())
9789
})
9890
}
9991

10092
func recursionA(i, depth int) *stacktrace.Stack {
10193
if i == 0 {
102-
return stacktrace.Capture(0, depth)
94+
return stacktrace.CaptureStack(0, depth)
10395
}
10496

10597
fn := recursionB
@@ -112,7 +104,7 @@ func recursionA(i, depth int) *stacktrace.Stack {
112104

113105
func recursionB(i, depth int) *stacktrace.Stack {
114106
if i == 0 {
115-
return stacktrace.Capture(0, depth)
107+
return stacktrace.CaptureStack(0, depth)
116108
}
117109

118110
fn := recursionB
@@ -123,16 +115,19 @@ func recursionB(i, depth int) *stacktrace.Stack {
123115
return fn(i-1, depth)
124116
}
125117

126-
func TestCapture(t *testing.T) {
118+
func TestCaptureStack(t *testing.T) {
127119
t.Parallel()
128120

121+
assert.Empty(t, new(stacktrace.Stack).String(), "empty stack should produce empty string")
122+
129123
t.Run("shallow stack", func(t *testing.T) {
130124
t.Parallel()
131125

132126
// Capture a very shallow stack with only 2 frames
133-
s := stacktrace.Capture(0, 2)
127+
s := stacktrace.CaptureStack(0, 2)
134128
require.Equal(t, 2, s.Len())
135129

130+
// Test through the .String function to also check stringification validity.
136131
str := s.String()
137132
require.NotEmpty(t, str)
138133

@@ -141,7 +136,7 @@ func TestCapture(t *testing.T) {
141136
require.Len(t, lines, 4, "should have 4 lines: func1, location1, func2, location2")
142137

143138
// First frame: function name (the current test function)
144-
require.Contains(t, lines[0], "TestCapture", "first frame should be test function")
139+
require.Contains(t, lines[0], "TestCaptureStack", "first frame should be test function")
145140
require.Contains(t, lines[0], "stacktrace_test", "first frame should be in stacktrace_test package")
146141

147142
// First frame: location (starts with tab)
@@ -194,3 +189,42 @@ func TestCapture(t *testing.T) {
194189
require.Equal(t, s.Len(), count, "iterator should yield same number of frames as Len()")
195190
})
196191
}
192+
193+
func TestCaptureCaller(t *testing.T) {
194+
t.Parallel()
195+
196+
t.Run("captures direct caller", func(t *testing.T) {
197+
t.Parallel()
198+
199+
frame := stacktrace.CaptureCaller(0)
200+
require.NotEmpty(t, frame.Function, "should capture function name")
201+
require.Contains(t, frame.Function, "TestCaptureCaller", "should contain test function name")
202+
require.NotEmpty(t, frame.File, "should capture file path")
203+
require.Contains(t, frame.File, "stacktrace_test.go", "should be from test file")
204+
require.Positive(t, frame.Line, "should have positive line number")
205+
})
206+
207+
t.Run("skip parameter works", func(t *testing.T) {
208+
t.Parallel()
209+
210+
// CaptureStack from this test directly with different skip values
211+
frame0 := stacktrace.CaptureCaller(0)
212+
require.Contains(t, frame0.Function, "TestCaptureCaller", "skip=0 should capture test function")
213+
214+
// Use helper to test skip=1
215+
helperFunc := func() stacktrace.Frame {
216+
return stacktrace.CaptureCaller(1) // skip=1 skips helper, captures test
217+
}
218+
219+
frame1 := helperFunc()
220+
require.Contains(t, frame1.Function, "TestCaptureCaller", "skip=1 should skip helper and capture test")
221+
})
222+
223+
t.Run("returns empty frame when no caller", func(t *testing.T) {
224+
t.Parallel()
225+
226+
// Very large skip should result in empty frame
227+
frame := stacktrace.CaptureCaller(1000)
228+
require.Empty(t, frame.Function, "should return empty frame for too-large skip")
229+
})
230+
}

0 commit comments

Comments
 (0)