effectful-tracing-0.1.0.0: src/Effectful/Tracing/Internal/Live.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.Internal.Live
-- Description : Shared span-lifecycle handler for interpreters that open spans.
-- Copyright : (c) The effectful-tracing contributors
-- License : BSD-3-Clause
-- Stability : internal
--
-- Every interpreter that actually opens and closes spans (in-memory,
-- pretty-print, OpenTelemetry) shares the same lifecycle: the active span is
-- __lexical__ (carried in a private @'Reader' ('Maybe' ActiveSpan)@ installed
-- by 'reinterpret', and set for a child scope with 'local' around the unlifted
-- action), and span finalization runs inside 'generalBracket' so a span is
-- closed exactly once even when its action is killed by an asynchronous
-- exception.
--
-- 'interpretTracer' captures that lifecycle once and parameterizes the only
-- thing the interpreters differ on: what to do with a completed 't:Span'. The
-- sink is a plain @'t:Span' -> 'IO' ()@, which is all the in-memory (append to a
-- buffer), pretty-print (render a finished trace), and OpenTelemetry (hand to
-- an exporter) interpreters need.
--
-- This is an @.Internal.@ module: it carries no stability promise.
module Effectful.Tracing.Internal.Live
( interpretTracer
) where
import Control.Exception (SomeException, displayException)
import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Maybe (fromMaybe, isNothing)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (reinterpret)
import Effectful.Dispatch.Dynamic qualified as Dynamic
import Effectful.Exception (ExitCase (ExitCaseAbort, ExitCaseException), generalBracket)
import Effectful.Reader.Static (Reader, ask, local, runReader)
import Effectful.Tracing.Attribute (Attribute (Attribute), AttributeValue (AttrText))
import Effectful.Tracing.Effect
( SpanArguments (attributes, kind, links, startTime)
, Tracer
( AddAttribute
, AddAttributes
, AddEvent
, GetActiveSpan
, RecordException
, SetStatus
, UpdateName
, WithLinkedRoot
, WithRemoteParent
, WithSpan
)
, transitionStatus
)
import Effectful.Tracing.Internal.Clock (Timestamp (Timestamp), getTimestamp)
import Effectful.Tracing.Internal.Ids (newSpanId, newTraceId)
import Effectful.Tracing.Internal.Types
( Event (Event)
, Link
, Span (..)
, SpanContext (..)
, SpanKind (Internal)
, SpanStatus (Error, Unset)
, defaultTraceFlags
, emptyTraceState
, setSampled
)
import Effectful.Tracing.Sampler
( Sampler (shouldSample)
, SamplerInput (SamplerInput)
, SamplingDecision (Drop, RecordAndSample)
, SamplingResult (decision, extraAttributes, newTraceState)
)
import Effectful.Tracing.SemConv qualified as SemConv
import Effectful.Tracing.SpanLimits
( SpanLimits (attributeCountLimit, eventCountLimit)
, applySpanLimits
)
-- | The mutable, accumulating part of an in-flight span. Attributes and events
-- are stored newest-first and reversed when the span completes, so each emit is
-- an O(1) cons rather than an O(n) append. The name lives here too (rather than
-- in the immutable 't:ActiveSpan' identity) so 'UpdateName' can replace it. The
-- running counts let the count caps (see 'SpanLimits') be enforced as the span
-- records, in O(1) per emit, so an in-flight span cannot grow past its limit.
data SpanBuilder = SpanBuilder
{ builderName :: !Text
, builderAttributes :: ![Attribute]
, builderAttributeCount :: !Int
, builderEvents :: ![Event]
, builderEventCount :: !Int
, builderStatus :: !SpanStatus
}
-- | The lexically-active span, carried in the handler's private 'Reader'. Its
-- immutable identity travels alongside an 'IORef' to the accumulating builder
-- so emit operations can mutate the span they are nested inside.
data ActiveSpan = ActiveSpan
{ activeContext :: !SpanContext
, activeParent :: !(Maybe SpanContext)
, activeKind :: !SpanKind
, activeStart :: !Timestamp
, activeLinks :: ![Link]
, activeBuilder :: !(IORef SpanBuilder)
, activeDecision :: !SamplingDecision
}
-- | Interpret 'Tracer' by opening a real span for each scoped action and
-- handing every completed, non-dropped 't:Span' to the given sink.
--
-- The active span is lexical: scoped actions run inside a fresh child span
-- installed for their scope only, emit operations annotate the
-- lexically-current span (and are silent no-ops when there is none), and
-- 'GetActiveSpan' reports it. Finalization runs in 'generalBracket', so the
-- sink sees each span exactly once, with an 'Error' status if the action was
-- killed, and exceptions still propagate (the interpreter records, it does not
-- swallow).
--
-- The 't:Sampler' is consulted once per span, at start. A 'Drop' decision still
-- runs the scoped action (the user's code must execute) and still establishes a
-- lexical span for nested operations, but the completed span is not handed to
-- the sink; 'Effectful.Tracing.Sampler.RecordOnly' and 'RecordAndSample' both reach the sink. The
-- @sampled@ trace flag is set exactly when the decision is 'RecordAndSample',
-- and the sampler's extra attributes and trace-state replacement are applied to
-- the span.
interpretTracer
:: IOE :> es
=> SpanLimits
-- ^ The per-span caps to enforce as spans record and finalize.
-> Sampler
-> (Span -> IO ())
-- ^ What to do with each completed span. Runs on the thread that closed the
-- span; keep it cheap and non-blocking.
-> Eff (Tracer : es) a
-> Eff es a
interpretTracer limits sampler onComplete =
reinterpret (runReader (Nothing :: Maybe ActiveSpan) . runReader ([] :: [Link])) $ \env -> \case
WithSpan name args action -> do
parent <- ask
pending <- ask
active <- openSpan limits sampler name args parent pending
Dynamic.localSeqUnlift env $ \unlift -> do
-- Inside the span the active span is this one and the pending links have
-- been consumed (a child must not re-link to the cause of its root).
let use _ = local (const (Just active)) (local (const ([] :: [Link])) (unlift action))
(result, ()) <-
generalBracket
(pure ())
(\_ exitCase -> do
completed <- finalizeSpan limits active exitCase
when (activeDecision active /= Drop) (liftIO (onComplete completed)))
use
pure result
WithLinkedRoot newLinks action ->
-- Detach the active span so a nested WithSpan starts a new root, and stage
-- the links so that root picks them up.
Dynamic.localSeqUnlift env $ \unlift ->
local (const (Nothing :: Maybe ActiveSpan)) (local (const newLinks) (unlift action))
WithRemoteParent context action -> do
-- Make the remote context the active parent for the scope so a nested
-- WithSpan continues its trace. The remote span is not ours to emit, so it
-- carries a throwaway builder and is never finalized.
remote <- remoteActiveSpan context
Dynamic.localSeqUnlift env $ \unlift ->
local (const (Just remote)) (local (const ([] :: [Link])) (unlift action))
AddAttribute key value ->
withActive $ \active ->
liftIO (modifyIORef' (activeBuilder active) (pushAttributes limits [Attribute key value]))
AddAttributes attrs ->
withActive $ \active ->
liftIO (modifyIORef' (activeBuilder active) (pushAttributes limits attrs))
AddEvent name attrs ->
withActive $ \active -> do
now <- getTimestamp
liftIO (modifyIORef' (activeBuilder active) (pushEvent limits (Event name now attrs)))
RecordException err ->
withActive $ \active -> do
now <- getTimestamp
liftIO (modifyIORef' (activeBuilder active) (pushEvent limits (exceptionEvent now err)))
SetStatus status ->
withActive $ \active ->
liftIO (modifyIORef' (activeBuilder active) (applyStatus status))
UpdateName name ->
withActive $ \active ->
liftIO (modifyIORef' (activeBuilder active) (\b -> b {builderName = name}))
GetActiveSpan -> fmap activeContext <$> ask
-- | Run an action against the active span, or do nothing if there is none.
withActive
:: Reader (Maybe ActiveSpan) :> es
=> (ActiveSpan -> Eff es ())
-> Eff es ()
withActive f = ask >>= maybe (pure ()) f
-- | Build a fresh 't:ActiveSpan': inherit trace identity from the parent (or mint
-- a new trace at a root), consult the sampler, allocate a span id, set the
-- @sampled@ flag and any sampler-supplied attributes and trace state, record
-- the start time, and seed the builder.
openSpan
:: IOE :> es
=> SpanLimits
-> Sampler
-> Text
-> SpanArguments
-> Maybe ActiveSpan
-> [Link]
-- ^ Pending "caused by" links, attached only when this span is a root (a span
-- opened inside a 'WithLinkedRoot' scope); ignored for child spans.
-> Eff es ActiveSpan
openSpan limits sampler name args parent pendingLinks = do
-- Force the projected parent context (not just the @Maybe@ to WHNF). A lazy
-- @activeContext <$> parent@ leaves @Just (activeContext p)@ as a thunk that
-- retains the parent's entire 't:ActiveSpan' (and its builder 'IORef') inside
-- every completed child span. 'spanContextTraceId' etc. are strict, so
-- forcing to WHNF keeps only the small immutable context.
let parentContext = case parent of
Nothing -> Nothing
Just p -> Just $! activeContext p
spanLinks = links args <> if isNothing parentContext then pendingLinks else []
(traceId, baseFlags, baseState) <- case parentContext of
Just pc -> pure (spanContextTraceId pc, spanContextTraceFlags pc, spanContextTraceState pc)
Nothing -> do
traceId <- newTraceId
pure (traceId, defaultTraceFlags, emptyTraceState)
-- Built positionally: SamplerInput's field names (spanName, spanKind, links)
-- collide with Span and SpanArguments selectors, so its labels are not
-- imported here.
result <-
liftIO . shouldSample sampler $
SamplerInput parentContext traceId name (kind args) (attributes args) spanLinks
spanId <- newSpanId
let sampled = decision result == RecordAndSample
context =
SpanContext
{ spanContextTraceId = traceId
, spanContextSpanId = spanId
, spanContextTraceFlags = setSampled sampled baseFlags
, spanContextTraceState = fromMaybe baseState (newTraceState result)
, spanContextIsRemote = False
}
start <- maybe getTimestamp (pure . Timestamp) (startTime args)
-- Seed the attributes through the same count cap the emit path uses, so an
-- over-budget initial set (span arguments plus sampler extras) is bounded
-- here rather than only at finalization.
let initialAttributes = capCount (attributeCountLimit limits) (attributes args <> extraAttributes result)
builder <-
liftIO . newIORef $
SpanBuilder
{ builderName = name
, builderAttributes = reverse initialAttributes
, builderAttributeCount = length initialAttributes
, builderEvents = []
, builderEventCount = 0
, builderStatus = Unset
}
pure
ActiveSpan
{ activeContext = context
, activeParent = parentContext
, activeKind = kind args
, activeStart = start
, activeLinks = spanLinks
, activeBuilder = builder
, activeDecision = decision result
}
-- | A synthetic 't:ActiveSpan' standing in for a remote parent. It exists only to
-- be the parent context for spans opened inside a 'WithRemoteParent' scope: it
-- is never finalized or emitted, so its builder is a throwaway and its name and
-- kind are placeholders. The context is marked remote.
remoteActiveSpan :: IOE :> es => SpanContext -> Eff es ActiveSpan
remoteActiveSpan context = do
start <- getTimestamp
builder <- liftIO (newIORef (SpanBuilder "" [] 0 [] 0 Unset))
pure
ActiveSpan
{ activeContext = context {spanContextIsRemote = True}
, activeParent = Nothing
, activeKind = Internal
, activeStart = start
, activeLinks = []
, activeBuilder = builder
, activeDecision = RecordAndSample
}
-- | Finalize a span: record its end time, fold in an error status and exception
-- event if the action did not complete normally, and snapshot the builder into
-- an immutable 't:Span'. Runs inside 'generalBracket', so it fires exactly once.
finalizeSpan
:: IOE :> es
=> SpanLimits
-> ActiveSpan
-> ExitCase a
-> Eff es Span
finalizeSpan limits active exitCase = do
end <- getTimestamp
case exitCase of
ExitCaseException err ->
liftIO . modifyIORef' (activeBuilder active) $
applyStatus (Error (T.pack (displayException err)))
. pushEvent limits (exceptionEvent end err)
ExitCaseAbort ->
liftIO . modifyIORef' (activeBuilder active) $
applyStatus (Error "span aborted")
_ -> pure ()
builder <- liftIO (readIORef (activeBuilder active))
-- Force the immutable span to WHNF before it leaves the closing thread. With
-- 'StrictData' that realizes every field (the attribute and event lists are
-- reversed and so fully traversed), so a sink that stores the span (the
-- in-memory buffer, the pretty-print accumulator) holds a finished value
-- rather than a thunk retaining this span's builder 'IORef' and 't:ActiveSpan'.
-- 'applySpanLimits' is where the value-length truncation and the link cap land
-- (the count caps were already enforced as the span recorded).
pure $!
applySpanLimits limits $
Span
{ spanContext = activeContext active
, spanParentContext = activeParent active
, spanName = builderName builder
, spanKind = activeKind active
, spanStartTime = activeStart active
, spanEndTime = end
, spanAttributes = reverse (builderAttributes builder)
, spanEvents = reverse (builderEvents builder)
, spanLinks = activeLinks active
, spanStatus = builderStatus builder
}
-- | Prepend attributes to a builder (stored newest-first), keeping at most the
-- attribute-count limit. Only the entries that fit the remaining budget are
-- recorded (the earliest of the batch), so the running count never exceeds the
-- cap.
pushAttributes :: SpanLimits -> [Attribute] -> SpanBuilder -> SpanBuilder
pushAttributes limits attrs builder =
builder
{ builderAttributes = reverse kept <> builderAttributes builder
, builderAttributeCount = builderAttributeCount builder + length kept
}
where
kept = case attributeCountLimit limits of
Nothing -> attrs
Just n -> take (max 0 (n - builderAttributeCount builder)) attrs
-- | Prepend an event to a builder (stored newest-first), unless the event-count
-- limit has already been reached, in which case the event is dropped (the
-- earliest events are the ones kept).
pushEvent :: SpanLimits -> Event -> SpanBuilder -> SpanBuilder
pushEvent limits event builder =
case eventCountLimit limits of
Just n | builderEventCount builder >= n -> builder
_ ->
builder
{ builderEvents = event : builderEvents builder
, builderEventCount = builderEventCount builder + 1
}
-- | Keep at most @n@ of a list (the first @n@) when a cap is set; 'Nothing'
-- keeps everything. Used to bound the initial attribute set at span open.
capCount :: Maybe Int -> [a] -> [a]
capCount Nothing = id
capCount (Just n) = take (max 0 n)
-- | Apply a status transition (see 'transitionStatus').
applyStatus :: SpanStatus -> SpanBuilder -> SpanBuilder
applyStatus status builder =
builder {builderStatus = transitionStatus (builderStatus builder) status}
-- | An OpenTelemetry-style @exception@ event carrying the message.
exceptionEvent :: Timestamp -> SomeException -> Event
exceptionEvent time err =
Event "exception" time [Attribute SemConv.exceptionMessage (AttrText (T.pack (displayException err)))]