packages feed

effectful-tracing-0.1.0.0: test/Effectful/Tracing/TypesSpec.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.TypesSpec
-- Description : Unit and property tests for the pure span data model.
--
-- These cover the small total functions that the interpreters lean on but never
-- exercise in isolation: the OpenTelemetry status-transition rules, the W3C
-- trace-state insert/lookup invariants (dedup, capacity, key and value
-- validation), the @tracestate@ header parser's resilience, and the trace-flags
-- bit manipulation.
module Effectful.Tracing.TypesSpec
  ( tests
  ) where

import Data.Bits (testBit)
import Data.Maybe (fromMaybe, isNothing)
import Data.Text qualified as T
import Data.Word (Word8)

-- @foldl'@ moved into Prelude in base-4.20 (GHC 9.10); import it explicitly on
-- older bases so the package still builds on GHC 9.6 / 9.8.
#if !MIN_VERSION_base(4,20,0)
import Data.List (foldl')
#endif

import Hedgehog (Gen, Property, forAll, property, (===))
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
import Test.Tasty.Hedgehog (testProperty)

import Effectful.Tracing.Effect (transitionStatus)
import Effectful.Tracing.Internal.Types
  ( SpanStatus (Error, Ok, Unset)
  , TraceFlags (TraceFlags)
  , defaultTraceFlags
  , emptyTraceState
  , insertTraceState
  , isSampled
  , lookupTraceState
  , maxTraceStateEntries
  , setSampled
  , traceStateEntries
  , traceStateFromHeader
  )

tests :: TestTree
tests =
  testGroup
    "Pure data model"
    [ testGroup "transitionStatus" transitionTests
    , testGroup "trace state" traceStateTests
    , testGroup "trace flags" traceFlagsTests
    ]

transitionTests :: [TestTree]
transitionTests =
  [ testCase "Unset moves to Ok or Error" $ do
      transitionStatus Unset Ok @?= Ok
      transitionStatus Unset (Error "boom") @?= Error "boom"
  , testCase "Error is overridden by Ok" $
      transitionStatus (Error "boom") Ok @?= Ok
  , testCase "Error is overridden by a later Error" $
      transitionStatus (Error "first") (Error "second") @?= Error "second"
  , testCase "Ok is terminal and ignores later transitions" $ do
      transitionStatus Ok (Error "boom") @?= Ok
      transitionStatus Ok Unset @?= Ok
      transitionStatus Ok Ok @?= Ok
  , testCase "a proposed Unset never downgrades the current status" $ do
      transitionStatus (Error "boom") Unset @?= Error "boom"
      transitionStatus Unset Unset @?= Unset
  , testProperty "Ok absorbs every proposed status" prop_okAbsorbs
  , testProperty "the result is Unset only when both inputs are Unset" prop_neverDowngradesToUnset
  ]

prop_okAbsorbs :: Property
prop_okAbsorbs = property $ do
  proposed <- forAll genStatus
  transitionStatus Ok proposed === Ok

prop_neverDowngradesToUnset :: Property
prop_neverDowngradesToUnset = property $ do
  current <- forAll genStatus
  proposed <- forAll genStatus
  (transitionStatus current proposed == Unset)
    === (current == Unset && proposed == Unset)

genStatus :: Gen SpanStatus
genStatus =
  Gen.choice [pure Unset, pure Ok, Error <$> Gen.text (Range.linear 0 12) Gen.alphaNum]

traceStateTests :: [TestTree]
traceStateTests =
  [ testCase "insert then lookup returns the value" $ do
      let st = insert "vendor" "value" emptyTraceState
      lookupTraceState "vendor" st @?= Just "value"
  , testCase "an inserted key is at the head (most recent)" $ do
      let st = insert "b" "2" (insert "a" "1" emptyTraceState)
      fmap fst (take 1 (traceStateEntries st)) @?= ["b"]
  , testCase "re-inserting a key dedupes and moves it to the head" $ do
      let st = insert "a" "v2" (insert "c" "3" (insert "a" "v1" emptyTraceState))
      traceStateEntries st @?= [("a", "v2"), ("c", "3")]
  , testCase "rejects an empty key" $
      assertBool "empty key" (isNothing (insertTraceState "" "v" emptyTraceState))
  , testCase "rejects an uppercase key" $
      assertBool "uppercase key" (isNothing (insertTraceState "Vendor" "v" emptyTraceState))
  , testCase "rejects an empty value" $
      assertBool "empty value" (isNothing (insertTraceState "k" "" emptyTraceState))
  , testCase "rejects a value containing a comma" $
      assertBool "comma value" (isNothing (insertTraceState "k" "a,b" emptyTraceState))
  , testCase "rejects a value containing an equals sign" $
      assertBool "equals value" (isNothing (insertTraceState "k" "a=b" emptyTraceState))
  , testCase "rejects a new key once the entry cap is reached" $
      assertBool "over capacity" (isNothing (insertTraceState "overflow" "v" fullState))
  , testCase "updates an existing key even at the entry cap" $ do
      -- key0 already exists, so the update removes it before inserting and stays
      -- within the cap.
      let updated = insertTraceState "key0" "fresh" fullState
      fmap (lookupTraceState "key0") updated @?= Just (Just "fresh")
  , testCase "the parser drops malformed members but keeps valid ones" $
      traceStateEntries (traceStateFromHeader "foo=bar,this is junk,baz=qux")
        @?= [("foo", "bar"), ("baz", "qux")]
  , testCase "the parser keeps the first occurrence of a duplicate key" $
      traceStateEntries (traceStateFromHeader "k=1,k=2") @?= [("k", "1")]
  ]
  where
    insert k v st = fromMaybe st (insertTraceState k v st)
    -- A trace state filled to exactly the entry cap, keys key0..key31.
    fullState =
      foldl'
        (\st n -> insert ("key" <> tShow n) (tShow n) st)
        emptyTraceState
        [0 .. maxTraceStateEntries - 1]
    tShow = T.pack . show

traceFlagsTests :: [TestTree]
traceFlagsTests =
  [ testCase "default flags are not sampled" $
      isSampled defaultTraceFlags @?= False
  , testCase "setSampled True then False round-trips the sampled bit" $ do
      isSampled (setSampled True defaultTraceFlags) @?= True
      isSampled (setSampled False (setSampled True defaultTraceFlags)) @?= False
  , testProperty "setSampled toggles only bit 0, preserving the reserved bits" prop_setSampledReservedBits
  ]

prop_setSampledReservedBits :: Property
prop_setSampledReservedBits = property $ do
  w <- forAll (Gen.word8 Range.constantBounded)
  b <- forAll Gen.bool
  let TraceFlags w' = setSampled b (TraceFlags w)
  -- bit 0 reflects the request; bits 1..7 are untouched.
  testBit w' 0 === b
  reservedBits w' === reservedBits w

-- | Bits 1 through 7 of a flags byte (everything but the sampled bit).
reservedBits :: Word8 -> [Bool]
reservedBits w = [testBit w i | i <- [1 .. 7]]