packages feed

kafka-effectful-0.2.0.0: test/Kafka/Effectful/OpenTelemetry/PropagationTest.hs

module Kafka.Effectful.OpenTelemetry.PropagationTest (tests) where

import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BSC
import Data.CaseInsensitive qualified as CI
import Data.Text (Text)
import Data.Text qualified as Text
import Kafka.Consumer.Types (
    ConsumerRecord (..),
    Offset (..),
    Timestamp (NoTimestamp),
 )
import Kafka.Effectful.OpenTelemetry.Propagation (
    extractTraceContextFromRecord,
    injectTraceContextIntoRecord,
    kafkaHeadersToRequestHeaders,
    requestHeadersToKafkaHeaders,
 )
import Kafka.Producer.Types (
    ProducePartition (UnassignedPartition),
    ProducerRecord (..),
 )
import Kafka.Types (
    PartitionId (..),
    TopicName (..),
    headersFromList,
    headersToList,
 )
import OpenTelemetry.Context qualified as Context
import OpenTelemetry.Trace (initializeGlobalTracerProvider)
import OpenTelemetry.Trace.Core (
    SpanContext (..),
    defaultTraceFlags,
    getSpanContext,
    wrapSpanContext,
 )
import OpenTelemetry.Trace.Id (
    Base (Base16),
    SpanId,
    TraceId,
    baseEncodedToSpanId,
    baseEncodedToTraceId,
    traceIdBaseEncodedText,
 )
import OpenTelemetry.Trace.TraceState qualified as TraceState
import Test.Tasty (TestTree, testGroup, withResource)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)

{- | Known traceparent value derived from the W3C trace-context
specification\'s example. Trace ID
@0af7651916cd43dd8448eb211c80319c@, span ID
@b7ad6b7169203331@, sampled (flag @01@).
-}
sampleTraceparent :: ByteString
sampleTraceparent =
    "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"

sampleTraceIdHex :: Text
sampleTraceIdHex = "0af7651916cd43dd8448eb211c80319c"

sampleSpanIdHex :: Text
sampleSpanIdHex = "b7ad6b7169203331"

tests :: TestTree
tests =
    -- Initialize the global tracer provider exactly once for the whole
    -- test group. The SDK\'s default propagator pipeline includes
    -- @w3cTraceContextPropagator@, which is what
    -- @injectTraceContextIntoRecord@ \/ @extractTraceContextFromRecord@
    -- consult under the hood.
    withResource initializeGlobalTracerProvider (\_ -> pure ()) $ \_ ->
        testGroup
            "Propagation"
            [ testCase "round-trip kafka headers <-> request headers (case-fold)" $ do
                let original =
                        headersFromList
                            [ ("traceparent", sampleTraceparent)
                            , ("Custom-Header", "value")
                            ]
                    roundTripped =
                        requestHeadersToKafkaHeaders
                            (kafkaHeadersToRequestHeaders original)
                lookup "traceparent" (headersToList roundTripped)
                    `shouldBeJust` sampleTraceparent
                lookup "custom-header" (headersToList roundTripped)
                    `shouldBeJust` "value"
            , testCase "kafkaHeadersToRequestHeaders preserves traceparent value" $ do
                let h = headersFromList [("traceparent", sampleTraceparent)]
                    rh = kafkaHeadersToRequestHeaders h
                lookup (CI.mk "traceparent") rh
                    `shouldBeJust` sampleTraceparent
            , testCase "injectTraceContextIntoRecord adds W3C traceparent" $ do
                tid <- decodeHexTraceId sampleTraceIdHex
                sid <- decodeHexSpanId sampleSpanIdHex
                let ctx =
                        Context.insertSpan
                            (wrapSpanContext (frozenContextWith tid sid))
                            Context.empty
                injected <-
                    injectTraceContextIntoRecord
                        ctx
                        emptyProducerRecord
                let injectedHeaders = headersToList (prHeaders injected)
                case lookup "traceparent" injectedHeaders of
                    Nothing ->
                        assertFailure
                            "expected the producer record to carry a traceparent header"
                    Just header ->
                        assertBool
                            ( "traceparent did not contain the trace-id "
                                <> Text.unpack sampleTraceIdHex
                                <> ", got: "
                                <> BSC.unpack header
                            )
                            (BSC.pack (Text.unpack sampleTraceIdHex) `BSC.isInfixOf` header)
            , testCase "extractTraceContextFromRecord recovers parent context" $ do
                let cr = consumerRecordWithHeaders sampleTraceparent
                ctx <-
                    extractTraceContextFromRecord cr Context.empty
                case Context.lookupSpan ctx of
                    Nothing ->
                        assertFailure
                            "expected the extracted context to carry a span"
                    Just span_ -> do
                        sc <- getSpanContext span_
                        let recovered = traceIdBaseEncodedText Base16 (traceId sc)
                        assertEqual
                            "recovered trace-id should match the inbound traceparent"
                            sampleTraceIdHex
                            recovered
            ]

shouldBeJust :: (Eq a, Show a) => Maybe a -> a -> IO ()
shouldBeJust Nothing expected =
    assertFailure ("expected Just " <> show expected <> ", got Nothing")
shouldBeJust (Just actual) expected =
    assertEqual "values differ" expected actual

decodeHexTraceId :: Text -> IO TraceId
decodeHexTraceId hex =
    case baseEncodedToTraceId Base16 (BSC.pack (Text.unpack hex)) of
        Right tid -> pure tid
        Left err -> assertFailure ("invalid hex trace id: " <> err) >> error "unreachable"

decodeHexSpanId :: Text -> IO SpanId
decodeHexSpanId hex =
    case baseEncodedToSpanId Base16 (BSC.pack (Text.unpack hex)) of
        Right sid -> pure sid
        Left err -> assertFailure ("invalid hex span id: " <> err) >> error "unreachable"

{- | Build a 'SpanContext' that carries the supplied trace and span
IDs. Trace flags are 'defaultTraceFlags' (unsampled); the W3C
propagator preserves the bytes anyway.
-}
frozenContextWith :: TraceId -> SpanId -> SpanContext
frozenContextWith tid sid =
    SpanContext
        { traceId = tid
        , spanId = sid
        , traceFlags = defaultTraceFlags
        , isRemote = False
        , traceState = TraceState.empty
        }

emptyProducerRecord :: ProducerRecord
emptyProducerRecord =
    ProducerRecord
        { prTopic = TopicName "demo"
        , prPartition = UnassignedPartition
        , prKey = Nothing
        , prValue = Just "value"
        , prHeaders = headersFromList []
        }

consumerRecordWithHeaders ::
    ByteString ->
    ConsumerRecord (Maybe ByteString) (Maybe ByteString)
consumerRecordWithHeaders traceparent =
    ConsumerRecord
        { crTopic = TopicName "demo"
        , crPartition = PartitionId 0
        , crOffset = Offset 0
        , crTimestamp = NoTimestamp
        , crHeaders = headersFromList [("traceparent", traceparent)]
        , crKey = Nothing
        , crValue = Just "value"
        }