parsonsmatt/annotated-exception

Guide for integrating `AnnotatedException`

parsonsmatt opened this issue · 2 comments

It's not entirely clear the best way to integrate AnnotatedException into a code base.

As Shea notices, this stuff is all one-way. Once an exception has been Annotated, the regular try/catch/handle etc functions won't be able to see it anymore. If your library throws AnnotatedException e, you're essentially locking your users into this library. Beyond just loudly documenting this, I wonder if there's a good way to have AnnotatedException link to it's own package - maybe the Show instance should say something like catch me with Control.Exception.Annotated.

So, a first step, is to probably import Control.Exception.Annotated everywhere in place of Control.Exception{,.Safe} or Control.Monad.Catch. If you're using a custom Prelude, this is easy - use hlint to ban Control.Exception and friends, ensure that try/catch etc come from this package, and you should be good.

But then - just to be safe - you should probably start by incorporating this at the top-level of your program. Ensure that all places that need to catch/handle exceptions are doing so with AnnotatedException. This shouldn't be totally necessary - try and catch from this library will work fine to catch the underlying e from an AnnotatedException e.

Once you've started with that, you can start adding checkpoints on code and get neat information back out.

Then you have to figure out how you want to ensure you're actually getting all the information you dig out of the Annotation. I'm not entirely sure the best way to do this. But, yeah, writing your own checkpoint that takes some more specific type is probably a good idea.

OK, so here's what I'm working with in the work codebase.

We're defining:

data OurAnnotation where
  OurAnnotation :: (OurRequirements a) => a -> OurAnnotation

ourCheckpoint :: (OurRequirements a, MonadUnliftIO m) => a -> m r -> mr
ourCheckpoint a 
    checkpoint (Annotation (OurAnnotation a))

Then, when we're digging through our [Annotation], we do something like this:

castToOurAnnotation :: Annotation -> Either OurAnnotation Annotation
castToOurAnnotation ann@(Annotation a) =
    case cast a of
        Just (OurAnnotation b) -> Left (OurAnnotation b)
        Nothing -> Right ann

handleAnnotations :: [Annotation] -> IO ()
handleAnnotations anns = do
    let (ours, unknown) = partitionEithers $ map castToOurAnnotation anns
    -- ...

For our codebase, this ends up doing BugsnagEvent modifications, and our core class is basically class MercuryAnnotation a where mercuryAnnotationBugsnagModifier :: a -> BugsnagEvent -> BugsnagEvent.

(though, i am now realizing that there's a lot of the Data.Annotation API that I'm recreating here)

For unknown annotations, we just show the underlying thing and report it as a string. Which isn't ideal, but we also control almost all of the annotations.

Bugsnag Integration

I wrote a brief issue comment on integrating Bugsnag and annotated-exception, which should apply to other error reporting services too.

Stripping Annotation at the reporting site

In bugsnag-haskell there's a field on BugsnagSettings called bsBeforeNotify :: BugsnagEvent -> BugsnagEvent. When initializing a BugsnagSettings, you will want to start by stripping off the AnnotatedException wrapper and folding the annotations into the event there.

myDefaultBugsnagSettings :: BugsnagSettings
myDefaultBugsnagSettings = 
    defaultBugsnagSettings
        { bsBeforeNotify = \event -> 
            updateEventFromOriginalException $ \(AnnotatedException anns exn) event ->
                (annotationsToBugsnagModifier anns event)
                    { beException = 
                        (beException event)
                            { beOriginalException =
                                Just exn
                            }
                    }
        }    

This will allow any other updateEventOnOriginalException to target the correct exception type. Additionally, you won't need to remember to strip annotations when doing a notifyBugsnag - it'll "Just Work" on any AnnotatedException directly.

Using internal checkpoint to add before notify hooks

Another pattern (in our app, at least) is to use Bugsnag with uncaught exceptions - something like:

case maybeThing of
    Just thing -> do
        doWorkOn thing
        pure ()
    Nothing -> 
        reportException $ SomeProblem "Thing wasn't present"

This is a non-annotated exception, and so we lose out on the annotations that may have been embedded had this been checkpointed.

To work around this, we provide our own checkpoint that adds a bsBeforeNotify hook after the ones already defined.

myCheckpoint :: (MyAnnotation ann, MonadBugsnag m, MonadUnliftIO m) => ann -> m a -> m a
myCheckpoint ann action = 
    withBeforeNotifyHook (myAnnotationToBeforeNotify ann) $ 
        checkpoint (Annotation (MyAnnotationWrapper ann)) action

withBeforeNotifyHook f action =
    local (\bsSettings -> bsSettings { bsBeforeNotify = f . bsBeforeNotify bsSettings })

The withBeforeNotifyHook ensures the annotation is present on the exception if it is reported inside of action, and the checkpoint ensures that it is attached to any exceptions that escape action.