packages feed

effectful-tracing-0.1.0.0: src/Effectful/Tracing/Sampler.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Sampler
-- Description : Span sampling: decide at span-start whether to record a span.
-- Copyright   : (c) The effectful-tracing contributors
-- License     : BSD-3-Clause
-- Stability   : experimental
--
-- A 't:Sampler' decides, when a span starts, whether it is dropped, recorded but
-- not exported, or recorded and exported. The decision is made from the
-- 't:SamplerInput': the parent context, the trace id, and the span's name, kind,
-- initial attributes, and links.
--
-- == Why @shouldSample@ is plain @IO@
--
-- 'shouldSample' returns @'IO' 't:SamplingResult'@ rather than @'Effectful.Eff' es@.
-- The built-in samplers are pure or clock-only, so 'IO' is sufficient, and it
-- keeps 't:Sampler' a plain value that interpreters can hold without threading an
-- effect row through their configuration. The cost is that a user-written
-- sampler cannot use other effects (for example, reading configuration through
-- an effect). If that turns out to matter, the alternative is
-- @SamplerInput -> Eff es SamplingResult@ with the sampler parameterized over a
-- fixed effect row. For now the simpler 'IO' form is the default.
module Effectful.Tracing.Sampler
  ( -- * Decisions
    SamplingDecision (..)
  , SamplingResult (..)
  , simpleResult

    -- * Samplers
  , Sampler (..)
  , SamplerInput (..)

    -- * Built-in samplers
  , alwaysOn
  , alwaysOff
  , traceIdRatioBased
  , parentBased

    -- * Parent-based configuration
  , ParentBasedConfig (..)
  , defaultParentBasedConfig
  ) where

import Data.Bits (shiftL, (.|.))
import Data.ByteString qualified as BS
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word64)

import Effectful.Tracing.Attribute (Attribute)
import Effectful.Tracing.Internal.Ids (TraceId (TraceId))
import Effectful.Tracing.Internal.Types
  ( Link
  , SpanContext (spanContextIsRemote, spanContextTraceFlags)
  , SpanKind
  , TraceState
  , isSampled
  )

-- | What a sampler decides for a span.
data SamplingDecision
  = -- | Do not record the span at all.
    Drop
  | -- | Record the span locally, but do not export it. Useful for debugging.
    -- Interpreters without an export step treat this like 'RecordAndSample'.
    RecordOnly
  | -- | Record the span and export it; the @sampled@ trace flag is set.
    RecordAndSample
  deriving (Eq, Show, Enum, Bounded)

-- | A sampler's verdict: a 'SamplingDecision', any attributes the sampler wants
-- added to the span, and an optional replacement 't:TraceState'.
data SamplingResult = SamplingResult
  { decision :: !SamplingDecision
  , extraAttributes :: ![Attribute]
  , newTraceState :: !(Maybe TraceState)
  }
  deriving (Eq, Show)

-- | A 't:SamplingResult' carrying just a decision: no extra attributes, no
-- trace-state change.
simpleResult :: SamplingDecision -> SamplingResult
simpleResult d =
  SamplingResult {decision = d, extraAttributes = [], newTraceState = Nothing}

-- | The information a sampler sees about a span that is about to start.
data SamplerInput = SamplerInput
  { parentContext :: !(Maybe SpanContext)
  , traceId :: !TraceId
  , spanName :: !Text
  , spanKind :: !SpanKind
  , initialAttributes :: ![Attribute]
  , links :: ![Link]
  }

-- | A named sampling policy. 'shouldSample' is consulted once per span, at
-- start.
data Sampler = Sampler
  { samplerName :: !Text
  , shouldSample :: SamplerInput -> IO SamplingResult
  }

-- | Always record and sample every span.
alwaysOn :: Sampler
alwaysOn =
  Sampler
    { samplerName = "AlwaysOn"
    , shouldSample = \_ -> pure (simpleResult RecordAndSample)
    }

-- | Never record any span.
alwaysOff :: Sampler
alwaysOff =
  Sampler
    { samplerName = "AlwaysOff"
    , shouldSample = \_ -> pure (simpleResult Drop)
    }

-- | Sample a deterministic fraction of traces, keyed on the trace id, so every
-- span in a given trace gets the same decision. A ratio @<= 0@ drops
-- everything; @>= 1@ samples everything.
traceIdRatioBased :: Double -> Sampler
traceIdRatioBased ratio =
  Sampler
    { samplerName = "TraceIdRatioBased{" <> T.pack (show ratio) <> "}"
    , shouldSample = pure . simpleResult . decide . traceId
    }
  where
    decide tid
      | ratio <= 0 = Drop
      | ratio >= 1 = RecordAndSample
      | fromIntegral (traceIdHighBits tid) / twoToThe64 < ratio = RecordAndSample
      | otherwise = Drop
    twoToThe64 = fromIntegral (maxBound :: Word64) + 1 :: Double

-- | The high 8 bytes of a trace id as a big-endian 'Word64'.
traceIdHighBits :: TraceId -> Word64
traceIdHighBits (TraceId bs) =
  BS.foldl' (\acc b -> (acc `shiftL` 8) .|. fromIntegral b) 0 (BS.take 8 bs)

-- | How 'parentBased' decides, given the kind of parent a span has.
data ParentBasedConfig = ParentBasedConfig
  { rootSampler :: !Sampler
  -- ^ Used when the span has no parent.
  , remoteParentSampled :: !Sampler
  , remoteParentNotSampled :: !Sampler
  , localParentSampled :: !Sampler
  , localParentNotSampled :: !Sampler
  }

-- | The usual parent-based configuration: defer to a parent's @sampled@ flag
-- when there is a parent (sampled parent -> sample, unsampled parent -> drop,
-- for both local and remote parents), and use the given root sampler otherwise.
defaultParentBasedConfig :: Sampler -> ParentBasedConfig
defaultParentBasedConfig root =
  ParentBasedConfig
    { rootSampler = root
    , remoteParentSampled = alwaysOn
    , remoteParentNotSampled = alwaysOff
    , localParentSampled = alwaysOn
    , localParentNotSampled = alwaysOff
    }

-- | Follow the parent span's sampling decision when there is a parent,
-- otherwise consult the configured root sampler. This is the recommended
-- default policy: it keeps a trace's spans consistently sampled or dropped.
parentBased :: ParentBasedConfig -> Sampler
parentBased config =
  Sampler
    { samplerName = "ParentBased{" <> samplerName (rootSampler config) <> "}"
    , shouldSample = \input -> case parentContext input of
        Nothing -> shouldSample (rootSampler config) input
        Just pc -> shouldSample (chooseFor pc) input
    }
  where
    chooseFor pc = case (spanContextIsRemote pc, isSampled (spanContextTraceFlags pc)) of
      (True, True) -> remoteParentSampled config
      (True, False) -> remoteParentNotSampled config
      (False, True) -> localParentSampled config
      (False, False) -> localParentNotSampled config