shibuya-core-0.13.0.0: test/Shibuya/Telemetry/CorrelationSpec.hs
-- | Trace correlation policies for per-message processing spans.
--
-- Every case drives a real tracer whose provider exports into memory, and
-- inspects the exported Consumer spans: their trace, their parent, and
-- their links.
module Shibuya.Telemetry.CorrelationSpec (spec) where
import Control.Exception (bracket)
import Control.Monad (forM_, void)
import Data.ByteString (ByteString)
import Data.Foldable (toList)
import Data.HashMap.Strict qualified as HashMap
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (isNothing)
import Data.Text (Text)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import OpenTelemetry.Attributes (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.Trace.TraceState qualified as TraceState
import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)
import Shibuya.Adapter (Adapter (..), AdapterTelemetry (traceCorrelation), TraceCorrelation (..), defaultAdapterTelemetry)
import Shibuya.Adapter.Mock (listAdapter)
import Shibuya.Core.Ack (AckDecision (..))
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 (runWithMetrics)
import Shibuya.Telemetry.Correlation (consumerSpanArgsLinkedTo, creationLink, creationLinkWith)
import Shibuya.Telemetry.Effect (Tracing, runTracing, runTracingNoop, withSpan')
import Shibuya.Telemetry.Process (withProcessSpan, withProcessSpanIO)
import Shibuya.Telemetry.Propagation (currentTraceHeaders, extractTraceContext)
import Shibuya.Telemetry.Semantic (processSpanName)
import Test.Hspec
spec :: Spec
spec = describe "Shibuya.Telemetry.Correlation" $ do
describe "AdapterTelemetry" $ do
it "defaults to ParentAndLink and is changed by record update" $ do
defaultAdapterTelemetry.traceCorrelation `shouldBe` ParentAndLink
(defaultAdapterTelemetry {traceCorrelation = LinkOnly}).traceCorrelation `shouldBe` LinkOnly
describe "creationLink" $ do
it "decodes a valid traceparent into an attribute-free link" $
case creationLink producerHeaders of
Nothing -> expectationFailure "expected a link"
Just link -> do
hexTrace link.linkContext `shouldBe` producerTraceHex
hexSpan link.linkContext `shouldBe` producerSpanHex
HashMap.null link.linkAttributes `shouldBe` True
it "keeps tracestate on the linked context" $
case creationLink (producerHeaders <> [("tracestate", "congo=t61rcWkgMzE")]) of
Nothing -> expectationFailure "expected a link"
Just link -> TraceState.toList link.linkContext.traceState `shouldBe` [(TraceState.Key "congo", TraceState.Value "t61rcWkgMzE")]
it "returns Nothing without a traceparent" $
isNothing (creationLink [("tracestate", "congo=t61rcWkgMzE")]) `shouldBe` True
it "returns Nothing for a malformed traceparent" $
isNothing (creationLink malformedHeaders) `shouldBe` True
it "carries caller-supplied attributes with creationLinkWith" $ do
let attrs = HashMap.singleton "messaging.message.id" (toAttribute ("m-1" :: Text))
case creationLinkWith attrs producerHeaders of
Nothing -> expectationFailure "expected a link"
Just link -> link.linkAttributes `shouldBe` attrs
it "builds Consumer-kind arguments carrying the links" $ do
let links = maybe [] pure (creationLink producerHeaders)
args = consumerSpanArgsLinkedTo links
args.kind `shouldBe` OTel.Consumer
length args.links `shouldBe` 1
describe "processing spans through the runner" $ do
it "ParentAndLink: child of the producer, with one link to it" $ do
[sp] <- consumerSpans =<< runMessage ParentAndLink (Just producerHeaders) Nothing
hexTrace sp.context `shouldBe` producerTraceHex
fmap hexSpan sp.parent `shouldBe` Just producerSpanHex
map (\l -> (hexTrace l, hexSpan l)) sp.links `shouldBe` [(producerTraceHex, producerSpanHex)]
it "LinkOnly: a root in a new trace, with one link to the producer" $ do
[sp] <- consumerSpans =<< runMessage LinkOnly (Just producerHeaders) Nothing
hexTrace sp.context `shouldNotBe` producerTraceHex
isNothing sp.parent `shouldBe` True
map (\l -> (hexTrace l, hexSpan l)) sp.links `shouldBe` [(producerTraceHex, producerSpanHex)]
it "LinkOnly: still a root when an ambient span is active, which is restored afterwards" $ do
restored <- newIORef Nothing
spans <- withExporter $ \tracer -> runEff $
runTracing tracer $
withSpan' "ambient" OTel.defaultSpanArguments $ \ambient -> do
runOnce LinkOnly (Just producerHeaders) Nothing
liftIO $ do
ambientCtx <- OTel.getSpanContext ambient
current <- Ctx.lookupSpan <$> Ctx.getContext
currentCtx <- traverse OTel.getSpanContext current
writeIORef restored (Just (Just ambientCtx == currentCtx))
[sp] <- consumerSpans spans
isNothing sp.parent `shouldBe` True
length sp.links `shouldBe` 1
readIORef restored `shouldReturn` Just True
forM_ [minBound .. maxBound] $ \policy -> do
forM_ [("absent", Nothing), ("malformed", Just malformedHeaders)] $ \(label, headers) -> do
it (show policy <> ": " <> label <> " headers keep the ambient parent and add no link") $ do
(ambientSpanHex, spans) <- withAmbient $ \_tracer ->
runOnce policy headers Nothing
[sp] <- consumerSpans spans
sp.links `shouldBe` []
fmap hexSpan sp.parent `shouldBe` Just ambientSpanHex
it (show policy <> ": " <> label <> " headers without an ambient span give a root") $ do
[sp] <- consumerSpans =<< runMessage policy headers Nothing
sp.links `shouldBe` []
isNothing sp.parent `shouldBe` True
it "LinkOnly: currentTraceHeaders in the handler names the new trace and the Consumer span" $ do
seen <- newIORef Nothing
spans <- withExporter $ \tracer ->
runEff $
runTracing tracer $
runOnce LinkOnly (Just producerHeaders) (Just seen)
[sp] <- consumerSpans spans
Just (Just headers) <- readIORef seen
case extractTraceContext headers of
Nothing -> expectationFailure "handler saw no decodable traceparent"
Just ctx -> do
hexTrace ctx `shouldBe` hexTrace sp.context
hexSpan ctx `shouldBe` hexSpan sp.context
hexTrace ctx `shouldNotBe` producerTraceHex
it "LinkOnly: a superseded delivery still opens a linked span that ends successfully" $ do
spans <- withExporter $ \tracer -> runEff $ runTracing tracer $ do
let message = (producedMessage (Just producerHeaders)) {deliveryStatus = pure DeliverySuperseded}
void $ runWithMetrics 4 (ProcessorId "correlation") (policyAdapter LinkOnly [message]) (\_ -> pure AckOk)
[sp] <- consumerSpans spans
isNothing sp.parent `shouldBe` True
length sp.links `shouldBe` 1
sp.status `shouldBe` OTel.Ok
sp.ackDecisions `shouldBe` [toAttribute ("superseded" :: Text)]
forM_ [minBound .. maxBound] $ \policy ->
it (show policy <> ": disabled tracing runs the handler and exports nothing") $ do
handled <- newIORef (0 :: Int)
spans <- withExporter $ \_tracer -> runEff $ runTracingNoop $ do
let adapter = policyAdapter policy [producedMessage (Just producerHeaders)]
void $ runWithMetrics 4 (ProcessorId "correlation") adapter (\_ -> liftIO (modifyIORef' handled (+ 1)) >> pure AckOk)
readIORef handled `shouldReturn` 1
length spans `shouldBe` 0
describe "withProcessSpan and withProcessSpanIO called directly" $ do
forM_ [minBound .. maxBound] $ \policy -> do
it (show policy <> ": both give the runner's span shape") $ do
[runner] <- consumerSpans =<< runMessage policy (Just producerHeaders) Nothing
[effect] <- consumerSpans =<< withExporter (\tracer -> runEff $ runTracing tracer $ withProcessSpan policy processName (Just producerHeaders) (\_ -> pure ()))
[io] <- consumerSpans =<< withExporter (\tracer -> withProcessSpanIO tracer policy processName (Just producerHeaders) (\_ -> pure ()))
forM_ [effect, io] $ \sp -> do
sp.name `shouldBe` runner.name
fmap hexSpan sp.parent `shouldBe` fmap hexSpan runner.parent
map hexSpan sp.links `shouldBe` map hexSpan runner.links
(hexTrace sp.context == producerTraceHex) `shouldBe` (hexTrace runner.context == producerTraceHex)
it (show policy <> ": the IO helper keeps the ambient parent without headers") $ do
(ambientSpanHex, spans) <- withAmbient $ \tracer ->
liftIO $ withProcessSpanIO tracer policy processName Nothing (\_ -> pure ())
[sp] <- consumerSpans spans
sp.links `shouldBe` []
fmap hexSpan sp.parent `shouldBe` Just ambientSpanHex
it (show policy <> ": withProcessSpan under runTracingNoop passes the sentinel span") $ do
recording <- runEff $ runTracingNoop $ withProcessSpan policy processName (Just producerHeaders) (liftIO . OTel.isRecording)
recording `shouldBe` False
--------------------------------------------------------------------------------
-- Fixtures
--------------------------------------------------------------------------------
producerTraceHex :: Text
producerTraceHex = "0af7651916cd43dd8448eb211c80319c"
producerSpanHex :: Text
producerSpanHex = "b7ad6b7169203331"
producerHeaders :: TraceHeaders
producerHeaders = [("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")]
malformedHeaders :: [(ByteString, ByteString)]
malformedHeaders = [("traceparent", "00-not-a-valid-traceparent-01")]
processName :: Text
processName = processSpanName "correlation"
producedMessage :: (IOE :> es) => Maybe TraceHeaders -> Ingested es ()
producedMessage headers =
mkIngested ((mkEnvelope (MessageId "m-1") ()) {traceContext = headers}) (AckHandle (\_ -> pure ()))
policyAdapter :: (IOE :> es) => TraceCorrelation -> [Ingested es msg] -> Adapter es msg
policyAdapter policy messages =
(listAdapter messages) {telemetry = defaultAdapterTelemetry {traceCorrelation = policy}}
-- | Process one message on the calling thread (the unsupervised runner path),
-- so an ambient span on this thread is visible to the processing span.
runOnce ::
(IOE :> es, Tracing :> es) =>
TraceCorrelation ->
Maybe TraceHeaders ->
Maybe (IORef (Maybe (Maybe TraceHeaders))) ->
Eff es ()
runOnce policy headers seen =
void $
runWithMetrics 4 (ProcessorId "correlation") (policyAdapter policy [producedMessage headers]) $ \_ -> do
forM_ seen $ \ref -> currentTraceHeaders >>= liftIO . writeIORef ref . Just
pure AckOk
runMessage :: TraceCorrelation -> Maybe TraceHeaders -> Maybe (IORef (Maybe (Maybe TraceHeaders))) -> IO [OTel.ImmutableSpan]
runMessage policy headers seen =
withExporter $ \tracer -> runEff $ runTracing tracer $ runOnce policy headers seen
-- | Run inside an ambient span and return that span's ID with the exports.
withAmbient :: (OTel.Tracer -> Eff '[Tracing, IOE] ()) -> IO (Text, [OTel.ImmutableSpan])
withAmbient action = do
ambientRef <- newIORef ""
spans <- withExporter $ \tracer -> runEff $
runTracing tracer $
withSpan' "ambient" OTel.defaultSpanArguments $ \ambient -> do
ctx <- liftIO $ OTel.getSpanContext ambient
liftIO $ writeIORef ambientRef (hexSpan ctx)
action tracer
ambientSpanHex <- readIORef ambientRef
pure (ambientSpanHex, spans)
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 "correlation-test" "" "" emptyAttributes) OTel.tracerOptions))
readIORef spansRef
data ConsumerSpan = ConsumerSpan
{ name :: Text,
context :: OTel.SpanContext,
parent :: Maybe OTel.SpanContext,
links :: [OTel.SpanContext],
status :: OTel.SpanStatus,
ackDecisions :: [OTel.Attribute]
}
consumerSpans :: [OTel.ImmutableSpan] -> IO [ConsumerSpan]
consumerSpans 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
ConsumerSpan
{ name = OTel.hotName hot,
context = OTel.spanContext sp,
parent,
links = map OTel.frozenLinkContext (toList (appendOnlyBoundedCollectionValues (OTel.hotLinks hot))),
status = OTel.hotStatus hot,
ackDecisions =
[ value
| event <- toList (appendOnlyBoundedCollectionValues (OTel.hotEvents hot)),
OTel.eventName event == "shibuya.ack.decision",
Just value <- [HashMap.lookup "shibuya.ack.decision" (getAttributeMap (OTel.eventAttributes event))]
]
}
hexTrace :: OTel.SpanContext -> Text
hexTrace ctx = OTel.Id.traceIdBaseEncodedText Base16 ctx.traceId
hexSpan :: OTel.SpanContext -> Text
hexSpan ctx = OTel.Id.spanIdBaseEncodedText Base16 ctx.spanId