Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/canonical-frame-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-go": minor
---

Error tracking stack frames are now sent in the canonical cross-SDK wire order: the entry point is first and the crash/capture site is last. Previously frames were sent innermost-first. Coordinated rollout: the ingestion pipeline gates on `$lib_version`, so this ships as a minor release.
12 changes: 10 additions & 2 deletions error_tracking_stack_trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ type DefaultStackTraceExtractor struct {
InAppDecider InAppDecider
}

// GetStackTrace captures the current goroutine stack and converts it to an ExceptionStacktrace.
// The skip parameter omits leading runtime.Callers frames before conversion.
// GetStackTrace captures the current goroutine stack in wire order (outermost
// frame first, capture site last). The skip parameter omits leading
// runtime.Callers frames before conversion.
func (d DefaultStackTraceExtractor) GetStackTrace(skip int) *ExceptionStacktrace {
pcs := make([]uintptr, 64)
stackCallCount := runtime.Callers(skip, pcs)
Expand All @@ -76,6 +77,13 @@ func (d DefaultStackTraceExtractor) GetStackTrace(skip int) *ExceptionStacktrace
}
}

// runtime.CallersFrames yields innermost first; reverse to the canonical
// wire order shared across PostHog SDKs (matching NativeStackTraceExtractor):
// outermost (entry point) first, capture site last.
for i, j := 0, len(traces)-1; i < j; i, j = i+1, j-1 {
traces[i], traces[j] = traces[j], traces[i]
}

return &ExceptionStacktrace{
Type: "raw",
Frames: traces,
Expand Down
59 changes: 43 additions & 16 deletions error_tracking_stack_trace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,27 @@ func TestDefaultStackTraceExtractor_GetStackTrace(t *testing.T) {
skip int
expectedNames []string
}{
// Frames are emitted in canonical wire order: outermost (entry point,
// runtime.goexit) first, capture site (runtime.Callers) last.
"the basic happy path with no skip or function depth": {
functionCallDepth: 0,
skip: 0,
expectedNames: []string{
"runtime.Callers",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
"testing.tRunner",
"runtime.goexit",
"testing.tRunner",
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"runtime.Callers",
},
},
"skipping will remove entries from the head of the list, useful to omit internal function calls that add no value": {
"skipping removes the innermost frames, now at the tail, useful to omit internal function calls that add no value": {
functionCallDepth: 0,
skip: 3,
expectedNames: []string{
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
"testing.tRunner",
"runtime.goexit",
"testing.tRunner",
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
},
},
"calls with a very high skip level should return an empty stack trace": {
Expand All @@ -91,26 +93,27 @@ func TestDefaultStackTraceExtractor_GetStackTrace(t *testing.T) {
functionCallDepth: 5,
skip: 0,
expectedNames: []string{
"runtime.Callers",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"runtime.goexit",
"testing.tRunner",
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
// +1 total for the 'actual' function call
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.TestDefaultStackTraceExtractor_GetStackTrace.func1",
"testing.tRunner",
"runtime.goexit",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"runtime.Callers",
},
},
"once we exceed the pre-configured limit of 64, we will start to drop traces at the tail end": {
// The 64-frame capture limit drops the outermost frames (the runtime
// stops yielding after 64 innermost entries), so after reversal the
// entry-point frames are gone and the slice starts mid-chain.
"once we exceed the pre-configured limit of 64, we will start to drop traces at the outermost end": {
functionCallDepth: 77,
skip: 0,
expectedNames: []string{
"runtime.Callers",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
Expand Down Expand Up @@ -173,6 +176,8 @@ func TestDefaultStackTraceExtractor_GetStackTrace(t *testing.T) {
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.recursiveChain",
"github.com/posthog/posthog-go.DefaultStackTraceExtractor.GetStackTrace",
"runtime.Callers",
},
},
}
Expand All @@ -195,3 +200,25 @@ func TestDefaultStackTraceExtractor_GetStackTrace(t *testing.T) {
})
}
}

// TestDefaultStackTraceExtractor_CanonicalWireOrder is an explicit regression
// guard for the canonical wire order shared across PostHog SDKs: frames run
// bottom-up, so the first frame is the outermost entry point and the last frame
// is the innermost capture site. Reversing this order would silently break
// server-side grouping and display.
func TestDefaultStackTraceExtractor_CanonicalWireOrder(t *testing.T) {
extractor := DefaultStackTraceExtractor{InAppDecider: SimpleInAppDecider}

traces := extractor.GetStackTrace(0)
if traces == nil || len(traces.Frames) < 2 {
t.Fatalf("expected at least two frames, got %v", traces)
}

if got := traces.Frames[0].Function; got != "runtime.goexit" {
t.Errorf("first frame should be the outermost entry point: got=%q want=%q", got, "runtime.goexit")
}

if got := traces.Frames[len(traces.Frames)-1].Function; got != "runtime.Callers" {
t.Errorf("last frame should be the innermost capture site: got=%q want=%q", got, "runtime.Callers")
}
}
Loading