Exceptions kind of suck in Haskell. You don’t get a stack trace. They don’t show up in the types of functions. They incorporate a subtyping mechanism that feels more like Java casting than typical Haskell programming.
A partial solution to the problem is HasCallStack
- that gives us a CallStack
which gets attached to error
calls.
However, it only gets attached to error
- so you can either have String
error messages and a CallStack
, or you can have richly typed exceptions with no location information.
A CallStack
is a static piece of information about the code.
“You called foo
, which called bar
, which called quuz
, which blew up with Prelude.read: No parse
.”
The CallStack
answers a single question: “Where did this go wrong?”
But there’s often many more interesting questions that simply “Where?” You often want to know Who? When? How? in order to diagnose the big one: why did my code blow up?
In order to help answer these questions and develop robust exception reporting and diagnosing facilities, I created the annotated-exception
package.
annotated-exception
provides a big improvement in static CallStack
behavior.
To understand the improvement, let’s dig into the core problem:
If any function doesn’t include a HasCallStack
constraint in your stack, then the chain is broken, and you only get the stack closest to the source.
Consider this trivial example, which has a few ways of blowing up:
import GHC.Stack
foo :: HasCallStack => Int
foo = error "foo"
bar :: HasCallStack => Int
bar = foo
baz :: Int
baz = foo
quux :: HasCallStack => Int
quux = bar
ohno :: HasCallStack => Int
ohno = baz
If we call foo
in GHCi, we get the immediate stack trace:
λ> foo
*** Exception: foo
CallStack (from HasCallStack):
error, called at <interactive>:4:7 in interactive:Ghci1
foo, called at <interactive>:14:1 in interactive:Ghci2
Since the bar
term has the HasCallStack
constraint, it will add it’s location to the mix:
λ> bar
*** Exception: foo
CallStack (from HasCallStack):
error, called at <interactive>:4:7 in interactive:Ghci1
foo, called at <interactive>:6:7 in interactive:Ghci1
bar, called at <interactive>:15:1 in interactive:Ghci2
However, baz
omits the constraint, which means that you won’t get that function in the stack:
λ> baz
*** Exception: foo
CallStack (from HasCallStack):
error, called at <interactive>:4:7 in interactive:Ghci1
foo, called at <interactive>:8:7 in interactive:Ghci1
The quux
term has the call stack, so you get the whole story again:
λ> quux
*** Exception: foo
CallStack (from HasCallStack):
error, called at <interactive>:4:7 in interactive:Ghci1
foo, called at <interactive>:6:7 in interactive:Ghci1
bar, called at <interactive>:10:8 in interactive:Ghci1
quux, called at <interactive>:17:1 in interactive:Ghci2
But here’s the crappy thing - ohno
does have a HasCallStack
constraint.
You might expect that it would show up in the backtrace.
But it does not:
λ> ohno
*** Exception: foo
CallStack (from HasCallStack):
error, called at <interactive>:4:7 in interactive:Ghci1
foo, called at <interactive>:8:7 in interactive:Ghci1
The CallStack
for foo
, baz
, and ohno
are indistinguishable.
This makes diagnosing the failure difficult.
To avoid this problem, you must diligently place a HasCallStack
constraint on every function in your code base.
This is pretty annoying!
And if you have any library code that calls your code, the library’s lack of HasCallStack
will break your chains for you.
checkpoint
to the rescueannotated-exception
introduces the idea of a checkpoint
.
The simplest one is checkpointCallStack
, which attaches the call-site to any exceptions thrown out of the action:
checkpointCallStack
:: (HasCallStack, MonadCatch m)
=> m a
-> m a
Let’s replicate the story from above.
import Control.Exception.Annotated
foo :: IO Int
foo = throw (userError "foo")
-- in GHCi, evaluate:
-- λ> foo
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc
{ srcLocPackage = "interactive"
, srcLocModule = "Ghci1"
, srcLocFile = "<interactive>"
, srcLocStartLine = 4
, srcLocStartCol = 7
, srcLocEndLine = 4
, srcLocEndCol = 30
}
)
]
]
, exception = user error (foo)
}
I’ve formatted the output to be a bit more legible.
Now, instead of a plain IOError
, we’ve thrown an AnnotatedException IOError
.
Inside of it, we have the CallStack
from throw
, which knows where it was thrown from.
That CallStack
inside of the exception is reporting the call-site of throw
- not the definition site!
This is true even though foo
does not have a HasCallStack
constraint!
Let’s do bar
.
We’ll do HasCallStack
and our checkpointCallStack
, just to see what happens:
import GHC.Stack
bar :: HasCallStack => IO Int
bar = checkpointCallStack foo
-- λ> bar
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc { srcLocPackage = "interactive", srcLocModule = "Ghci1", srcLocFile = "<interactive>", srcLocStartLine = 4, srcLocStartCol = 7, srcLocEndLine = 4, srcLocEndCol = 30}
)
, ( "checkpointCallStack"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci2", srcLocFile = "<interactive>", srcLocStartLine = 15, srcLocStartCol = 7, srcLocEndLi ne = 15, srcLocEndCol = 30}
)
, ( "bar"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci3", srcLocFile = "<interactive>", srcLocStartLine = 17, srcLocStartCol = 1, srcLocEndLine = 17, srcLocEndCol = 4}
)
]
]
, exception = user error (foo)
}
We get the source location for throw
, checkpointCallStack
, and then the use site of bar
.
Now, suppose we have our Problem Function again: baz
doesn’t have a HasCallStack
constraint or a checkpointCallStack
.
And when we called it through ohno
, we lost the stack, even though ohno
had the HasCallStack
constraint.
baz :: IO Int
baz = bar
ohno :: IO Int
ohno = checkpointCallStack baz
-- λ> ohno
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci1", srcLocFile = "<interactive>", srcLocStartLine = 4, srcLocStartCol = 7, srcLocEndLine = 4, srcLocEndCol = 30}
)
, ( "checkpointCallStack"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci2", srcLocFile = "<interactive>", srcLocStartLine = 15, srcLocStartCol = 7, srcLocEndLi ne = 15, srcLocEndCol = 30}
)
, ( "bar"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci3", srcLocFile = "<interactive>", srcLocStartLine = 21, srcLocStartCol = 7, srcLocEndLine = 21, srcLocEndCol = 10}
)
, ( "checkpointCallStack"
, SrcLoc {srcLocPackage = "interactive", srcLocModule = "Ghci3", srcLocFile = "<interactive>", srcLocStartLine = 23, srcLocStartCol = 8, srcLocEndLine = 23, srcLocEndCol = 31}
)
]
]
, exception = user error (foo)
}
When we call ohno
, we preserve all of the entries in the CallStack
.
checkpointCallStack
in ohno
adds itself to the CallStack
that is present on the AnnotatedException
itself, so it doesn’t need to worry about the stack being broken.
It’s perfectly capable of recording that history for you.
catch
me laterThe type signature for catch
in annotated-exception
looks like this:
catch
:: (HasCallStack, Exception e, MonadCatch m)
=> m a
-> (e -> m a)
-> m a
That HasCallStack
constraint is used to give you a CallStack
entry for any time that you catch
an exception.
newtype MyException = MyException String
deriving Show
instance Exception MyException
boom :: IO Int
boom = throw (MyException "boom")
recovery :: IO Int
recovery =
boom `catch` \(MyException message) -> do
putStrLn message
throw (MyException (message ++ " recovered"))
recovery
catches the MyException
from boom
, prints the message, and then throws a new exception with a modified message.
λ> recovery
boom
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 19, srcLocStartCol = 9, srcLocEndLine = 19, srcLocEndCol = 54}
)
, ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 13, srcLocStartCol = 8, srcLocEndLine = 13, srcLocEndCol = 34}
)
, ( "catch"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 17, srcLocStartCol = 5, srcLocEndLine = 19, srcLocEndCol = 54}
)
]
]
, exception = MyException "boom recovered"
}
Now, look at that call stack: we have the first throw
(from boom
), then we have the second throw
(in recovery
), and finally the catch
in recovery
.
So we know where the exception originally happened, where it was rethrown, and where it was caught. This is fantastic!
But, even better - these annotations survive even if you throw a different type of Exception
.
This means you can translate exceptions fearlessly, knowing that any essential annotated context won’t be lost.
As I said earlier, CallStack
is fine, but it’s a static thing.
We can figure out “what code called what other code” that eventually led to an exception, but we can’t know anything about the running state of the program.
Enter checkpoint
.
This function attaches an arbitrary Annotation
to thrown exceptions.
An Annotation
is a wrapper around any value that has an instance of Show
and Typeable
.
The library provides an instance of IsString
for this, so you can enable OverloadedStrings
and have stringly-typed annotations.
constantAnnotation :: IO String
constantAnnotation =
checkpoint "from constant annotation" $ do
msg <- getLine
if null msg
then throw (MyException "empty message")
else pure msg
But the real power is in using runtime data to annotate things.
Let’s imagine you’ve got a web application. You’re reporting runtime exceptions to a service, like Bugsnag. Specific teams “own” routes, so if something breaks, you want to alert the right team.
You can annotate thrown exceptions with the route.
data Route
= Login
| Signup
| ViewPosts
| CreatePost
| EditPost PostId
deriving Show
dispatch :: Request -> IO Response
dispatch req =
case parseRequest req of
Right route ->
checkpoint (Annotation route) $
case route of
Login ->
handleLogin
Signup ->
handleSignup
ViewPosts ->
handleViewPosts
CreatePost ->
handleCreatePost
EditPost postId ->
checkpoint (Annotation postId) $
handleEditPost postId
Left _ ->
invalidRouteError
Now, suppose an exception is thrown somewhere in handleLogin
.
It’s going to bubble up past dispatch
and get handled by the Warp default exception handler.
That’s going to dig into the [Annotation]
and use that to alter the report we send to Bugsnag.
The team that is responsible for handleLogin
gets a notification that something broke there.
In the EditPost
case, we’ve also annotated the exception with the post ID that we’re trying to edit.
This means that, when debugging, we can know exactly which post threw the given exception.
Now, when diagnosing and debugging, we can immediately pull up the problematic entry.
This gives us much more information about the problem, which makes diagnosis easier.
Likewise, suppose we have a function that gives us the logged in user:
withLoggedInUser :: (Maybe (Entity User) -> IO a) -> IO a
withLoggedInUser action = do
muser <- getLoggedInUser
checkpoint (Annotation (fmap entityKey muser)) $ do
action muser
If the action we pass in to withLoggedInUser
throws an exception, that exception will carry the Maybe UserId
of whoever was logged in.
Now, we can easily know who is having a problem on our service, in addition to what the problem actually is.
But wait - if all exceptions are wrapped with this
AnnotatedException
type, then how do I catch things? Won’t this pollute my codebase?And, what happens if I try to catch an
AnnotatedException MyException
but some other code only threw a plainMyException
? Won’t that break things?
These are great questions.
catch
and try
from other libraries will fail to catch a FooException
if the real type of the exception is AnnotatedException FooException
.
However, catch
and try
from annotated-exception
is capable of “seeing through” the AnnotatedException
wrapper.
In fact, we took advantage of this earlier - here’s the code for recovery
again:
boom :: IO Int
boom = throw (MyException "boom")
recovery :: IO Int
recovery =
boom `catch` \(MyException message) -> do
putStrLn message
throw (MyException (message ++ " recovered"))
Note how catch
doesn’t say anything about annotations.
We catch a MyException
, exactly like you would in Control.Exception
, and the annotations are propagated.
But, let’s say you want to catch the AnnotatedException MyException
.
You just do that.
recoveryAnnotated :: IO Int
recoveryAnnotated =
boom `catch` \(AnnotatedException annotations (MyException message)) -> do
putStrLn message
traverse print annotations
throw (OtherException (length message))
-- in GHCi,
λ> recoveryAnnotated
boom
Annotation @CallStack [("throw",SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 13, srcLocStartCol = 8, srcLocEndLine = 13, srcLocEndCol = 34})]
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 37, srcLocStartCol = 9, srcLocEndLine = 37, srcLocEndCol = 48}
)
]
]
, exception = OtherException 4
}
Now, something tricky occurs here: we don’t preserve the annotations on the thrown exception.
If you catch an AnnotatedException
, the library assumes that you’re going to handle those yourself.
If you want to keep them, you’d need to throw an AnnotatedException
:
recoveryAnnotatedPreserve :: IO Int
recoveryAnnotatedPreserve =
boom `catch` \(AnnotatedException annotations (MyException message)) -> do
putStrLn message
traverse print annotations
throw (AnnotatedException annotations (OtherException (length message)))
-- in GHCi,
λ> recoveryAnnotatedPreserve
boom
Annotation @CallStack [("throw",SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 13, srcLocStartCol = 8, srcLocEndLine = 13, srcLocEndCol = 34})]
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 44, srcLocStartCol = 9, srcLocEndLine = 44, srcLocEndCol = 81}
)
]
, Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "src/annotated.hs", srcLocStartLine = 13, srcLocStartCol = 8, srcLocEndLine = 13, srcLocEndCol = 34}
)
]
]
, exception = OtherException 4
}
We’re missing catch
, which is unfortunate, but generally you aren’t going to be doing this - you’re either going to be handling an error completely, or rethrowing it, and the [Annotation]
won’t be relevant to you… unless you’re writing an integration with Bugsnag, or reporting on them in some other way.
So annotated-exception
’s exception handling functions can “see through” an AnnotatedException inner
to work only on the inner
exception type.
But what if I try to catch a DatabaseException
as an AnnotatedException DatabaseException
?
Turns out, the Exception
instance of AnnotatedException
allows you to do that.
import qualified Control.Exception
emptyAnnotationsAreCool :: IO ()
emptyAnnotationsAreCool =
Control.Exception.throwIO (MyException "definitely not annotated?")
`Control.Exception.catch`
\(AnnotatedException annotations (MyException woah)) -> do
print annotations
putStrLn woah
-- in GHCi,
λ> emptyAnnotationsAreCool
[]
definitely not annotated?
We promote the inner
into AnnotatedException [] inner
.
So the library works regardless if any code you throw cares about AnnotatedException
.
If you call some external library code which throws an exception, you’ll get the first annotation you try - including if that’s just from catch
:
catchPutsACallStack :: IO ()
catchPutsACallStack =
Control.Exception.throwIO (MyException "definitely not annotated?")
`catch`
\(MyException woah) -> do
throw (OtherException (length woah))
-- in GHCi,
λ> catchPutsACallStack
*** Exception:
AnnotatedException
{ annotations =
[ Annotation @CallStack
[ ( "throw"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "../parsonsmatt.github.io/src/annotated.hs", srcLocStartLine = 60, srcLocStartCol = 17, srcLocEndLine = 60, srcLocEndCol = 53})
, ("catch"
, SrcLoc {srcLocPackage = "main", srcLocModule = "Annotated", srcLocFile = "../parsonsmatt.github.io/src/annotated.hs", srcLocStartLine = 58, srcLocStartCol = 9, srcLocEndLine = 58, srcLocEndCol = 16}
)
]
]
, exception = OtherException 25
}
We get throw
and catch
both showing up in our stack trace.
If we’d used Control.Exception.throwIO
instead of Control.Exception.Annotated.throw
, then we’d still have catch
as an annotation.
The primary purpose here is to share the technique and inspire a hunger for dynamic exception annotations.
We’ve been using this technique at Mercury for most of this year. It has dramatically simplified how we report exceptions, the shape of our exceptions, and how much info we get from a Bugsnag report. It’s now much easier to diagnose problems and fix bugs.
The Really Big Deal here is that - we now have something better than other languages.
The lack of stack traces in Haskell is really annoying, and a clear way that Haskell suffers compared to Ruby or Java.
But now, with annotated-exception
, we actually have more powerful and more useful exception annotations than a mere stack trace.
And, since this is all just library functions, you can swap to Control.Exception.Annotated
with little fuss.