kafka-effectful-0.3.1.0: test/Kafka/Effectful/OpenTelemetry/ConsumerSpanTest.hs
-- | Trace-context hygiene tests for the traced consumer interpreter.
--
-- These drive 'withConsumerSpan' directly rather than going through the
-- interpreter, because the interpreter closes over a live
-- @Kafka.Consumer.KafkaConsumer@ and calls hw-kafka-client IO, while the
-- behaviour under test — how a record\'s inbound trace context is extracted,
-- attached, and detached — is entirely broker-independent.
--
-- Spans are captured with an in-memory exporter wired into a tracer provider
-- built fresh for each test case. Building it per case rather than sharing one
-- matters twice over: tasty runs test cases concurrently, so a shared span
-- reference would race, and @inSpan\'\'@ takes a fast path that skips context
-- modification entirely when the provider has no span processors, so a
-- processor-less provider would make these assertions vacuous.
module Kafka.Effectful.OpenTelemetry.ConsumerSpanTest (tests) where
import Data.ByteString (ByteString)
import Data.IORef (readIORef)
import Data.Text (Text)
import Effectful (runEff)
import Kafka.Consumer.ConsumerProperties (ConsumerProperties)
import Kafka.Consumer.Types
( ConsumerRecord (..),
Offset (..),
Timestamp (NoTimestamp),
)
import Kafka.Effectful.OpenTelemetry.Consumer.Interpreter (withConsumerSpan)
import Kafka.Types
( PartitionId (..),
TopicName (..),
headersFromList,
)
import OpenTelemetry.Context qualified as Context
import OpenTelemetry.Context.ThreadLocal (getContext)
import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)
import OpenTelemetry.Trace (initializeGlobalTracerProvider)
import OpenTelemetry.Trace.Core
( ImmutableSpan (..),
SpanContext (..),
createTracerProvider,
emptyTracerProviderOptions,
forceFlushTracerProvider,
makeTracer,
tracerOptions,
)
import OpenTelemetry.Trace.Id (Base (Base16), traceIdBaseEncodedText)
import Test.Tasty (TestTree, testGroup, withResource)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
-- | The W3C trace-context specification\'s example traceparent. Trace ID
-- @0af7651916cd43dd8448eb211c80319c@.
sampleTraceparent :: ByteString
sampleTraceparent =
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
sampleTraceIdHex :: Text
sampleTraceIdHex = "0af7651916cd43dd8448eb211c80319c"
-- | A second, distinct traceparent, for the batch-isolation case.
otherTraceparent :: ByteString
otherTraceparent =
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
otherTraceIdHex :: Text
otherTraceIdHex = "4bf92f3577b34da6a3ce929d0e0e4736"
-- | An invalid UTF-8 byte sequence: @0xC3@ opens a two-byte sequence but
-- @0x28@ is not a valid continuation byte.
invalidUtf8 :: ByteString
invalidUtf8 = "\xc3\x28"
mkRecord ::
[(ByteString, ByteString)] ->
ConsumerRecord (Maybe ByteString) (Maybe ByteString)
mkRecord headers =
ConsumerRecord
{ crTopic = TopicName "demo",
crPartition = PartitionId 0,
crOffset = Offset 0,
crTimestamp = NoTimestamp,
crHeaders = headersFromList headers,
crKey = Nothing,
crValue = Just "value"
}
emptyProps :: ConsumerProperties
emptyProps = mempty
-- | Run 'withConsumerSpan' over each record in order against a private
-- tracer provider, and return the exported spans in creation order.
runRecords ::
[ConsumerRecord (Maybe ByteString) (Maybe ByteString)] ->
IO [ImmutableSpan]
runRecords records = do
(processor, spansRef) <- inMemoryListExporter
provider <- createTracerProvider [processor] emptyTracerProviderOptions
let tracer = makeTracer provider "kafka-effectful-test" tracerOptions
runEff $
mapM_ (\cr -> withConsumerSpan tracer emptyProps cr (pure ())) records
_ <- forceFlushTracerProvider provider Nothing
-- inMemoryListExporter conses each span as it ends, so the list is
-- newest-first; reverse to recover creation order.
reverse <$> readIORef spansRef
traceIdHexOf :: ImmutableSpan -> Text
traceIdHexOf = traceIdBaseEncodedText Base16 . traceId . spanContext
-- | Whether a context carries a span at all. Enough to detect a leak, and
-- avoids needing an 'Eq' instance for 'Context'.
hasSpan :: Context.Context -> Bool
hasSpan = maybe False (const True) . Context.lookupSpan
tests :: TestTree
tests =
-- Establishes the SDK default propagator stack (W3C trace context),
-- which extractTraceContextFromRecord consults via the global lookup.
withResource initializeGlobalTracerProvider (\_ -> pure ()) $ \_ ->
testGroup
"ConsumerSpan"
[ testCase "a record with a traceparent gets the remote trace id" $ do
spans_ <- runRecords [mkRecord [("traceparent", sampleTraceparent)]]
case spans_ of
[s] ->
assertEqual
"span should inherit the inbound trace id"
sampleTraceIdHex
(traceIdHexOf s)
_ -> assertFailure ("expected exactly one span, got " <> show (length spans_)),
-- The KSC-6 regression. Before the fix, withConsumerSpan extracted
-- into the *current* thread-local context and discarded the token
-- it attached, so the previous record's remote context was still
-- installed when this headerless record arrived and its span
-- chained onto it -- contradicting the module's documented "new
-- root span when no inbound context is present".
testCase "a headerless record after a traced record starts a new root" $ do
spans_ <-
runRecords
[ mkRecord [("traceparent", sampleTraceparent)],
mkRecord []
]
case spans_ of
[_traced, headerless] -> do
assertBool
( "headerless record must not inherit the previous record's trace id, got "
<> show (traceIdHexOf headerless)
)
(traceIdHexOf headerless /= sampleTraceIdHex)
assertBool
"a new root span has no parent"
(maybe True (const False) (spanParent headerless))
_ -> assertFailure ("expected exactly two spans, got " <> show (length spans_)),
-- The context leak observed from outside: whatever ambient context
-- the caller had must survive the call. Before the fix the
-- thread-local permanently retained the last record's context.
testCase "the caller's ambient context is restored" $ do
ctxBefore <- getContext
_ <-
runRecords
[ mkRecord [("traceparent", sampleTraceparent)],
mkRecord []
]
ctxAfter <- getContext
assertEqual
"thread-local context must not retain the last record's context"
(hasSpan ctxBefore)
(hasSpan ctxAfter),
-- The KSC-1 residual. A non-UTF-8 application header must not cost
-- the record its inbound trace context. Before the fix, building
-- the carrier decoded every header with partial decodeUtf8, the
-- exception escaped into the propagator's catch-all, and the whole
-- context -- traceparent included -- was dropped.
testCase "a non-UTF-8 header does not poison extraction" $ do
spans_ <-
runRecords
[ mkRecord
[ ("traceparent", sampleTraceparent),
("payload-hint", invalidUtf8)
]
]
case spans_ of
[s] ->
assertEqual
"inbound trace id must survive an undecodable sibling header"
sampleTraceIdHex
(traceIdHexOf s)
_ -> assertFailure ("expected exactly one span, got " <> show (length spans_)),
-- Pins the PollMessageBatch walk, which calls withConsumerSpan once
-- per record on a single thread.
testCase "each record in a batch is isolated from its neighbours" $ do
spans_ <-
runRecords
[ mkRecord [("traceparent", sampleTraceparent)],
mkRecord [],
mkRecord [("traceparent", otherTraceparent)]
]
case spans_ of
[first_, middle, third] -> do
assertEqual
"first record keeps its own remote trace"
sampleTraceIdHex
(traceIdHexOf first_)
assertBool
"middle record must be a new root, not a continuation"
(traceIdHexOf middle /= sampleTraceIdHex)
assertEqual
"third record picks up its own remote trace"
otherTraceIdHex
(traceIdHexOf third)
_ -> assertFailure ("expected exactly three spans, got " <> show (length spans_))
]