effectful-tracing-0.1.0.0: src/Effectful/Tracing/Effect.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}
-- |
-- Module : Effectful.Tracing.Effect
-- Description : The @Tracer@ effect and its smart-constructor API.
-- Copyright : (c) The effectful-tracing contributors
-- License : BSD-3-Clause
-- Stability : experimental
--
-- The 'Tracer' effect, modeled as a dynamic @effectful@ effect because tracing
-- has many valid interpretations (no-op, in-memory, OpenTelemetry export). The
-- only higher-order operation is 'WithSpan', which runs a scoped action inside
-- a fresh child span; the rest are first-order emit operations against the
-- currently-active span.
--
-- This module exposes the raw effect constructors so interpreters can pattern
-- match on them. End users should program against the smart constructors
-- ('withSpan', 'addAttribute', and friends), which hide @send@ and the
-- constructor types. The public "Effectful.Tracing" module re-exports the smart
-- constructors and 'Tracer' as an abstract type.
module Effectful.Tracing.Effect
( -- * The effect
Tracer (..)
-- * Span arguments
, SpanArguments (..)
, defaultSpanArguments
-- * Scoping a span
, withSpan
, withSpan'
, withLinkedRoot
, withRemoteParent
-- * Annotating the active span
, addAttribute
, addAttributes
, addEvent
, recordException
, setStatus
, updateName
, getActiveSpan
-- * Status transitions
, transitionStatus
) where
import Control.Exception (SomeException)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import GHC.Stack (HasCallStack)
import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>))
import Effectful.Dispatch.Dynamic (send)
import Effectful.Tracing.Attribute (Attribute, AttributeValue, ToAttributeValue, toAttributeValue)
import Effectful.Tracing.Internal.Types
( Link
, SpanContext
, SpanKind (Internal)
, SpanStatus (..)
)
-- | Tracing as a scoped effect. 'WithSpan' is higher-order: it runs the scoped
-- action @m a@ inside a child span whose parent is the lexically-current active
-- span. 'WithLinkedRoot' is also higher-order: it detaches the active span for
-- its scope (so a nested 'WithSpan' starts a new root trace) while recording
-- links to the spans that caused the work. 'WithRemoteParent' is the inbound
-- counterpart: it makes a remote span context the active parent for its scope,
-- so a nested 'WithSpan' continues that remote trace. The remaining operations
-- annotate the active span and are no-ops when there is none (see
-- "Effectful.Tracing.Effect#no-active-span").
data Tracer :: Effect where
WithSpan :: Text -> SpanArguments -> m a -> Tracer m a
WithLinkedRoot :: [Link] -> m a -> Tracer m a
WithRemoteParent :: SpanContext -> m a -> Tracer m a
AddAttribute :: Text -> AttributeValue -> Tracer m ()
AddAttributes :: [Attribute] -> Tracer m ()
AddEvent :: Text -> [Attribute] -> Tracer m ()
RecordException :: SomeException -> Tracer m ()
SetStatus :: SpanStatus -> Tracer m ()
UpdateName :: Text -> Tracer m ()
GetActiveSpan :: Tracer m (Maybe SpanContext)
type instance DispatchOf Tracer = Dynamic
-- | Optional parameters for a new span. A record (rather than positional
-- arguments) so new fields can be added without breaking callers, who build it
-- from 'defaultSpanArguments' with record updates.
data SpanArguments = SpanArguments
{ kind :: !SpanKind
-- ^ The span's role in the trace. Defaults to 'Internal'.
, attributes :: ![Attribute]
-- ^ Attributes to attach at span start.
, links :: ![Link]
-- ^ Links to other spans (causal references outside the parent/child tree).
, startTime :: !(Maybe UTCTime)
-- ^ An explicit start time; 'Nothing' means "now" at the moment the span
-- opens.
}
-- | The default arguments: an 'Internal' span with no initial attributes or
-- links and an implicit "now" start time.
--
-- > withSpan' "db.query" defaultSpanArguments { kind = Client } $ do
-- > ...
defaultSpanArguments :: SpanArguments
defaultSpanArguments =
SpanArguments
{ kind = Internal
, attributes = []
, links = []
, startTime = Nothing
}
-- | Run an action inside a new child span with the given name and default
-- arguments. The span opens before the action runs and is closed (with its end
-- time, and 'Error' status if the action throws) when it returns or unwinds.
--
-- > total :: Tracer :> es => Eff es Int
-- > total = withSpan "compute.total" $ do
-- > addAttribute "items" (3 :: Int)
-- > pure 6
withSpan
:: (HasCallStack, Tracer :> es)
=> Text
-> Eff es a
-> Eff es a
withSpan name = withSpan' name defaultSpanArguments
-- | 'withSpan' with explicit 't:SpanArguments'.
--
-- > fetch :: Tracer :> es => Eff es Response
-- > fetch = withSpan' "http.get" defaultSpanArguments { kind = Client } $ do
-- > ...
withSpan'
:: (HasCallStack, Tracer :> es)
=> Text
-> SpanArguments
-> Eff es a
-> Eff es a
withSpan' name args action = send (WithSpan name args action)
-- | Run an action detached from the current trace, recording the given links as
-- "caused by" references. Inside the scope there is no active span, so the
-- first 'withSpan' opens a new __root__ span (a new trace) rather than a child
-- of the enclosing span; the links are attached to that root. This expresses a
-- causal relationship for work that is triggered by, but does not belong to,
-- the current trace, such as fire-and-forget background tasks.
--
-- "Effectful.Tracing.Concurrent" builds @forkLinked@ on this: it captures the
-- current span context as a link and forks the body inside a linked root scope.
--
-- > withLinkedRoot [Link callerContext []] $
-- > withSpan "background.reindex" $ ...
withLinkedRoot
:: (HasCallStack, Tracer :> es)
=> [Link]
-> Eff es a
-> Eff es a
withLinkedRoot links action = send (WithLinkedRoot links action)
-- | Run an action as if it were a child of the given remote span. Inside the
-- scope the active span is the remote context, so the first 'withSpan'
-- continues that trace: it inherits the remote trace id and sampled flag and
-- records the remote span as its parent. This is how an inbound request rejoins
-- a distributed trace; pair it with 'Effectful.Tracing.Propagation.extractContext'
-- to turn @traceparent@ / @tracestate@ headers into the 't:SpanContext'.
--
-- The remote span is not local and is never emitted: this only sets the parent
-- for spans opened within the scope. Emit operations issued directly in the
-- scope (outside any 'withSpan') have no local span to annotate and are
-- dropped.
--
-- > withRemoteParent inbound $
-- > withSpan' "handle.request" defaultSpanArguments { kind = Server } $ ...
withRemoteParent
:: (HasCallStack, Tracer :> es)
=> SpanContext
-> Eff es a
-> Eff es a
withRemoteParent context action = send (WithRemoteParent context action)
-- | Attach a single attribute to the active span. No-op if there is no active
-- span.
--
-- > addAttribute "user.id" ("u123" :: Text)
addAttribute
:: (HasCallStack, Tracer :> es, ToAttributeValue v)
=> Text
-> v
-> Eff es ()
addAttribute key value = send (AddAttribute key (toAttributeValue value))
-- | Attach several attributes to the active span. No-op if there is no active
-- span.
--
-- > addAttributes ["http.method" .= ("GET" :: Text), "http.status" .= (200 :: Int)]
addAttributes :: (HasCallStack, Tracer :> es) => [Attribute] -> Eff es ()
addAttributes = send . AddAttributes
-- | Record a timestamped event on the active span. No-op if there is no active
-- span.
--
-- > addEvent "cache.miss" ["key" .= ("session:42" :: Text)]
addEvent :: (HasCallStack, Tracer :> es) => Text -> [Attribute] -> Eff es ()
addEvent name attrs = send (AddEvent name attrs)
-- | Record an exception as an event on the active span. This does not set the
-- span's status; pair it with 'setStatus' if the exception is fatal. No-op if
-- there is no active span.
--
-- > recordException (toException err)
recordException :: (HasCallStack, Tracer :> es) => SomeException -> Eff es ()
recordException = send . RecordException
-- | Set the active span's status, following the OpenTelemetry transition rules
-- (see 'transitionStatus'). No-op if there is no active span.
--
-- > setStatus (Error "upstream timeout")
setStatus :: (HasCallStack, Tracer :> es) => SpanStatus -> Eff es ()
setStatus = send . SetStatus
-- | Replace the active span's name. This is OpenTelemetry's @Span.updateName@:
-- useful when the final, low-cardinality name is only known after the span has
-- opened, such as a server span that learns its matched route template once the
-- routing layer has run. No-op if there is no active span.
--
-- > updateName "GET /users/{id}"
updateName :: (HasCallStack, Tracer :> es) => Text -> Eff es ()
updateName = send . UpdateName
-- | The active span's context, or 'Nothing' if there is no active span.
--
-- > parent <- getActiveSpan
getActiveSpan :: (HasCallStack, Tracer :> es) => Eff es (Maybe SpanContext)
getActiveSpan = send GetActiveSpan
-- | Combine the current span status with a proposed one, per the OpenTelemetry
-- rules:
--
-- * 'Unset' is the default and may move to either 'Ok' or 'Error'.
-- * 'Error' may be overridden by 'Ok'.
-- * 'Ok' is final: any later transition is ignored.
-- * A status is never downgraded back to 'Unset'.
--
-- Every interpreter routes @setStatus@ through this single function so the
-- semantics stay consistent.
transitionStatus
:: SpanStatus
-- ^ current
-> SpanStatus
-- ^ proposed
-> SpanStatus
transitionStatus current proposed =
case current of
Ok -> Ok
_ -> case proposed of
Unset -> current
_ -> proposed