packages feed

shibuya-core-0.13.0.0: test/Shibuya/Telemetry/BatchCorrelationSpec.hs

-- | Fan-in links on batch processing spans.
--
-- A batch has many producers and a span has one parent, so the batch span is
-- a root that links every member's creation context, bounded at
-- 'maxBatchLinks', each link naming its member.
module Shibuya.Telemetry.BatchCorrelationSpec (spec) where

import Control.Exception (bracket)
import Control.Monad (forM_, void)
import Data.ByteString.Char8 qualified as BS8
import Data.Foldable (toList)
import Data.HashMap.Strict qualified as HashMap
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Numeric (showHex)
import OpenTelemetry.Attributes (Attribute, emptyAttributes, getAttributeMap, toAttribute)
import OpenTelemetry.Context qualified as Ctx
import OpenTelemetry.Context.ThreadLocal qualified as Ctx
import OpenTelemetry.Exporter.InMemory (inMemoryListExporter)
import OpenTelemetry.Trace.Core qualified as OTel
import OpenTelemetry.Trace.Id (Base (..))
import OpenTelemetry.Trace.Id qualified as OTel.Id
import OpenTelemetry.Trace.Id.Generator (IdGenerator (..))
import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)
import Shibuya.Adapter (Adapter (..), AdapterTelemetry (traceCorrelation), TraceCorrelation (..), defaultAdapterTelemetry)
import Shibuya.Adapter.Mock (listAdapter)
import Shibuya.Batch (BatchConfig (..), ackAllOk, defaultBatchConfig)
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..), mkIngested)
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Core.Types (Envelope (..), MessageId (..), TraceHeaders, mkEnvelope)
import Shibuya.Internal.Runner.Supervised (runWithMetricsBatch)
import Shibuya.Policy (Concurrency (..))
import Shibuya.Telemetry.Correlation (BatchLinks (..), collectBatchLinks, maxBatchLinks)
import Shibuya.Telemetry.Effect (Tracing, runTracing, runTracingNoop, withSpan')
import Test.Hspec

spec :: Spec
spec = describe "Shibuya.Telemetry.Correlation (batches)" $ do
  describe "collectBatchLinks" $ do
    it "links members in order, each with its message id" $ do
      let result = collectBatchLinks [(MessageId "a", Just (producer 1)), (MessageId "b", Nothing), (MessageId "c", Just (producer 3))]
      map (hexSpan . (.linkContext)) result.links `shouldBe` [spanHex 1, spanHex 3]
      map (HashMap.lookup "messaging.message.id" . (.linkAttributes)) result.links
        `shouldBe` [Just (toAttribute ("a" :: Text)), Just (toAttribute ("c" :: Text))]
      result.dropped `shouldBe` 0

    it "keeps one link per member when members share a context" $ do
      let result = collectBatchLinks [(MessageId "a", Just (producer 1)), (MessageId "b", Just (producer 1))]
      map (hexSpan . (.linkContext)) result.links `shouldBe` [spanHex 1, spanHex 1]
      map (HashMap.lookup "messaging.message.id" . (.linkAttributes)) result.links
        `shouldBe` [Just (toAttribute ("a" :: Text)), Just (toAttribute ("b" :: Text))]

    it "stores at most maxBatchLinks and counts only valid omitted contexts" $ do
      let full = [(MessageId (tshow i), Just (producer i)) | i <- [1 .. maxBatchLinks]]
          tailMembers =
            [ (MessageId "absent", Nothing),
              (MessageId "malformed", Just malformed),
              (MessageId "valid-1", Just (producer 1000)),
              (MessageId "valid-2", Just (producer 1001))
            ]
          result = collectBatchLinks (full <> tailMembers)
      length result.links `shouldBe` maxBatchLinks
      result.dropped `shouldBe` 2

    it "keeps sparse valid contexts before the cap" $ do
      let members = concat [[(MessageId (tshow i), Just (producer i)), (MessageId "gap", Nothing), (MessageId "bad", Just malformed)] | i <- [1 .. 10]]
          result = collectBatchLinks members
      length result.links `shouldBe` 10
      result.dropped `shouldBe` 0

  describe "batch spans through the runner" $ do
    it "is a root with one link per producer, naming each member" $ do
      [sp] <- batchSpans =<< runBatch [member "m1" (Just (producer 1)), member "m2" (Just (producer 2)), member "m3" (Just (producer 3))]
      isNothing sp.parent `shouldBe` True
      map fst sp.links `shouldBe` [spanHex 1, spanHex 2, spanHex 3]
      map snd sp.links `shouldBe` map (Just . toAttribute) ["m1", "m2", "m3" :: Text]
      sp.traceHex `notElem` map traceHex [1, 2, 3] `shouldBe` True
      HashMap.lookup "messaging.batch.message_count" sp.attributes `shouldBe` Just (toAttribute (3 :: Int))
      sp.name `shouldBe` "process batch-correlation"
      HashMap.lookup "messaging.operation.name" sp.attributes `shouldBe` Just (toAttribute ("process" :: Text))
      HashMap.lookup "messaging.operation.type" sp.attributes `shouldBe` Just (toAttribute ("process" :: Text))
      HashMap.member "shibuya.batch.links.dropped" sp.attributes `shouldBe` False

    it "links members that share a context separately" $ do
      [sp] <- batchSpans =<< runBatch [member "m1" (Just (producer 1)), member "m2" (Just (producer 1)), member "m3" (Just (producer 3))]
      map fst sp.links `shouldBe` [spanHex 1, spanHex 1, spanHex 3]
      map snd sp.links `shouldBe` map (Just . toAttribute) ["m1", "m2", "m3" :: Text]

    it "has no links and no parent when no member has headers" $ do
      [sp] <- batchSpans =<< runBatch [member "m1" Nothing, member "m2" Nothing]
      sp.links `shouldBe` []
      isNothing sp.parent `shouldBe` True

    it "links only the members that have headers" $ do
      [sp] <- batchSpans =<< runBatch [member "m1" Nothing, member "m2" (Just (producer 2)), member "m3" (Just malformed)]
      map snd sp.links `shouldBe` [Just (toAttribute ("m2" :: Text))]

    it "bounds 200 distinct producers at 128 links and reports 72 dropped" $ do
      [sp] <- batchSpans =<< runBatch [member (tshow i) (Just (producer i)) | i <- [1 .. 200]]
      length sp.links `shouldBe` 128
      HashMap.lookup "shibuya.batch.links.dropped" sp.attributes `shouldBe` Just (toAttribute (72 :: Int))
      HashMap.lookup "messaging.batch.message_count" sp.attributes `shouldBe` Just (toAttribute (200 :: Int))

    it "does not link a superseded member" $ do
      let superseded = (member "m2" (Just (producer 2))) {deliveryStatus = pure DeliverySuperseded}
      [sp] <- batchSpans =<< runBatch [member "m1" (Just (producer 1)), superseded, member "m3" (Just (producer 3))]
      map snd sp.links `shouldBe` map (Just . toAttribute) ["m1", "m3" :: Text]
      HashMap.lookup "messaging.batch.message_count" sp.attributes `shouldBe` Just (toAttribute (2 :: Int))

    forM_ [minBound .. maxBound] $ \policy ->
      it ("ignores the adapter's " <> show policy <> " policy") $ do
        [sp] <- batchSpans =<< runBatchWith policy [member "m1" (Just (producer 1))]
        isNothing sp.parent `shouldBe` True
        map fst sp.links `shouldBe` [spanHex 1]

    it "is still a root inside an ambient span, which is restored afterwards" $ do
      restored <- newIORef False
      spans <- withExporter $ \tracer -> runEff $
        runTracing tracer $
          withSpan' "ambient" OTel.defaultSpanArguments $ \ambient -> do
            batchOnce ParentAndLink [member "m1" (Just (producer 1))]
            liftIO $ do
              ambientCtx <- OTel.getSpanContext ambient
              current <- traverse OTel.getSpanContext . Ctx.lookupSpan =<< Ctx.getContext
              writeIORef restored (current == Just ambientCtx)
      [sp] <- batchSpans spans
      isNothing sp.parent `shouldBe` True
      readIORef restored `shouldReturn` True

    it "runs the handler and exports nothing with tracing disabled" $ do
      handled <- newIORef (0 :: Int)
      spans <- withExporter $ \_ -> runEff $ runTracingNoop $ do
        let adapter = listAdapter [member "m1" (Just (producer 1))]
        void $ runWithMetricsBatch 16 (ProcessorId "batch-correlation") Serial batchConfig adapter $ \_ _ ->
          liftIO (modifyIORef' handled (+ 1)) >> pure ackAllOk
      readIORef handled `shouldReturn` 1
      length spans `shouldBe` 0

--------------------------------------------------------------------------------
-- Fixtures
--------------------------------------------------------------------------------

batchConfig :: BatchConfig es msg
batchConfig = defaultBatchConfig {batchSize = 1000, batchTimeout = 60}

member :: (IOE :> es) => Text -> Maybe TraceHeaders -> Ingested es ()
member msgId headers =
  mkIngested ((mkEnvelope (MessageId msgId) ()) {traceContext = headers}) (AckHandle (\_ -> pure ()))

-- | A distinct, valid producer context for each index.
producer :: Int -> TraceHeaders
producer i = [("traceparent", BS8.pack ("00-" <> Text.unpack (traceHex i) <> "-" <> Text.unpack (spanHex i) <> "-01"))]

malformed :: TraceHeaders
malformed = [("traceparent", "00-not-a-valid-traceparent-01")]

traceHex :: Int -> Text
traceHex i = padHex 32 (i + 0xabc)

spanHex :: Int -> Text
spanHex i = padHex 16 (i + 0x100)

padHex :: Int -> Int -> Text
padHex width n = let digits = showHex n "" in Text.pack (replicate (width - length digits) '0' <> digits)

tshow :: (Show a) => a -> Text
tshow = Text.pack . show

batchOnce :: (IOE :> es, Tracing :> es) => TraceCorrelation -> [Ingested es ()] -> Eff es ()
batchOnce policy members =
  let adapter = (listAdapter members) {telemetry = defaultAdapterTelemetry {traceCorrelation = policy}}
   in void $ runWithMetricsBatch 16 (ProcessorId "batch-correlation") Serial batchConfig adapter (\_ _ -> pure ackAllOk)

runBatch :: [Ingested '[Tracing, IOE] ()] -> IO [OTel.ImmutableSpan]
runBatch = runBatchWith ParentAndLink

runBatchWith :: TraceCorrelation -> [Ingested '[Tracing, IOE] ()] -> IO [OTel.ImmutableSpan]
runBatchWith policy members = withExporter $ \tracer -> runEff $ runTracing tracer $ batchOnce policy members

withExporter :: (OTel.Tracer -> IO ()) -> IO [OTel.ImmutableSpan]
withExporter action = do
  (processor, spansRef) <- inMemoryListExporter
  bracket
    (OTel.createTracerProvider [processor] OTel.emptyTracerProviderOptions {OTel.tracerProviderOptionsIdGenerator = DefaultIdGenerator})
    (\provider -> void $ OTel.shutdownTracerProvider provider (Just 5_000_000))
    (\provider -> action (OTel.makeTracer provider (OTel.InstrumentationLibrary "batch-correlation-test" "" "" emptyAttributes) OTel.tracerOptions))
  readIORef spansRef

data BatchSpan = BatchSpan
  { name :: Text,
    traceHex :: Text,
    parent :: Maybe OTel.SpanContext,
    links :: [(Text, Maybe Attribute)],
    attributes :: HashMap.HashMap Text Attribute
  }

batchSpans :: [OTel.ImmutableSpan] -> IO [BatchSpan]
batchSpans spans = traverse describeSpan [sp | sp <- spans, OTel.spanKind sp == OTel.Consumer]
  where
    describeSpan sp = do
      hot <- readIORef (OTel.spanHot sp)
      parent <- traverse OTel.getSpanContext (OTel.spanParent sp)
      pure
        BatchSpan
          { name = OTel.hotName hot,
            traceHex = OTel.Id.traceIdBaseEncodedText Base16 (OTel.spanContext sp).traceId,
            parent,
            links =
              [ (hexSpan (OTel.frozenLinkContext link), HashMap.lookup "messaging.message.id" (getAttributeMap (OTel.frozenLinkAttributes link)))
              | link <- toList (appendOnlyBoundedCollectionValues (OTel.hotLinks hot))
              ],
            attributes = getAttributeMap (OTel.hotAttributes hot)
          }

hexSpan :: OTel.SpanContext -> Text
hexSpan ctx = OTel.Id.spanIdBaseEncodedText Base16 ctx.spanId