Evolving the Stack Annotations API to include Source Locations

Stack Annotations are a new feature in GHC-9.14 which allow you to push arbitrary Haskell values to the RTS callstack.
The annotations are rendered in backtraces in order to provide additional context for exceptions.

We started by exposing an experimental user interface for this feature in ghc-experimental-9.1401.0.
As we continue to learn more about the user requirements for the interface, we hope to stabilise over the next few GHC versions.

There is also a compatibility library ghc-stack-annotations for older GHC versions.

For example, the backtrace for the following program will contain the callstack added by annotateCallStackIO, a special list and a string annotation.

import GHC.Stack.Annotation.Experimental
import Control.Exception.Backtrace
import Control.Exception

main :: IO ()
main = do
  setBacktraceMechanismState IPEBacktrace True
  annotateCallStackIO $ do
    annotateStackShowIO ([1..4] :: [Int]) $ do
      annotateStackStringIO "Lovely annotation" $ do
        foo 500

foo :: Int -> IO ()
foo arg =
  throwIO $ ErrorCall $ "Exception: " <> show arg

This example assumes GHC 9.14.1 (and ghc-experimental GHC.Stack.Annotation.Experimental), and results in the following stacktrace:

T159: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

Exception: 500

IPE backtrace:
  Lovely annotation
  [1,2,3,4]
  annotateCallStackIO, called at app/Main.hs:12:3 in T159-0.1.0.0-inplace-T159:Main
HasCallStack backtrace:
  throwIO, called at app/Main.hs:19:3 in T159-0.1.0.0-inplace-T159:Main

The stack annotations are now visible in the backtrace, giving us backtraces which include user-specific context.

The interface of StackAnnotation was initially trying to be minimal, exposing a single method:

-- | 'StackAnnotation's are types which can be pushed onto the call stack
-- as the payload of 'AnnFrame' stack frames.
--
class StackAnnotation a where
  -- | Display a human readable string for the 'StackAnnotation'.
  displayStackAnnotation :: a -> String

This is flexible, but it turns out, the lack of structure makes it difficult for consumers to extract important information, for example source locations.

A better backtrace should always include source locations if possible, so we want to have a backtrace like this:

T159: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

Exception: 500

IPE backtrace:
  Lovely annotation, called at app/Main.hs:14:7 in T159-0.1.0.0-inplace-T159:Main
  [1,2,3,4], called at app/Main.hs:13:5 in T159-0.1.0.0-inplace-T159:Main
  annotateCallStackIO, called at app/Main.hs:12:3 in T159-0.1.0.0-inplace-T159:Main
HasCallStack backtrace:
  throwIO, called at app/Main.hs:19:3 in T159-0.1.0.0-inplace-T159:Main

Turns out, other downstream consumers directly benefit from source locations as well, for example ghc-debugger:

Note the rendered “Call stack” (on the left of the image) shows the rendered StackAnnotations including the source location.

Further, ghc-stack-profiler can use them to provide easier to read flamegraphs.


Thus, we propose to extend the interface of StackAnnotation to:

-- | 'StackAnnotation's are types which can be pushed onto the call stack
-- as the payload of 'AnnFrame' stack frames.
--
class StackAnnotation a where
  -- | Display a human readable string for the 'StackAnnotation'.
  --
  displayStackAnnotation :: a -> String

  -- | Get the 'SrcLoc' of the given 'StackAnnotation'.
  --
  -- This is optional, 'SrcLoc' are not strictly required for 'StackAnnotation', but
  -- it is still heavily encouarged to provide a 'SrcLoc' for better IPE backtraces.
  sourceLocationOfStackAnnotation :: a -> Maybe SrcLoc

  -- | The description of the StackAnnotation without any metadata such as source locations.
  displayStackAnnotationShort :: a -> String

This allows users to provide customised source locations, but still tries to be as flexible and non-restrictive as possible.

Now, my questions:

  • Do people agree that extending the StackAnnotation interface makes sense and is worthwhile to provide better backtraces?
  • If yes, what do you think about the hardest problem here, the naming? In particular sourceLocationOfStackAnnotation sounds really unwieldy to me.
  • Are there other important things StackAnnotation should expose in a structured manner?
14 Likes

I would use stackAnnotationSourceLocation, it sounds a bit better to me and resembles the common practice/hack of prefixing record fields with the name of the record.

As for displayStackAnnotation/shortDisplayStackAnnotation, perhaps we can get inspiration from displayException/displayExceptionWithInfo, and use displayStackAnnotation (for the short version) and displayStackAnnotationWithMetadata (for the version with SrcLoc). But I’m less sure about this.

In any case, I’m very happy about all these improvements in exceptions contexts, backtraces, and stack annotations! It was something that Haskell was sorely missing, and custom stack annotations might have lots of interesting uses. Like, I see myself putting things like user and request ids there, for easier debugging in case of exception :thinking: Or perhaps it wouldn’t be a good use case for them?

2 Likes

Thanks for the suggestions, I agree with stackAnnotationSourceLocation!

Unfortunately, as a little complication on top, changing the implementation of displayStackAnnotation to be the short version would be somewhat of a breaking change… A semantic breaking change that will not throw any compile time error :grimacing:

However, perhaps this isn’t too terrible… Difficult to say, perhaps we just immediately want to deprecate the displayStackAnnotation class method and just use new naming altogether?


I think this would be fitting use case for Stack Annotations :slight_smile:

One thing about which I would be unsure is whether to annotate the exception as it “bubbles up” using annotateIO, or to annotate the stack as we go deeper in the chain of calls with the new stack annotations :thinking: Is there a clear principle to choose between the two?

So, I thought about this a little bit. As you point out, it is quite unclear whether we should prefer annotating any thrown exception or annotating the stack directly.

Let me try to voice my thoughts:

  • annotateIO doesn’t need special support of the compiler to exist.
  • ExceptionAnnotation is a value attached to SomeException, as such, you can interact with it like any other Haskell object.
  • annotateIO has simpler semantics (you always use it in IO)
  • Only exception throwing benefits from annotateIO

On the other hand:

  • annotateStack* needs compiler support, but other tools, such as the debugger, can use the StackAnnotations to provide better backtraces, or more accurate profiles.
  • Pushes a Stack Frame, which could affects trail recursion (but that’s on par with annotateIO as it uses catch#)
  • Works with IPEBacktrace, while annotateIO feels more closely related to HasCallStack backtraces… But it doesn’t necessarily need to.

I think, annotateStack* is a more low-level primitive, but also more flexible.

To answer your particular question, I think at the moment, it makes sense to do both, e.g. annotateIO ctx . annotateStackShow ctx.
I generally think, catch is strategically a very good location to add StackAnnotation’s to it, since you already push a StackFrame, and another will not hurt :slight_smile: Also, having a source location at catch seems like it can only be beneficial.

In the future, when it becomes more apparent how StackAnnotations fit into the bigger picture of backtraces and exception handling, we might want to revisit annotateIO to also push a StackAnnotation by default.

1 Like