packages feed

otel-effectful-1.0.0: src/Effectful/OpenTelemetry/Tracing/Effect.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedLists #-}

module Effectful.OpenTelemetry.Tracing.Effect
    ( -- * Effect
      Tracing
    , inSpan
    , inOkSpan
    , inErrorSpan
    , withContext
    , addEvent
    , addEventNow
    , recordException
    , recordExceptionAt
    , currentContext
    , currentContextIO

      -- * Runners
    , runTracing
    , runHttpTracing
    , runGrpcTracing
    , runConsoleTracing
    , runInMemoryTracing
    , runTracingWith
    , runNoTracing
    )
where

import Control.Monad.Extra (ifM)
import Data.Aeson (ToJSON (..))
import Data.Functor (void, (<&>))
import Data.Sequence (Seq, (|>))
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Typeable (typeOf)
import Effectful
import Effectful.Concurrent (Concurrent)
import Effectful.Dispatch.Static
import Effectful.Environment (Environment)
import Effectful.Error.Static (Error, throwError, tryError)
import Effectful.Exception
    ( Exception
    , SomeException
    , displayException
    , mask
    , throwIO
    , try
    , uninterruptibleMask_
    )
import Effectful.Http2Client (HostName, PortNumber)
import Effectful.HttpClient (responseTimeoutDefault)
import Effectful.OpenTelemetry.Exporter (Exporter)
import Effectful.OpenTelemetry.Exporter qualified as Exporter
import Effectful.OpenTelemetry.Exporter.Console qualified as Console
import Effectful.OpenTelemetry.Exporter.Environment qualified as Exporter.Environment
import Effectful.OpenTelemetry.Exporter.OTLP (defaultGrpcSendTimeout)
import Effectful.OpenTelemetry.Protocol
    ( Attributes
    , Compression
    , Encoding
    , OTLP
    , Resource
    , Scope
    , exportIO
    , runInMemoryOTLP
    , runNoOTLP
    , runOTLPWith
    )
import Effectful.OpenTelemetry.Protocol.Environment qualified as Environment
import Effectful.OpenTelemetry.Protocol.Export qualified as Export
import Effectful.OpenTelemetry.Timestamp (Timestamp)
import Effectful.OpenTelemetry.Timestamp qualified as Timestamp
import Effectful.OpenTelemetry.Tracing.Span (Span (..))
import Effectful.OpenTelemetry.Tracing.Span.Context qualified as Span (Context)
import Effectful.OpenTelemetry.Tracing.Span.Context qualified as Span.Context
import Effectful.OpenTelemetry.Tracing.Span.Event (Event (..))
import Effectful.OpenTelemetry.Tracing.Span.Event qualified as Event
import Effectful.OpenTelemetry.Tracing.Span.Kind qualified as Span
import Effectful.OpenTelemetry.Tracing.Span.Status qualified as Span.Status
import Effectful.Retry (Retry)
import Effectful.Timeout (Timeout)
import GHC.Stack (whoCreated)
import Network.URI (URI)
import Prelude

data Tracing :: Effect

type instance DispatchOf Tracing = 'Static 'WithSideEffects

data instance StaticRep Tracing = Tracing
    { currentSpan :: Maybe Span.Context
    , events :: Seq Event
    , export :: Span -> IO ()
    }

data ExitCase e
    = ExitSuccess
    | ExitFailure e

-- | Execute an action with a given 'Span.Context'.
withContext :: (Tracing :> es) => Span.Context -> Eff es a -> Eff es a
withContext context = localStaticRep \tracing ->
    tracing{currentSpan = Just context, events = mempty}

-- | Execute an action within a named 'Span'.
-- If the action throws an exception, the 'Span' is finalised with 'Span.Status.Error'
-- and the exception is re-raised.
-- Otherwise, the 'Span' is exported with 'Span.Status.Unset'.
inSpan
    :: forall es a
     . (HasCallStack, Tracing :> es)
    => Text
    -> Span.Kind
    -> Attributes
    -> Eff es a
    -> Eff es a
inSpan name kind attributes action = do
    tracing <- getStaticRep @Tracing
    context <- unsafeEff_ $ Span.Context.new tracing.currentSpan
    startTime <- unsafeEff_ Timestamp.now
    withContext context $ finallyCaseIO @SomeException action \exitCase -> do
        endTime <- unsafeEff_ Timestamp.now
        tracing' <- getStaticRep @Tracing
        unsafeEff_ . tracing.export $
            Span
                { name
                , parentSpanId = tracing.currentSpan <&> (.spanId)
                , context
                , kind
                , startTime
                , endTime = Just endTime
                , attributes
                , events = tracing'.events
                , status =
                    case exitCase of
                        ExitSuccess -> Span.Status.unset
                        (ExitFailure e) -> Span.Status.fromException e
                }
  where
    finallyCaseIO
        :: forall e
         . (Exception e)
        => Eff es a
        -> (ExitCase e -> Eff es ())
        -> Eff es a
    finallyCaseIO thing after = mask \restore -> do
        try @e (restore thing) >>= \case
            Left e -> do
                recordException e mempty
                void . try @e . uninterruptibleMask_ . after $ ExitFailure e
                throwIO e
            Right y -> do
                void . try @e . uninterruptibleMask_ $ after ExitSuccess
                pure y

-- | Execute an action that may not fail within a named 'Span'.
-- Explicity marks the 'Span' with 'Span.Status.Ok'.
-- Does not export the 'Span' if the action raises an exception.
-- Use 'inErrorSpan' or 'inSpan' for actions that may fail.
inOkSpan
    :: forall a es
     . (Tracing :> es)
    => Text
    -> Span.Kind
    -> Attributes
    -> Eff es a
    -> Eff es a
inOkSpan name kind attributes action = do
    tracing <- getStaticRep @Tracing
    context <- unsafeEff_ $ Span.Context.new tracing.currentSpan
    startTime <- unsafeEff_ Timestamp.now
    (a, tracing') <- withContext context $ (,) <$> action <*> getStaticRep @Tracing
    endTime <- unsafeEff_ Timestamp.now
    unsafeEff_ . tracing.export $
        Span
            { name
            , parentSpanId = tracing.currentSpan <&> (.spanId)
            , context
            , kind
            , startTime
            , endTime = Just endTime
            , attributes
            , events = tracing'.events
            , status = Span.Status.ok
            }
    pure a

-- | Execute an action that may fail with the expected @Error e@ within a named 'Span'.
-- If the action fails with this expected @Error e@, the 'Span' is finalised with 'Span.Status.Error'
-- and the exception is re-raised.
-- Otherwise, exports the 'Span' with 'Span.Status.Unset'.
-- Does not export the 'Span' if the action fails with an unexpected exception (e.g. via 'throwIO').
-- If you need to export 'Span's in the presence of any exception, use 'inSpan' instead.
inErrorSpan
    :: forall e es a
     . (Exception e, Error e :> es, Tracing :> es)
    => Text
    -> Span.Kind
    -> Attributes
    -> Eff es a
    -> Eff es a
inErrorSpan name kind attributes action = do
    tracing <- getStaticRep @Tracing
    context <- unsafeEff_ $ Span.Context.new tracing.currentSpan
    startTime <- unsafeEff_ Timestamp.now
    withContext context . finallyCase action $ \exitCase -> do
        endTime <- unsafeEff_ Timestamp.now
        tracing' <- getStaticRep @Tracing
        unsafeEff_ . tracing.export $
            Span
                { name
                , parentSpanId = tracing.currentSpan <&> (.spanId)
                , context
                , kind
                , startTime
                , endTime = Just endTime
                , attributes
                , events = tracing'.events
                , status =
                    case exitCase of
                        ExitSuccess -> Span.Status.unset
                        (ExitFailure e) -> Span.Status.fromException e
                }
  where
    finallyCase :: Eff es a -> (ExitCase e -> Eff es ()) -> Eff es a
    finallyCase thing after = do
        tryError @e thing >>= \case
            Left (_, e) -> do
                recordException e mempty
                void . tryError @e . uninterruptibleMask_ . after $ ExitFailure e
                throwError e
            Right y -> do
                void . tryError @e . uninterruptibleMask_ . after $ ExitSuccess
                pure y

-- | Add an event to the current span. This is a no-op if not called in a span.
--
-- https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/api.md#add-events
addEvent :: (Tracing :> es) => Event -> Eff es ()
addEvent event = do
    tracing <- getStaticRep
    case tracing.currentSpan of
        Nothing -> pure ()
        Just context ->
            putStaticRep $ tracing{currentSpan = Just context, events = tracing.events |> event}

-- | Add an event that occurred at the current time to the current span. This is a no-op if not called in a span.
--
-- https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/api.md#add-events
addEventNow :: (Tracing :> es) => Text -> Attributes -> Eff es ()
addEventNow name attributes = addEvent =<< unsafeEff_ (Event.now name attributes)

-- | Add an exception to the current span. This is a no-op if not called in a span.
--
-- - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/api.md#record-exception
-- - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/exceptions.md
recordExceptionAt :: (Exception e, Tracing :> es) => Timestamp -> e -> Attributes -> Eff es ()
recordExceptionAt time e attributes = do
    stackTrace <- unsafeEff_ $ whoCreated e
    addEvent
        Event
            { time
            , name = "exception"
            , attributes =
                [ ("exception.type", toJSON . Text.pack . show $ typeOf e)
                , ("exception.message", toJSON . Text.pack $ displayException e)
                , ("exception.stacktrace", toJSON . Text.unlines $ Text.pack <$> stackTrace)
                ]
                    <> attributes
            }

-- | Add an exception event that occurred at the current time to the current span. This is a no-op if not called in a span.
--
-- - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/api.md#record-exception
-- - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.57.0/specification/trace/exceptions.md
recordException :: (Exception e, Tracing :> es) => e -> Attributes -> Eff es ()
recordException e attributes = do
    time <- unsafeEff_ Timestamp.now
    recordExceptionAt time e attributes

currentContext :: (Tracing :> es) => Eff es (Maybe Span.Context)
currentContext = do
    Tracing{..} <- getStaticRep
    pure currentSpan

currentContextIO :: (Tracing :> es) => Eff es (IO (Maybe Span.Context))
currentContextIO = unsafeEff $ pure . unEff currentContext

runTracingState
    :: (OTLP Span :> es, IOE :> es)
    => Eff (Tracing ': es) a
    -> Eff es a
runTracingState eff = do
    export <- exportIO
    evalStaticRep Tracing{currentSpan = Nothing, events = mempty, ..} eff

-- | Run the 'Tracing' effect, sending telemetry to an exporter.
-- Reads the configuration from <https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration the standard environment variables>.
-- Delegates to 'runNoTracing' if @OTEL_SDK_DISABLED = true@.
runTracing
    :: ( IOE :> es
       , Concurrent :> es
       , Environment :> es
       , Retry :> es
       , Timeout :> es
       )
    => Resource
    -> Scope
    -> Eff (Tracing ': es) a
    -> Eff es a
runTracing resource scope eff =
    ifM Environment.isSdkDisabled (runNoTracing eff) do
        exporter <- Environment.runConfigError $ Exporter.Environment.lookup @Span resource scope
        runTracingWith exporter eff

-- | Run the 'Tracing' effect, sending telemetry to a collector at the given 'URI'
-- over HTTP with the given 'Encoding'.
runHttpTracing
    :: (IOE :> es, Concurrent :> es, Retry :> es, Timeout :> es)
    => Resource
    -> Scope
    -> Encoding
    -> Compression
    -> URI
    -> Eff (Tracing ': es) a
    -> Eff es a
runHttpTracing resource scope encoding compression endpoint =
    runTracingWith $
        Exporter.http
            @Span
            resource
            scope
            encoding
            endpoint
            (Export.defaultConfig @Span)
            compression
            responseTimeoutDefault

-- | Run the 'Tracing' effect, sending telemetry to a gRPC collector.
runGrpcTracing
    :: (IOE :> es, Concurrent :> es, Retry :> es, Timeout :> es)
    => Resource
    -> Scope
    -> HostName
    -> PortNumber
    -> Compression
    -> Eff (Tracing ': es) a
    -> Eff es a
runGrpcTracing resource scope host port compression =
    runTracingWith $
        Exporter.grpc @Span
            resource
            scope
            host
            port
            (Export.exportGrpcRPC @Span)
            (Export.defaultConfig @Span)
            compression
            defaultGrpcSendTimeout

-- | Run the 'Tracing' effect, printing telemetry to the console rather than sending to a collector.
runConsoleTracing :: (IOE :> es) => Eff (Tracing ': es) a -> Eff es a
runConsoleTracing = runTracingWith Console.stdout

-- | Run the 'Tracing' effect, collecting telemetry in-memory rather than sending to a collector.
runInMemoryTracing
    :: (IOE :> es, Concurrent :> es)
    => Eff (Tracing ': es) a
    -> Eff es (a, [Span])
runInMemoryTracing = runInMemoryOTLP @Span . runTracingState . inject

-- | Run the 'Tracing' effect with a custom 'Exporter'.
-- Passes 'Span's to the sink synchronously as they are finalised, without batching or retrying.
runTracingWith
    :: (IOE :> es)
    => Exporter es Span
    -> Eff (Tracing ': es) a
    -> Eff es a
runTracingWith exporter = runOTLPWith exporter . runTracingState . inject

-- | Run the 'Tracing' effect as a no-op action.
runNoTracing
    :: (IOE :> es)
    => Eff (Tracing ': es) a
    -> Eff es a
runNoTracing = runNoOTLP @Span . runTracingState . inject