packages feed

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

module Effectful.OpenTelemetry.Tracing.Span.Context where

import Control.Monad ((>=>))
import Data.Aeson qualified as Aeson
import Data.ByteString (ByteString)
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Builder.Extra qualified as Builder
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Either.Extra (eitherToMaybe)
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Effectful.OpenTelemetry.Tracing.Span.ID qualified as Span (ID)
import Effectful.OpenTelemetry.Tracing.Span.ID qualified as Span.ID
import Effectful.OpenTelemetry.Tracing.Trace.Flags qualified as Trace (Flags)
import Effectful.OpenTelemetry.Tracing.Trace.Flags qualified as Trace.Flags
import Effectful.OpenTelemetry.Tracing.Trace.ID qualified as Trace (ID)
import Effectful.OpenTelemetry.Tracing.Trace.ID qualified as Trace.ID
import Effectful.OpenTelemetry.Tracing.Trace.State qualified as Trace (State)
import GHC.Generics (Generic)
import Prelude

-- | A propagation mechanism that carries execution-scoped values across API boundaries.
--
-- See the <https://opentelemetry.io/docs/concepts/signals/traces/#span-context OpenTelemetry spec>.
data Context = Context
    { traceId :: Trace.ID
    , spanId :: Span.ID
    , traceFlags :: Trace.Flags
    , traceState :: Trace.State
    }
    deriving stock (Generic, Eq, Show)

-- | Create a new 'Context' with an optional parent and a new span 'Span.ID'.
-- If a parent is given, the new 'Context' inherits the parent's trace 'Trace.ID', 'Trace.Flags',
-- and 'Trace.State'.
-- Otherwise it gets a fresh trace 'Trace.ID', the 'Trace.Flags.sampled' and 'Trace.Flags.random'
-- trace 'Trace.Flags', and empty trace 'Trace.State'.
new :: Maybe Context -> IO Context
new parent = do
    traceId <- maybe Trace.ID.new (pure . traceId) parent
    spanId <- Span.ID.new
    pure
        Context
            { traceFlags =
                let flags =
                        maybe
                            mempty
                                { Trace.Flags.sampled = True
                                , Trace.Flags.random = True
                                }
                            traceFlags
                            parent
                 in flags{Trace.Flags.remote = Trace.Flags.IsNotRemote}
            , traceState = maybe mempty traceState parent
            , ..
            }

-- | Decode a 'Context' with an empty 'Trace.State' from a @traceparent@ header.
fromTraceparent :: ByteString -> Maybe Context
fromTraceparent =
    eitherToMaybe . Text.decodeUtf8' >=> \t ->
        case Aeson.String <$> Text.splitOn "-" t of
            [ "00"
                , Aeson.fromJSON -> Aeson.Success traceId
                , Aeson.fromJSON -> Aeson.Success spanId
                , Aeson.fromJSON -> Aeson.Success traceFlags
                ] ->
                    Just
                        Context
                            { traceState = mempty
                            , traceFlags = traceFlags{Trace.Flags.remote = Trace.Flags.IsRemote}
                            , ..
                            }
            _ -> Nothing

-- | Encode a 'Context' as a @traceparent@ header.
-- Drops the 'traceState'.
toTraceparent :: Context -> ByteString
toTraceparent Context{..} =
    LazyByteString.toStrict
        . Builder.toLazyByteStringWith (Builder.untrimmedStrategy 64 64) mempty
        . mconcat
        . List.intersperse "-"
        $ [ "00"
          , Builder.byteStringHex $ Trace.ID.toBytes traceId
          , Builder.byteStringHex $ Span.ID.toBytes spanId
          , Builder.word8HexFixed $ Trace.Flags.toWord8 traceFlags
          ]