packages feed

shibuya-kafka-adapter-0.2.0.0: test/Shibuya/Adapter/Kafka/TracingTest.hs

{- | Unit tests for 'Shibuya.Adapter.Kafka.Tracing.traced'.

Drives the 'traced' stream transformer over a single synthetic 'Ingested',
captures the resulting OTel span with an in-memory 'SpanProcessor', and
asserts span shape: trace parenting, attribute set, and that the original
'AckDecision' still flows through the wrapped 'AckHandle.finalize'.

No Kafka broker is required. The processor implementation mirrors
@OpenTelemetry.Exporter.InMemory.Span.inMemoryListExporter@ from
hs-opentelemetry-exporter-in-memory; the latter is not used directly
because its current Hackage release pins @hs-opentelemetry-api <0.3@,
while this repository resolves to 0.3.1.0.
-}
module Shibuya.Adapter.Kafka.TracingTest (tests) where

import Control.Concurrent.Async (async)
import Control.Exception (bracket)
import Data.ByteString (ByteString)
import Data.IORef (IORef, atomicModifyIORef, newIORef, readIORef, writeIORef)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Kafka.Types (TopicName (..))
import OpenTelemetry.Attributes (Attribute (..), PrimitiveAttribute (..), lookupAttribute)
import OpenTelemetry.Processor.Span (ShutdownResult (..), SpanProcessor (..))
import OpenTelemetry.Trace.Core (
    ImmutableSpan (..),
    SpanContext (..),
    Tracer,
    createTracerProvider,
    emptyTracerProviderOptions,
    getSpanContext,
    makeTracer,
    shutdownTracerProvider,
    tracerOptions,
 )
import OpenTelemetry.Trace.Id (Base (..), spanIdBaseEncodedText, traceIdBaseEncodedText)
import Shibuya.Adapter.Kafka.Tracing (traced)
import Shibuya.Core.Ack (AckDecision (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), TraceHeaders)
import Shibuya.Telemetry.Effect (runTracing)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
    testGroup
        "Tracing"
        [ testCase "envelope traceparent becomes span parent" testParentedSpan
        , testCase "missing traceContext yields root span" testRootSpan
        , testCase "messaging attributes populated from envelope" testAttributes
        , testCase "AckDecision threads through wrapped finalize" testAckPassthrough
        ]

-- | SpanProcessor that records ended spans to an IORef.
recordingProcessor :: IO (SpanProcessor, IORef [ImmutableSpan])
recordingProcessor = do
    ref <- newIORef []
    let processor =
            SpanProcessor
                { spanProcessorOnStart = \_ _ -> pure ()
                , spanProcessorOnEnd = \spanRef -> do
                    s <- readIORef spanRef
                    atomicModifyIORef ref (\l -> (s : l, ()))
                , spanProcessorShutdown = async (pure ShutdownSuccess)
                , spanProcessorForceFlush = pure ()
                }
    pure (processor, ref)

withRecordingTracer :: (IORef [ImmutableSpan] -> Tracer -> IO a) -> IO a
withRecordingTracer k = do
    (processor, spansRef) <- recordingProcessor
    bracket
        (createTracerProvider [processor] emptyTracerProviderOptions)
        shutdownTracerProvider
        $ \tp ->
            k spansRef (makeTracer tp "shibuya-kafka-adapter-test" tracerOptions)

-- Known-good W3C traceparent with recoverable trace and span ids.
expectedTraceIdHex :: Text
expectedTraceIdHex = "0af7651916cd43dd8448eb211c80319c"

expectedSpanIdHex :: Text
expectedSpanIdHex = "b7ad6b7169203331"

sampleTraceparent :: ByteString
sampleTraceparent =
    TE.encodeUtf8 ("00-" <> expectedTraceIdHex <> "-" <> expectedSpanIdHex <> "-01")

mkEnvelope :: Maybe TraceHeaders -> Envelope ()
mkEnvelope tc =
    Envelope
        { messageId = MessageId "orders-2-42"
        , cursor = Just (CursorInt 42)
        , partition = Just "2"
        , enqueuedAt = Nothing
        , traceContext = tc
        , payload = ()
        }

mkIngestedFor ::
    (IOE :> es) =>
    IORef (Maybe AckDecision) ->
    Envelope () ->
    Ingested es ()
mkIngestedFor ackRef env =
    Ingested
        { envelope = env
        , ack = AckHandle $ \decision -> liftIO $ writeIORef ackRef (Just decision)
        , lease = Nothing
        }

-- | Drive 'traced' over a single envelope, calling finalize with 'AckOk'.
runOneThroughTraced ::
    Tracer ->
    Envelope () ->
    IORef (Maybe AckDecision) ->
    IO ()
runOneThroughTraced tracer env ackRef = runEff . runTracing tracer $ do
    let ing = mkIngestedFor ackRef env
    Stream.fold Fold.drain $
        Stream.mapM callFinalize $
            traced (TopicName "orders") $
                Stream.fromList [ing]
  where
    callFinalize :: Ingested es () -> Eff es ()
    callFinalize i =
        let AckHandle fin = i.ack
         in fin AckOk

testParentedSpan :: Assertion
testParentedSpan = withRecordingTracer $ \spansRef tracer -> do
    ackRef <- newIORef Nothing
    runOneThroughTraced tracer (mkEnvelope (Just [("traceparent", sampleTraceparent)])) ackRef
    spans <- readIORef spansRef
    case spans of
        [s] -> do
            assertEqual
                "span traceId matches parent"
                expectedTraceIdHex
                (traceIdBaseEncodedText Base16 s.spanContext.traceId)
            case s.spanParent of
                Just parentSpan -> do
                    parentCtx <- getSpanContext parentSpan
                    assertEqual
                        "parent traceId"
                        expectedTraceIdHex
                        (traceIdBaseEncodedText Base16 parentCtx.traceId)
                    assertEqual
                        "parent spanId"
                        expectedSpanIdHex
                        (spanIdBaseEncodedText Base16 parentCtx.spanId)
                Nothing -> assertFailure "expected spanParent to be set"
        other -> assertFailure ("expected exactly one span, got " <> show (length other))

testRootSpan :: Assertion
testRootSpan = withRecordingTracer $ \spansRef tracer -> do
    ackRef <- newIORef Nothing
    runOneThroughTraced tracer (mkEnvelope Nothing) ackRef
    spans <- readIORef spansRef
    case spans of
        [s] -> case s.spanParent of
            Nothing -> pure ()
            Just _ -> assertFailure "expected root span to have no parent"
        other -> assertFailure ("expected exactly one span, got " <> show (length other))

testAttributes :: Assertion
testAttributes = withRecordingTracer $ \spansRef tracer -> do
    ackRef <- newIORef Nothing
    runOneThroughTraced tracer (mkEnvelope (Just [("traceparent", sampleTraceparent)])) ackRef
    spans <- readIORef spansRef
    case spans of
        [s] -> do
            let attrs = s.spanAttributes
            assertEqual
                "messaging.system"
                (Just (AttributeValue (TextAttribute "kafka")))
                (lookupAttribute attrs "messaging.system")
            assertEqual
                "messaging.destination.name"
                (Just (AttributeValue (TextAttribute "orders")))
                (lookupAttribute attrs "messaging.destination.name")
            assertEqual
                "messaging.message.id"
                (Just (AttributeValue (TextAttribute "orders-2-42")))
                (lookupAttribute attrs "messaging.message.id")
            assertEqual
                "messaging.destination.partition.id"
                (Just (AttributeValue (TextAttribute "2")))
                (lookupAttribute attrs "messaging.destination.partition.id")
        other -> assertFailure ("expected exactly one span, got " <> show (length other))

testAckPassthrough :: Assertion
testAckPassthrough = withRecordingTracer $ \_ tracer -> do
    ackRef <- newIORef Nothing
    runOneThroughTraced tracer (mkEnvelope Nothing) ackRef
    decision <- readIORef ackRef
    assertEqual "underlying finalize received the decision" (Just AckOk) decision