diff --git a/errors.go b/errors.go index dffc743..737daf7 100644 --- a/errors.go +++ b/errors.go @@ -158,13 +158,45 @@ func (p *Error) StackTrace() []uintptr { return out } -// StackString formats the stack as a beautiful string with newlines +// StackString formats the stacks from the terror chain as a string. If we +// encounter more than one terror in the chain with a stack frame, we'll print +// each one, separated by three hyphens on their own line. func (p *Error) StackString() string { - stackStr := "" - for _, frame := range p.StackFrames { - stackStr = fmt.Sprintf("%s\n %s:%d in %s", stackStr, frame.Filename, frame.Line, frame.Method) + // 32,000 seems like a reasonable limit for a stack trace. Otherwise, we risk + // overwhelming downstream systems. + return StackStringWithMaxSize(p, 32000) +} + +func StackStringWithMaxSize(p *Error, sizeLimit int) string { + // if we run into this many causes, we've likely run into something absurd. Like + // a self causing error. + const maxCausalDepth = 1024 + var buffer strings.Builder + terr := p + var causalDepth int +outer: + for terr != nil { + if buffer.Len() != 0 && len(terr.StackFrames) > 0 { + fmt.Fprintf(&buffer, "\n---") + } + for _, frame := range terr.StackFrames { + // 10 seems like a reasonable estimate of how large the rest of the line would be. + estimatedLineLen := len(frame.Filename) + len(frame.Method) + 16 + if estimatedLineLen+buffer.Len() > sizeLimit { + break outer + } + fmt.Fprintf(&buffer, "\n %s:%d in %s", frame.Filename, frame.Line, frame.Method) + } + + if tcause, ok := terr.cause.(*Error); ok && causalDepth < maxCausalDepth { + terr = tcause + causalDepth += 1 + } else { + break outer + } } - return stackStr + + return buffer.String() } // VerboseString returns the error message, stack trace and params diff --git a/errors_test.go b/errors_test.go index cdf1be0..4aa9cd2 100644 --- a/errors_test.go +++ b/errors_test.go @@ -583,3 +583,36 @@ func TestSetIsUnexpected(t *testing.T) { err.SetIsUnexpected(false) assert.False(t, *err.IsUnexpected) } + +func failyFunction() error { + return InternalService("halp", "I'm in trouble", nil) +} + +func TestStackStringChasesCausalChain(t *testing.T) { + err := Augment(failyFunction(), "something may be up", nil) + terr := err.(*Error) + ss := terr.StackString() + t.Log(ss) + assert.Contains(t, ss, "failyFunction") +} + +func TestCircularErrorProducesFiniteOutputWithStackFrames(t *testing.T) { + orig := failyFunction() + err := Augment(orig, "something may be up", nil) + terr := err.(*Error) + terr.cause = terr + terr.StackFrames = orig.(*Error).StackFrames + ss := terr.StackString() + + // The default field size limit used in elastic-slog. It's kind of arbitrary, but it'll do for now. + assert.Less(t, len(ss), 32000) + assert.GreaterOrEqual(t, len(ss), 32000-1000) +} +func TestCircularErrorProducesFiniteOutputWithoutStackFrames(t *testing.T) { + err := Augment(failyFunction(), "something may be up", nil) + terr := err.(*Error) + terr.cause = terr + ss := terr.StackString() + // There's no actual stack in the causal cycle, so we don't render anything here. + assert.Empty(t, ss) +}