effectful-tracing-0.1.0.0: test/Effectful/Tracing/OpenTelemetrySpec.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.OpenTelemetrySpec
-- Description : Tests for the OpenTelemetry export interpreter.
--
-- Two angles. 'toImmutableSpan' is tested directly (the translation is the whole
-- contract: our ids, name, kind, status, and attributes must survive the
-- crossing into @hs-opentelemetry@'s representation). And 'runTracerOTel' is
-- tested end to end through a capturing 'SpanProcessor', confirming that a small
-- traced program drives well-formed spans, carrying our ids and parent linkage,
-- all the way to a processor. This is the runnable stand-in for the manual Jaeger
-- smoke test, which needs a live collector.
--
-- The processor is a synchronous one built straight from the @hs-opentelemetry@
-- API: it records each span in 'spanProcessorOnEnd' as the span finishes. The
-- SDK's @simpleProcessor@ exports on a worker thread whose shutdown races an
-- in-flight export, which drops spans nondeterministically; a synchronous
-- processor removes that race while exercising exactly the interface
-- 'runTracerOTel' drives.
module Effectful.Tracing.OpenTelemetrySpec
( tests
) where
import Control.Concurrent.Async (async)
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)
import Data.ByteString (ByteString)
import Data.IORef (readIORef)
import Data.List (find, nub, sort)
import Data.Maybe (isNothing)
import Data.Text qualified as T
import OpenTelemetry.Attributes qualified as OtelAttr
import OpenTelemetry.Processor.Span
( ShutdownResult (ShutdownSuccess)
, SpanProcessor (..)
)
import OpenTelemetry.Trace.Core qualified as OTel
import OpenTelemetry.Trace.Id qualified as OtelId
import Effectful (Eff, IOE, runEff, (:>))
import Hedgehog (Property, evalEither, evalIO, forAll, property, (===))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Test.Tasty.Hedgehog (testProperty)
import Effectful.Tracing
( SpanArguments (kind)
, Tracer
, addAttribute
, defaultSpanArguments
, withSpan
, withSpan'
)
import Effectful.Tracing.Attribute (Attribute (attributeKey))
import Effectful.Tracing.Gen (genSpan)
import Effectful.Tracing.Internal.Ids (SpanId (SpanId), TraceId (TraceId))
import Effectful.Tracing.Internal.Types
( Span (..)
, SpanContext (..)
, SpanKind (Server)
, SpanStatus (Error, Ok, Unset)
)
import Effectful.Tracing.Interpreter.InMemory
( newCapturedSpans
, readCapturedSpans
, runTracerInMemory
)
import Effectful.Tracing.Interpreter.OpenTelemetry
( OtelConfig (OtelConfig, instrumentationScope, sampler, spanLimits, spanProcessors)
, runTracerOTel
, toImmutableSpan
)
import Effectful.Tracing.Sampler (alwaysOn)
import Effectful.Tracing.SpanLimits (defaultSpanLimits)
tests :: TestTree
tests =
testGroup
"OpenTelemetry"
[ testGroup "toImmutableSpan" translationTests
, testGroup "toImmutableSpan (property)" translationProperties
, testGroup "runTracerOTel (end to end)" endToEndTests
]
-- A small traced program: a parent span with an attribute, wrapping a child.
program :: Tracer :> es => Eff es ()
program =
withSpan "parent" $ do
addAttribute "service.name" ("checkout" :: String)
withSpan "child" (pure ())
translationTests :: [TestTree]
translationTests =
[ testCase "preserves ids, name, status, and attributes" $ do
spans <- runInMemory (withSpan "root" (addAttribute "k" ("v" :: String)))
tracer <- makeTestTracer
completed <- single spans
case toImmutableSpan tracer completed of
Left err -> fail ("translation failed: " <> err)
Right immutable -> do
OTel.spanName immutable @?= "root"
let TraceId ourTrace = spanContextTraceId (spanContext completed)
SpanId ourSpan = spanContextSpanId (spanContext completed)
OtelId.traceIdBytes (OTel.traceId (OTel.spanContext immutable)) @?= ourTrace
OtelId.spanIdBytes (OTel.spanId (OTel.spanContext immutable)) @?= ourSpan
OTel.spanStatus immutable @?= OTel.Unset
OtelAttr.getCount (OTel.spanAttributes immutable) @?= 1
, testCase "maps span kind" $ do
spans <- runInMemory (withSpan' "srv" defaultSpanArguments {kind = Server} (pure ()))
tracer <- makeTestTracer
completed <- single spans
case toImmutableSpan tracer completed of
Left err -> fail err
Right immutable -> case OTel.spanKind immutable of
OTel.Server -> pure ()
other -> assertFailure ("expected Server kind, got " <> show other)
]
translationProperties :: [TestTree]
translationProperties =
[ testProperty "preserves ids, name, kind, status, and attribute count" prop_translationPreserves
]
-- | For any generated span, the crossing into @hs-opentelemetry@ must be
-- lossless on the fields that identify and classify the span: our trace and
-- span id bytes, the name, the kind, the status, and the number of distinct
-- attribute keys. (The OTel attribute set is a keyed map, so the count it
-- reports is the number of distinct keys, which is what we compare against.)
prop_translationPreserves :: Property
prop_translationPreserves = property $ do
completed <- forAll genSpan
tracer <- evalIO makeTestTracer
immutable <- evalEither (toImmutableSpan tracer completed)
let TraceId ourTrace = spanContextTraceId (spanContext completed)
SpanId ourSpan = spanContextSpanId (spanContext completed)
OtelId.traceIdBytes (OTel.traceId (OTel.spanContext immutable)) === ourTrace
OtelId.spanIdBytes (OTel.spanId (OTel.spanContext immutable)) === ourSpan
OTel.spanName immutable === spanName completed
-- Our 'SpanKind' and OpenTelemetry's share constructor names, so comparing
-- their 'Show' output sidesteps the missing 'Eq' on OTel's 'SpanKind'.
show (OTel.spanKind immutable) === show (spanKind completed)
OTel.spanStatus immutable === expectedStatus (spanStatus completed)
OtelAttr.getCount (OTel.spanAttributes immutable)
=== length (nub (map attributeKey (spanAttributes completed)))
-- | The expected OpenTelemetry status for one of ours.
expectedStatus :: SpanStatus -> OTel.SpanStatus
expectedStatus Unset = OTel.Unset
expectedStatus Ok = OTel.Ok
expectedStatus (Error message) = OTel.Error message
endToEndTests :: [TestTree]
endToEndTests =
[ testCase "drives well-formed spans to a processor, parent linkage intact" $ do
(processor, captured) <- capturingProcessor
let config =
OtelConfig
{ spanProcessors = [processor]
, instrumentationScope = "effectful-tracing-test"
, sampler = alwaysOn
, spanLimits = defaultSpanLimits
}
runEff (runTracerOTel config program)
exported <- readMVar captured
sort (map (T.unpack . OTel.spanName) exported) @?= ["child", "parent"]
parentSpan <- requireSpan "parent" exported
childSpan <- requireSpan "child" exported
-- Same trace.
OtelId.traceIdBytes (OTel.traceId (OTel.spanContext childSpan))
@?= OtelId.traceIdBytes (OTel.traceId (OTel.spanContext parentSpan))
-- The child's parent is the parent span.
childParent <- parentSpanId childSpan
childParent @?= Just (OtelId.spanIdBytes (OTel.spanId (OTel.spanContext parentSpan)))
-- The parent is a root (no parent of its own).
assertBool "parent span is a root" (isNothing (OTel.spanParent parentSpan))
]
-- | A synchronous 'SpanProcessor' that records every span it sees in
-- 'spanProcessorOnEnd' into an 'MVar', in finish order.
capturingProcessor :: IO (SpanProcessor, MVar [OTel.ImmutableSpan])
capturingProcessor = do
captured <- newMVar []
let processor =
SpanProcessor
{ spanProcessorOnStart = \_ _ -> pure ()
, spanProcessorOnEnd = \ref -> do
immutable <- readIORef ref
modifyMVar_ captured (\acc -> pure (acc <> [immutable]))
, spanProcessorShutdown = async (pure ShutdownSuccess)
, spanProcessorForceFlush = pure ()
}
pure (processor, captured)
-- | The OTel span id of an immutable span's parent, if any.
parentSpanId :: OTel.ImmutableSpan -> IO (Maybe ByteString)
parentSpanId immutable =
case OTel.spanParent immutable of
Nothing -> pure Nothing
Just p -> do
context <- OTel.getSpanContext p
pure (Just (OtelId.spanIdBytes (OTel.spanId context)))
requireSpan :: String -> [OTel.ImmutableSpan] -> IO OTel.ImmutableSpan
requireSpan name spans =
maybe (fail ("no span named " <> name)) pure (find ((== name) . T.unpack . OTel.spanName) spans)
-- | Build an OTel tracer with no processors, just to satisfy translation.
makeTestTracer :: IO OTel.Tracer
makeTestTracer = do
provider <- OTel.createTracerProvider [] OTel.emptyTracerProviderOptions
pure (OTel.makeTracer provider "effectful-tracing-test" OTel.tracerOptions)
single :: [a] -> IO a
single [x] = pure x
single other = fail ("expected exactly one span, got " <> show (length other))
runInMemory :: Eff '[Tracer, IOE] a -> IO [Span]
runInMemory action = runEff $ do
captured <- newCapturedSpans
_ <- runTracerInMemory captured action
readCapturedSpans captured