effectful-tracing-0.1.0.0: test/Effectful/Tracing/SamplerSpec.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.SamplerSpec
-- Description : Tests for the built-in samplers and their interpreter integration.
--
-- The samplers are tested directly through 'shouldSample' (the decision is the
-- whole contract), and the in-memory interpreter is used to confirm that a
-- 'Drop' omits a span, that 'RecordOnly' captures without setting the sampled
-- flag, and that 'RecordAndSample' captures and flags.
module Effectful.Tracing.SamplerSpec
( tests
) where
import Control.Monad (replicateM)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Effectful (Eff, IOE, runEff)
import Hedgehog (Property, evalIO, forAll, property, (===))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Test.Tasty.Hedgehog (testProperty)
import Effectful.Tracing (Tracer, withSpan)
import Effectful.Tracing.Attribute (attributeKey, (.=))
import Effectful.Tracing.Gen (genTraceId)
import Effectful.Tracing.Internal.Ids
( SpanId
, TraceId
, newTraceId
, spanIdFromHex
, traceIdFromHex
)
import Effectful.Tracing.Internal.Types
( Span (spanAttributes, spanContext)
, SpanContext (..)
, SpanKind (Internal)
, TraceState
, defaultTraceFlags
, emptyTraceState
, insertTraceState
, isSampled
, setSampled
)
import Effectful.Tracing.Interpreter.InMemory
( newCapturedSpans
, readCapturedSpans
, runTracerInMemoryWith
)
import Effectful.Tracing.Sampler
( Sampler (Sampler, shouldSample)
, SamplerInput (SamplerInput)
, SamplingDecision (Drop, RecordAndSample, RecordOnly)
, SamplingResult (SamplingResult, decision)
, alwaysOff
, alwaysOn
, defaultParentBasedConfig
, parentBased
, simpleResult
, traceIdRatioBased
)
tests :: TestTree
tests =
testGroup
"Sampling"
[ testGroup
"built-in decisions"
[ testCase "alwaysOn records and samples; alwaysOff drops" $ do
decisionOf alwaysOn (input Nothing) >>= (@?= RecordAndSample)
decisionOf alwaysOff (input Nothing) >>= (@?= Drop)
, testProperty "traceIdRatioBased 1.0 always samples" prop_ratioOneSamples
, testProperty "traceIdRatioBased 0.0 never samples" prop_ratioZeroDrops
, testProperty "traceIdRatioBased is deterministic per trace id" prop_ratioDeterministic
, testCase "traceIdRatioBased 0.5 samples about half of many trace ids" $ do
ids <- replicateM sampleCount newTraceId
let half = traceIdRatioBased 0.5
sampled <-
length . filter (== RecordAndSample)
<$> traverse (decisionOf half . inputFor Nothing) ids
let fraction = fromIntegral sampled / fromIntegral sampleCount :: Double
assertBool
("sampled fraction " <> show fraction <> " should be near 0.5")
(abs (fraction - 0.5) < 0.03)
, testCase "parentBased follows the parent and falls back to the root" $ do
-- root is alwaysOff, so a sampled parent decision can only come from
-- inheritance, not the root sampler.
let s = parentBased (defaultParentBasedConfig alwaysOff)
decisionOf s (input Nothing) >>= (@?= Drop)
decisionOf s (input (Just (parentContext True False))) >>= (@?= RecordAndSample)
decisionOf s (input (Just (parentContext False False))) >>= (@?= Drop)
decisionOf s (input (Just (parentContext True True))) >>= (@?= RecordAndSample)
decisionOf s (input (Just (parentContext False True))) >>= (@?= Drop)
]
, testGroup
"interpreter integration"
[ testCase "alwaysOff captures no spans" $ do
spans <- captureWith alwaysOff twoSpans
spans @?= []
, testCase "traceIdRatioBased 0.0 captures no spans" $ do
spans <- captureWith (traceIdRatioBased 0.0) twoSpans
spans @?= []
, testCase "alwaysOn captures every span and flags it sampled" $ do
spans <- captureWith alwaysOn twoSpans
length spans @?= 2
assertBool "every captured span is flagged sampled" (all sampledFlag spans)
, testCase "RecordOnly captures spans but does not flag them sampled" $ do
spans <- captureWith recordOnly twoSpans
length spans @?= 2
assertBool "no captured span is flagged sampled" (not (any sampledFlag spans))
, testCase "sampler extra attributes are attached to the captured span" $ do
spans <- captureWith extraAttrSampler (withSpan "s" (pure ()))
s <- single spans
assertBool
"the sampler-supplied attribute is present"
(any ((== "sampler.tag") . attributeKey) (spanAttributes s))
, testCase "sampler trace-state replacement is applied to the span context" $ do
spans <- captureWith stateSampler (withSpan "s" (pure ()))
s <- single spans
spanContextTraceState (spanContext s) @?= samplerState
]
]
-- Properties ----------------------------------------------------------------
prop_ratioOneSamples :: Property
prop_ratioOneSamples = property $ do
t <- forAll genTraceId
d <- evalIO (decisionOf (traceIdRatioBased 1.0) (inputFor Nothing t))
d === RecordAndSample
prop_ratioZeroDrops :: Property
prop_ratioZeroDrops = property $ do
t <- forAll genTraceId
d <- evalIO (decisionOf (traceIdRatioBased 0.0) (inputFor Nothing t))
d === Drop
prop_ratioDeterministic :: Property
prop_ratioDeterministic = property $ do
t <- forAll genTraceId
let s = traceIdRatioBased 0.37
d1 <- evalIO (decisionOf s (inputFor Nothing t))
d2 <- evalIO (decisionOf s (inputFor Nothing t))
d1 === d2
-- Programs and interpreter helpers ------------------------------------------
twoSpans :: Eff '[Tracer, IOE] ()
twoSpans = withSpan "outer" (withSpan "inner" (pure ()))
captureWith :: Sampler -> Eff '[Tracer, IOE] () -> IO [Span]
captureWith sampler prog = do
captured <- runEff newCapturedSpans
runEff (runTracerInMemoryWith sampler captured prog)
runEff (readCapturedSpans captured)
recordOnly :: Sampler
recordOnly = Sampler "RecordOnly" (\_ -> pure (simpleResult RecordOnly))
-- | A sampler that records and tags every span with a fixed attribute.
extraAttrSampler :: Sampler
extraAttrSampler =
Sampler "WithExtraAttribute" $ \_ ->
pure (SamplingResult RecordAndSample ["sampler.tag" .= ("set" :: Text)] Nothing)
-- | A sampler that records and replaces the trace state with 'samplerState'.
stateSampler :: Sampler
stateSampler =
Sampler "WithTraceState" $ \_ ->
pure (SamplingResult RecordAndSample [] (Just samplerState))
-- | The trace state 'stateSampler' installs.
samplerState :: TraceState
samplerState = fromMaybe emptyTraceState (insertTraceState "vendor" "1" emptyTraceState)
single :: [Span] -> IO Span
single [s] = pure s
single other = assertFailure ("expected exactly one span, got " <> show (length other))
sampledFlag :: Span -> Bool
sampledFlag = isSampled . spanContextTraceFlags . spanContext
-- Sampler helpers -----------------------------------------------------------
sampleCount :: Int
sampleCount = 20000
decisionOf :: Sampler -> SamplerInput -> IO SamplingDecision
decisionOf sampler = fmap decision . shouldSample sampler
input :: Maybe SpanContext -> SamplerInput
input parent = inputFor parent fixedTraceId
inputFor :: Maybe SpanContext -> TraceId -> SamplerInput
inputFor parent traceId = SamplerInput parent traceId "op" Internal [] []
parentContext :: Bool -> Bool -> SpanContext
parentContext sampled remote =
SpanContext
{ spanContextTraceId = fixedTraceId
, spanContextSpanId = fixedSpanId
, spanContextTraceFlags = setSampled sampled defaultTraceFlags
, spanContextTraceState = emptyTraceState
, spanContextIsRemote = remote
}
fixedTraceId :: TraceId
fixedTraceId = unsafeHex traceIdFromHex "4f1a9c000000000000000000000000aa"
fixedSpanId :: SpanId
fixedSpanId = unsafeHex spanIdFromHex "0000000000000001"
unsafeHex :: (t -> Maybe a) -> t -> a
unsafeHex parse raw = fromMaybe (error "bad fixture id") (parse raw)