atelier-core-0.1.0.0: src/Atelier/Effects/Monitoring/Tracing.hs
-- | Tracing effect for distributed tracing with OpenTelemetry.
--
-- Provides operations for creating spans, adding attributes, and propagating trace context.
--
-- == Basic Usage
--
-- @
-- myHandler :: (Tracing :> es) => Eff es ()
-- myHandler = do
-- withSpan "api.create_user" $ do
-- addAttribute "user.id" "123"
-- -- ... do work ...
-- setStatus Ok
-- @
module Atelier.Effects.Monitoring.Tracing
( -- * Effect
Tracing (..)
-- * Span Operations
, withSpan
, withSpanLinked
, withLinkPropagation
, addAttribute
, addEvent
, setStatus
, getSpanContext
, OT.ToAttribute (..)
, Attr (..)
, ToAttributeShow (..)
-- * Interpreters
, runTracing
, runTracingFromConfig
, runTracingNoOp
-- * Configuration
, TracingConfig (..)
-- * Re-exports
, SpanStatus (..)
, SpanContext
) where
import Data.Aeson (FromJSON, ToJSON)
import Data.Default (Default (..))
import Effectful (Effect, IOE, Limit (..), Persistence (..), UnliftStrategy (..))
import Effectful.Dispatch.Dynamic (interposeWith, interpret, interpretWith, localSeqUnlift, localUnlift)
import Effectful.Exception (bracket, onException)
import Effectful.Reader.Static (Reader, ask)
import Effectful.TH (makeEffect)
import Data.HashMap.Strict qualified as HashMap
import OpenTelemetry.Context qualified as Context
import OpenTelemetry.Context.ThreadLocal qualified as ThreadLocal
import OpenTelemetry.Trace qualified as OT
import OpenTelemetry.Trace.Core qualified as OT
import Atelier.Effects.Timeout (Timeout, timeout)
import Atelier.Time (Second)
import Atelier.Types.QuietSnake (QuietSnake (..))
import Atelier.Types.WithDefaults (WithDefaults (..))
import Atelier.Effects.Monitoring.Tracing.Provider qualified as Provider
-- | Tracing configuration for OpenTelemetry
data TracingConfig = TracingConfig
{ enabled :: Bool
-- ^ Enable tracing
, serviceName :: Text
-- ^ Service name for traces
, otlpEndpoint :: Text
-- ^ OTLP endpoint (e.g., "http://localhost:4318")
}
deriving stock (Eq, Generic, Show)
deriving (ToJSON) via QuietSnake TracingConfig
deriving (FromJSON) via WithDefaults (QuietSnake TracingConfig)
instance Default TracingConfig where
def =
TracingConfig
{ enabled = False
, serviceName = "hoard"
, otlpEndpoint = "http://localhost:4318"
}
-- | Span status for indicating success or failure
data SpanStatus
= -- | The operation completed successfully.
Ok
| -- | The operation failed, with an explanatory message.
Error Text
deriving stock (Eq, Show)
-- | Opaque span context for correlating traces with metrics
type SpanContext = OT.SpanContext
-- | Tracing effect for distributed tracing
data Tracing :: Effect where
-- | Execute an action within a named span (bracket-style, automatic cleanup)
WithSpan :: Text -> m a -> Tracing m a
-- | Execute an action within a named span with links to other span contexts
WithSpanLinked :: Text -> [SpanContext] -> m a -> Tracing m a
-- | Add an attribute to the current span
AddAttribute :: (OT.ToAttribute attr) => Text -> attr -> Tracing m ()
-- | Add an event to the current span
AddEvent :: (OT.ToAttribute attr) => Text -> [(Text, attr)] -> Tracing m ()
-- | Set the status of the current span
SetStatus :: SpanStatus -> Tracing m ()
-- | Get the current span context (for exemplars)
GetSpanContext :: Tracing m (Maybe SpanContext)
-- | Get the current OpenTelemetry context (internal use)
GetCurrentContext :: Tracing m Context.Context
-- | Useful to create a heterogeneous list of attribute values. These two are equivalent:
--
-- @
-- addEvent "foo" [OT.toAttribute 1, OT.toAttribute "foo"]
-- addEvent "foo" [Attr 1, Attr "foo"]
-- @
data Attr where
Attr :: (OT.ToAttribute a) => a -> Attr
instance OT.ToAttribute Attr where
toAttribute (Attr a) = OT.toAttribute a
-- | Wrapper that turns any 'Show'able value into an OpenTelemetry attribute via
-- its 'Show' instance. Derive an attribute instance @via 'ToAttributeShow' T@,
-- or wrap a value directly.
newtype ToAttributeShow a = ToAttributeShow
{ getToAttributeShow :: a
}
instance (Show a) => OT.ToPrimitiveAttribute (ToAttributeShow a) where
toPrimitiveAttribute = OT.TextAttribute . show . getToAttributeShow
instance (Show a) => OT.ToAttribute (ToAttributeShow a)
makeEffect ''Tracing
-- | Run an action with automatic span link propagation for fire-and-forget forks.
--
-- Any span created with no current parent (i.e., a root span) will automatically
-- receive a link to @parentCtx@. Nested spans inside those are unaffected — they
-- already have a parent and follow normal child semantics.
--
-- This avoids the trace growth problem caused by parent-child propagation in
-- long-running loops: each loop iteration's work becomes its own trace, linked
-- back to the originating span rather than piling spans onto a single trace.
withLinkPropagation :: (Tracing :> es) => Maybe SpanContext -> Eff es a -> Eff es a
withLinkPropagation Nothing action = action
withLinkPropagation (Just parentSpanCtx) action =
interposeWith action $ \env -> \case
WithSpan name m -> do
currentCtx <- getCurrentContext
localUnlift env (ConcUnlift Persistent Unlimited) $ \unlift ->
case Context.lookupSpan currentCtx of
Nothing -> withSpanLinked name [parentSpanCtx] (unlift m)
Just _ -> withSpan name (unlift m)
WithSpanLinked name ctxs m ->
localUnlift env (ConcUnlift Persistent Unlimited) $ \unlift ->
withSpanLinked name ctxs (unlift m)
AddAttribute key val -> addAttribute key val
AddEvent name attrs -> addEvent name attrs
SetStatus status -> setStatus status
GetSpanContext -> getSpanContext
GetCurrentContext -> getCurrentContext
-- | Run the Tracing effect with OpenTelemetry
--
-- Initializes the tracer provider and manages span lifecycle.
runTracing
:: (IOE :> es, Timeout :> es)
=> Bool
-- ^ Tracing enabled flag
-> Text
-- ^ Service name
-> Text
-- ^ OTLP endpoint
-> Eff (Tracing : es) a
-> Eff es a
runTracing enabled serviceName otlpEndpoint action
| not enabled = runTracingNoOp action
| otherwise =
bracket
(liftIO $ Provider.initTracingState serviceName otlpEndpoint)
(\tracingState -> void $ timeout (3 :: Second) $ liftIO $ Provider.shutdownTracingState tracingState)
$ \tracingState -> interpretWith action $ \env -> \case
WithSpan spanName innerAction -> localSeqUnlift env $ \unlift -> do
currentCtx <- liftIO ThreadLocal.getContext
newSpan <- liftIO $ OT.createSpan tracingState.tracer currentCtx spanName OT.defaultSpanArguments
let newCtx = Context.insertSpan newSpan currentCtx
oldCtx <- liftIO $ ThreadLocal.attachContext newCtx
innerResult <-
unlift innerAction `onException` do
liftIO $ OT.setStatus newSpan (OT.Error "Exception occurred")
-- Restore the old context
liftIO $ void $ case oldCtx of
Just ctx -> ThreadLocal.attachContext ctx
Nothing -> ThreadLocal.detachContext
liftIO $ OT.endSpan newSpan Nothing
pure innerResult
WithSpanLinked spanName linkedContexts innerAction -> localSeqUnlift env $ \unlift -> do
currentCtx <- liftIO ThreadLocal.getContext
let spanArgs =
OT.defaultSpanArguments
{ OT.links = map (\ctx -> OT.NewLink ctx mempty) linkedContexts
}
newSpan <- liftIO $ OT.createSpan tracingState.tracer currentCtx spanName spanArgs
let newCtx = Context.insertSpan newSpan currentCtx
oldCtx <- liftIO $ ThreadLocal.attachContext newCtx
innerResult <-
unlift innerAction `onException` do
liftIO $ OT.setStatus newSpan (OT.Error "Exception occurred")
-- Restore the old context
liftIO $ void $ case oldCtx of
Just ctx -> ThreadLocal.attachContext ctx
Nothing -> ThreadLocal.detachContext
liftIO $ OT.endSpan newSpan Nothing
pure innerResult
AddAttribute key value -> do
currentCtx <- liftIO ThreadLocal.getContext
case Context.lookupSpan currentCtx of
Just currentSpan ->
liftIO $ OT.addAttribute currentSpan key (OT.toAttribute value)
Nothing ->
-- No active span, ignore
pure ()
AddEvent eventName attributes -> do
currentCtx <- liftIO ThreadLocal.getContext
case Context.lookupSpan currentCtx of
Just currentSpan -> do
let attrMap = HashMap.fromList $ map (\(k, v) -> (k, OT.toAttribute v)) attributes
let event = OT.NewEvent eventName attrMap Nothing
liftIO $ OT.addEvent currentSpan event
Nothing ->
-- No active span, ignore
pure ()
SetStatus status -> do
currentCtx <- liftIO ThreadLocal.getContext
case Context.lookupSpan currentCtx of
Just currentSpan ->
liftIO $ case status of
Ok -> OT.setStatus currentSpan OT.Ok
Error msg -> OT.setStatus currentSpan (OT.Error msg)
Nothing ->
-- No active span, ignore
pure ()
GetSpanContext -> do
currentCtx <- liftIO ThreadLocal.getContext
case Context.lookupSpan currentCtx of
Just currentSpan -> do
spanCtx <- liftIO $ OT.getSpanContext currentSpan
pure $ Just spanCtx
Nothing -> pure Nothing
GetCurrentContext -> liftIO ThreadLocal.getContext
-- | Run the Tracing effect with config from Reader
--
-- Convenience wrapper that reads TracingConfig from the Reader effect.
runTracingFromConfig
:: (IOE :> es, Reader TracingConfig :> es, Timeout :> es)
=> Eff (Tracing : es) a
-> Eff es a
runTracingFromConfig action = do
TracingConfig {enabled, serviceName, otlpEndpoint} <- ask
runTracing enabled serviceName otlpEndpoint action
-- | No-op interpreter that discards all tracing operations
runTracingNoOp :: Eff (Tracing : es) a -> Eff es a
runTracingNoOp = interpret $ \env -> \case
WithSpan _ act -> localSeqUnlift env $ \unlift -> unlift act
WithSpanLinked _ _ act -> localSeqUnlift env $ \unlift -> unlift act
AddAttribute _ _ -> pure ()
AddEvent _ _ -> pure ()
SetStatus _ -> pure ()
GetSpanContext -> pure Nothing
GetCurrentContext -> pure Context.empty