Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add interface for libraries to register exception enrichment #47

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

iand675
Copy link
Owner

@iand675 iand675 commented Dec 15, 2022

This is a stab at supporting improved exception reporting in a fashion that can mostly reduce the work needed to wire things up. This is to support things like annotated-exceptions better. Feedback is appreciated.

Copy link
Collaborator

@parsonsmatt parsonsmatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a function in the record will work better than the class magic. The class stuff works for terminal types, but not for wrappers.

Comment on lines +351 to +357
catchesHandler :: ExceptionEnricher -> SomeException -> [(Text, Attribute)]
catchesHandler handler e = tryHandler handler
where
tryHandler (ExceptionEnricher handler _)
= case fromException e of
Just e' -> handler e'
Nothing -> []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hrm, I found the name shadowing a bit confusing here. Would probably prefer to inline some of that:

Suggested change
catchesHandler :: ExceptionEnricher -> SomeException -> [(Text, Attribute)]
catchesHandler handler e = tryHandler handler
where
tryHandler (ExceptionEnricher handler _)
= case fromException e of
Just e' -> handler e'
Nothing -> []
catchesHandler :: ExceptionEnricher -> SomeException -> [(Text, Attribute)]
catchesHandler (ExceptionEnricher handler _) e =
case fromException e of
Just e' -> handler e'
Nothing -> []

Comment on lines +683 to +686
-- | This type class allows library authors and users to enrich the
-- contents of an exception with additional attributes.
class Exception e => HasExceptionEnricher e where
exceptionEnricher :: Proxy e -> ExceptionEnricher
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Thinking about how I'd write this for annotated-exception.

instance (HasExceptionEnricher e) => HasExceptionEnricher (AnnotatedException e) where
  exceptionEnricher _ = ExceptionEnricher
    { exceptionEnricherAdditionalFields = \(AnnotatedException anns er) ->
        exceptionEnricherAdditionalFields (exceptionEnricher (pure er)) e
        <> makeAdditionalFields anns
    , exceptionEnricherName = "AnnotatedException " <> show (typeRep @e)
    }

makeAdditionalFields is pretty limited- it can only access the Annotation, which can only really be shown into a String. So the only real impl would be something like map (\(Annotation ann) -> (Text.pack (show $ typeRep (pure ann)), show ann)) annotations.

I would expect that apps would want to customize this directly, for their own app. So having a type class may not be the best way to do that.

discoverInstances also only works "transitively" in the same package, otherwise you do need the type itself imported in a module (and used!) or it won't discover the instance.

Copy link
Owner Author

@iand675 iand675 Dec 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so that's why I took the approach of providing tracerProviderOptionsExceptionEnrichers.

There are two things I'm trying to solve here:

  1. This is the hs-opentelemetry-api package, which is intended to be usable by libraries (such as persistent) in such a fashion that they have no effect if you aren't using hs-opentelemetry-sdk in your application. I'd like to provide a mechanism for domain-specific exceptions to provide additional attributes to the span, which is why the ExceptionEnricher + type class + discoverExceptionEnricher mechanism exists. I know it's a bit brittle in that you have to import the right things in the right module, but I'm not sure how else to provide any amount of automatic hookup for library-supplied enrichers.
  2. I don't want the type class instance auto-discovery bits to be required to use the library, which is why any discovered enrichers ultimately have to be set in the tracerProviderOptionsExceptionEnrichers :: [ExceptionEnricher] record field. In the case of wanting to do more than rendering annotations to a string, this would presumably be where you'd be able to append a domain-specific ExceptionEnricher that can leverage Typeable to do more useful annotations.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we do need the function parameter to have a [Enricher] parameter - otherwise, we can only deal with the things that are known that that point in time. I ran into this same problem when considering a plain function approach - you can recurse into wrappers just fine, and you can compose SomeException -> [(Text, Attribute)] fine with <>, but you can't make a wrapper that can see through other wrappers defined later, unless you accept the final :: [Enricher] as a late bound function argument.

Playing with it this afternoon and I got:

newtype Enricher = Enricher ([Enricher] -> SomeException -> [(Text, Attribute)])
    deriving newtype (Semigroup, Monoid)

enrich :: SomeException -> [Enricher] -> [(Text, Attribute)]
enrich exception enrichers = 
    foldMap (\(Enricher k) -> k enrichers exception)

simpleEnricher :: Exception e => (e -> [(Text, Attribute)]) -> Enricher
simpleEnricher k =
    Enricher \_ someException -> 
        foldMap k (fromException someException)

nestedEnricher :: Exception e => ([Enricher] -> e -> [(Text, Attribute)]) -> Enricher
nestedEnricher k =
    Enricher \final exception -> 
        foldMap (k final) (fromException exception) 

Each library can define an Enricher for a specific exception, then mconcat them all into a single Enricher.

Figuring out the exact right implementation for this is subtle, though. cast and fromException need to be chosen with care, or you may get into infinite loops. So I think it's important to figure out some basic tests (SomeException, SomeAsyncException, SyncExceptionWrapper, AsyncExceptionWrapper, AnnotatedException, etc) and ensure we're getting the right behavior.

Figuring out the plugin-UX side of things can happen at a later step. IMO it's fine to say "You need to import the Enricher for a given library and plug it in to the settings." Very few libraries work in any other way.

Comment on lines 390 to 398
( \e (parent, s) -> liftIO $ do
forM_ e $ \(SomeException inner) -> do
forM_ e $ \anException@(SomeException inner) -> do
setStatus s $ Error $ T.pack $ displayException inner
recordException s [] Nothing inner
let exceptionAttributes = concatMap (\enricher -> catchesHandler enricher anException) $
V.toList (tracerProviderExceptionEnrichers $ tracerProvider t)
recordException s exceptionAttributes Nothing inner
endSpan s Nothing
adjustContext $ \ctx ->
maybe (removeSpan ctx) (`insertSpan` ctx) parent
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like what I usually see is a configurable function, like data Settings = Settings { settingsOnException :: SomeException -> IO () }.

For this pattern, we can do:

data TracerProvider = TracerProvider 
  { ...
  , tracerProviderExceptionToAttributes :: SomeException -> [(Text, Attr)]
  }

Which removes the need for the ExceptionEnricher complexity.

But, is this equivalent?

Let's write one that pulls annotations off an annotated exception.

tracerProvider = TracerProvicer {..}
  where
    tracerProviderExceptionToAttributes somE@(SomeException inner) 
      -- because fromException always succeeds, don't want to infinite recursion
      | Just (AnnotatedException anns err) <- cast inner 
      = convertAnnotations anns <> tracerProvicerExceptionToAttributes (toException err)
      | Just (SomeAsyncException inner) <- fromException somE
      = ("Async Exception", _) : tracerProviderExceptionToAttributes (SomeException inner)
      -- use `SomeException` instead of `toException` because `toException` will rewrap in `SomeAsyncException`

Hmm, already getting pretty complex...

But I think it's probably better than the ExceptionEnricher class, which doesn't locally know what the other things are doing.

instance HasExceptionEnricher SomeAsyncException where
    exceptionEnricher _ = ExceptionEnricher 
      do \(SomeAsyncException e) -> 
           -- well, we can't really do anything interesting here
      do "SomeAsyncException"

AnnotatedException is able to summon the canonical ExceptionEnricher for a type, since it has the type parameter. But no other exception wrapper would work.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we could work around the wrapper issue by passing in known exception enrichers to the function:

data ExceptionEnricher = forall e. Exception e => ExceptionEnricher
  { exceptionEnricherAdditionalFields :: [ExceptionEnricher] {- ^ all known enrichers -} -> e -> [(Text, Attribute)]
  , exceptionEnricherName :: Text
  }

That would allow us in the case of wrappers to feed the inner exception through the same process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants