hs-opentelemetry-instrumentation-tasty (empty) → 0.1
raw patch · 7 files changed
+455/−0 lines, 7 filesdep +asyncdep +basedep +containers
Dependencies added: async, base, containers, hs-opentelemetry-api, hs-opentelemetry-instrumentation-tasty, hs-opentelemetry-sdk, tagged, tasty, tasty-hunit, text
Files
- ChangeLog.md +5/−0
- LICENSE +30/−0
- README.md +19/−0
- hs-opentelemetry-instrumentation-tasty.cabal +51/−0
- src/OpenTelemetry/Instrumentation/Tasty.hs +140/−0
- tests/Main.hs +16/−0
- tests/OpenTelemetry/Instrumentation/Tasty/Tests.hs +194/−0
+ ChangeLog.md view
@@ -0,0 +1,5 @@+# Changelog for hs-opentelemetry-instrumentation-tasty++## 0.0.1.0++- Initial release
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Ian Duncan (c) 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Ian Duncan nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,19 @@+# hs-opentelemetry-instrumentation-tasty++OpenTelemetry instrumentation library for Tasty.+It creates spans for:++1. Individual test cases+2. Test groups+3. Resource setup and teardown++The library should be robust to tests running in parallel.++## Usage++Usage requires:++1. Setting and tearing down a trace provider as normal in your test executable.+2. Calling `instrumentTestTree` on your `TestTree`.++See the test suite for examples of how to use the library.
+ hs-opentelemetry-instrumentation-tasty.cabal view
@@ -0,0 +1,51 @@+cabal-version: 2.0+name: hs-opentelemetry-instrumentation-tasty+version: 0.1++description: Please see the README on GitHub at <https://github.com/iand675/hs-opentelemetry/tree/main/instrumentation/tasty#readme>+homepage: https://github.com/iand675/hs-opentelemetry#readme+bug-reports: https://github.com/iand675/hs-opentelemetry/issues+author: Michael Peyton Jones+maintainer: me@michaelpj.com+copyright: 2024 Ian Duncan, Mercury Technologies+license: BSD3+license-file: LICENSE+build-type: Simple+extra-source-files:+ README.md+ ChangeLog.md++source-repository head+ type: git+ location: https://github.com/iand675/hs-opentelemetry++library+ default-language: Haskell2010+ build-depends:+ base >=4.7 && <5+ , hs-opentelemetry-api ^>=0.2+ , tagged ^>= 0.8+ , tasty ^>= 1.5+ , text++ exposed-modules: OpenTelemetry.Instrumentation.Tasty+ hs-source-dirs: src++test-suite tests+ default-language: Haskell2010+ type: exitcode-stdio-1.0+ main-is: Main.hs+ build-depends:+ async+ , base+ , containers+ , hs-opentelemetry-api+ , hs-opentelemetry-instrumentation-tasty+ , hs-opentelemetry-sdk+ , tasty+ , tasty-hunit+ , text++ other-modules: OpenTelemetry.Instrumentation.Tasty.Tests+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ hs-source-dirs: tests
+ src/OpenTelemetry/Instrumentation/Tasty.hs view
@@ -0,0 +1,140 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TemplateHaskell #-}++module OpenTelemetry.Instrumentation.Tasty (instrumentTestTree, instrumentTestTreeWithTracer) where++import Control.Exception (bracket)+import Data.Tagged (Tagged, retag)+import Data.Text qualified as T+import OpenTelemetry.Context (insertSpan, lookupSpan, removeSpan)+import OpenTelemetry.Context.ThreadLocal (adjustContext, getContext)+import OpenTelemetry.Trace.Core (Span, SpanStatus (Error, Ok), Tracer, addAttribute, createSpan, defaultSpanArguments, detectInstrumentationLibrary, endSpan, getGlobalTracerProvider, inSpan, makeTracer, setStatus, tracerOptions)+import Test.Tasty (TestTree, withResource)+import Test.Tasty.Options (OptionDescription)+import Test.Tasty.Providers (IsTest (run, testOptions))+import Test.Tasty.Runners (Outcome (Failure, Success), ResourceSpec (ResourceSpec), Result (Result, resultDescription, resultOutcome), TestTree (After, AskOptions, PlusTestOptions, SingleTest, TestGroup, WithResource))+++{- | A test case with a wrapper function that can do some IO around the+test. We use the wrapper to set up spans appropriately.+-}+data WrappedTest t = WrappedTest+ {wrapper :: forall a. IO a -> IO a, innerTest :: t}+++instance IsTest t => IsTest (WrappedTest t) where+ run opts (WrappedTest {wrapper, innerTest}) progress =+ wrapper $ do+ ctx <- getContext+ let mspan = lookupSpan ctx+ res@Result {resultOutcome, resultDescription} <- run opts innerTest progress+ case mspan of+ Just s -> do+ addAttribute s "result.description" (T.pack resultDescription)+ case resultOutcome of+ Success -> do+ setStatus s $ Ok+ Failure reason -> do+ setStatus s $ Error $ T.pack $ show reason+ Nothing -> pure ()++ pure res+ testOptions = retag (testOptions :: Tagged t [OptionDescription])+++-- | Transform a 'TestTree' into one that emits spans around tests and test groups.+instrumentTestTree :: TestTree -> IO TestTree+instrumentTestTree t = do+ provider <- getGlobalTracerProvider+ let tracer = makeTracer provider $detectInstrumentationLibrary tracerOptions+ pure $ instrumentTestTreeWithTracer tracer t+++-- | See 'instrumentTestTree'.+instrumentTestTreeWithTracer :: Tracer -> TestTree -> TestTree+instrumentTestTreeWithTracer tracer = instrumentTestTree' tracer (pure Nothing)+++instrumentTestTree'+ :: Tracer+ -> IO (Maybe Span)+ -> TestTree+ -> TestTree+instrumentTestTree' tracer = go+ where+ -- See Note [Test parallelism] for why we pass around 'getParentSpan'+ go :: IO (Maybe Span) -> TestTree -> TestTree+ go getParentSpan = \case+ TestGroup name tests ->+ -- We use 'withResource' to associate the creation and destruction of the+ -- group span with the beginning and end of the group itself. This way+ -- 'tasty' manages the lifetime of the span for us.+ withResource+ (mkSpan (T.pack name))+ (\s -> endSpan s Nothing)+ $ \getGroupSpan ->+ let getParentSpan' = Just <$> getGroupSpan+ in TestGroup name (fmap (go getParentSpan') tests)+ SingleTest name t ->+ SingleTest name $+ WrappedTest {wrapper = withNamedSpan name, innerTest = t}+ WithResource (ResourceSpec acquire release) f ->+ -- Add spans for resource acquisition and release+ -- Nit: currently we don't create a span for the top-level itself, so+ -- if you acquire outside a test group then the spans will be detached.+ -- We could add a span for the top level, although it's maybe a little odd.+ let newResourceSpec = ResourceSpec (withNamedSpan "acquire" acquire) (fmap (withNamedSpan "release") release)+ in WithResource newResourceSpec (go' . f)+ PlusTestOptions modifier t -> PlusTestOptions modifier (go' t)+ AskOptions f -> AskOptions (go' . f)+ After d e t -> After d e $ go' t+ where+ go' = go getParentSpan+ mkSpan name = do+ ctx <- getContext+ -- See Note [Test parallelism]+ parentSpan <- getParentSpan+ -- This does not modify the thread-local context, just locally+ ctx' <- case parentSpan of+ Just s -> pure $ insertSpan s ctx+ Nothing -> pure ctx+ createSpan tracer ctx' name defaultSpanArguments+ withNamedSpan :: String -> (forall a. IO a -> IO a)+ withNamedSpan name act = do+ -- See Note [Test parallelism]+ parentSpan <- getParentSpan+ let wrapper = case parentSpan of+ Just ps -> withParentSpan ps+ Nothing -> id+ wrapper $ inSpan tracer (T.pack name) defaultSpanArguments act+++-- Possibly should upstream this to the SDK?++{- | Given a span, produces a wrapper function that sets the given span+as the installed span in the context.+-}+withParentSpan :: Span -> (forall a. IO a -> IO a)+withParentSpan parentSpan act =+ bracket setup teardown $ \_ -> act+ where+ setup = do+ ctx <- getContext+ adjustContext (insertSpan parentSpan)+ pure (lookupSpan ctx, ctx)+ teardown (originalParentSpan, _ctx) = do+ adjustContext $ \ctx -> maybe (removeSpan ctx) (`insertSpan` ctx) originalParentSpan++{- Note [Test parallelism]+Tasty runs tests in parallel by default, and we don't want to disturb that.+However, that means that any individual test case may be running on a random thread at tasty's+discretion, and so we can't rely on the thread-local context to link up spans.++Our solution is just to track (an action to access) the parent span manually as we traverse+the tree, so we can connect them up manually.+-}
+ tests/Main.hs view
@@ -0,0 +1,16 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Main (main) where++import OpenTelemetry.Instrumentation.Tasty.Tests qualified+import Test.Tasty (TestTree, defaultMain, testGroup)+++main :: IO ()+main = defaultMain tests+++tests :: TestTree+tests =+ testGroup "Tasty instrumentation" $+ [OpenTelemetry.Instrumentation.Tasty.Tests.tests]
+ tests/OpenTelemetry/Instrumentation/Tasty/Tests.hs view
@@ -0,0 +1,194 @@+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedLists #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PatternSynonyms #-}++module OpenTelemetry.Instrumentation.Tasty.Tests (tests) where++import Control.Concurrent (newEmptyMVar, putMVar, takeMVar)+import Control.Concurrent.Async (async)+import Control.Exception (bracket)+import Data.Functor (void)+import Data.IORef (atomicModifyIORef, newIORef, readIORef)+import Data.Map (Map)+import Data.Map qualified as Map+import Data.Maybe (fromMaybe)+import Data.Set qualified as Set+import Data.Text (Text)+import OpenTelemetry.Instrumentation.Tasty (instrumentTestTree)+import OpenTelemetry.Processor.Span (ShutdownResult (ShutdownSuccess), SpanProcessor, spanProcessorForceFlush, spanProcessorOnEnd, spanProcessorOnStart, spanProcessorShutdown, pattern SpanProcessor)+import OpenTelemetry.Trace (ImmutableSpan (ImmutableSpan, spanName, spanParent), createTracerProvider, defaultSpanArguments, emptyTracerProviderOptions, getGlobalTracerProvider, inSpan, makeTracer, setGlobalTracerProvider, shutdownTracerProvider, tracerOptions)+import OpenTelemetry.Trace.Core (unsafeReadSpan)+import Test.Tasty (DependencyType (AllFinish), TestTree, sequentialTestGroup, testGroup, withResource)+import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.Ingredients (tryIngredients)+import Test.Tasty.Ingredients.Basic (Quiet (Quiet), consoleTestReporter)+import Test.Tasty.Options (setOption)+++tests :: TestTree+-- each of these tests sets and tears down the global tracer provider,+-- so they have to run sequentially+tests =+ sequentialTestGroup+ "OpenTelemetry.Instrumentation.Tasty"+ AllFinish+ [ basic+ , simpleNesting+ , branching+ , testWithSpan+ , parallelism+ , resources+ ]+++basic :: TestTree+basic = testCase "basic" $ do+ (_, trees) <- spanTrees $ do+ testTree <- instrumentTestTree $ testCase "hello" $ pure ()+ runTests $ testTree+ trees @?= [Leaf "hello"]+++simpleNesting :: TestTree+simpleNesting = testCase "nested groups make a simple tree" $ do+ (_, trees) <- spanTrees $ do+ testTree <-+ instrumentTestTree $+ testGroup+ "g1"+ [ testGroup "g2" [testCase "t1" (pure ())]+ ]+ runTests $ testTree+ trees @?= [Branch "g1" [Branch "g2" [Leaf "t1"]]]+++branching :: TestTree+branching = testCase "nested groups make a branching tree" $ do+ (_, trees) <- spanTrees $ do+ testTree <-+ instrumentTestTree $+ testGroup+ "g1"+ [ testGroup "g2" [testCase "t1" (pure ()), testCase "t2" (pure ())]+ , testGroup "g3" [testCase "t3" (pure ())]+ ]+ runTests $ testTree+ trees @?= [Branch "g1" [Branch "g2" [Leaf "t1", Leaf "t2"], Branch "g3" [Leaf "t3"]]]+++testWithSpan :: TestTree+testWithSpan = testCase "test that has a span itself" $ do+ (_, trees) <- spanTrees $ do+ testTree <- instrumentTestTree $ testCase "hello" $ do+ tp <- getGlobalTracerProvider+ let tracer = makeTracer tp "test" tracerOptions+ inSpan tracer "inner" defaultSpanArguments $ pure ()+ runTests $ testTree+ trees @?= [Branch "hello" [Leaf "inner"]]+++parallelism :: TestTree+parallelism = testCase "parallelism works" $ do+ block <- newEmptyMVar+ (_, trees) <- spanTrees $ do+ testTree <-+ instrumentTestTree $+ testGroup+ "g1"+ -- Force t1 to wait for t2 before it can even begin, so we+ -- should definitely not start the span for t1 until then+ [ testGroup+ "g2"+ [ withResource (takeMVar block) (const $ pure ()) $ \_ ->+ testCase "t1" (pure ())+ ]+ , testGroup "g3" [testCase "t2" (putMVar block ())]+ ]+ runTests $ testTree+ trees @?= [Branch "g1" [Branch "g2" [Leaf "acquire", Leaf "t1", Leaf "release"], Branch "g3" [Leaf "t2"]]]+++resources :: TestTree+resources = testCase "spans for resource setup and teardown" $ do+ (_, trees) <- spanTrees $ do+ tp <- getGlobalTracerProvider+ let tracer = makeTracer tp "test" tracerOptions+ let acquire = inSpan tracer "myAcquire" defaultSpanArguments $ pure ()+ let release _ = inSpan tracer "myRelease" defaultSpanArguments $ pure ()+ testTree <- instrumentTestTree $ withResource acquire release $ \_ -> testCase "hello" $ pure ()+ runTests $ testTree+ trees @?= [Leaf "hello", Branch "acquire" [Leaf "myAcquire"], Branch "release" [Leaf "myRelease"]]+++data Tree a = Branch a (Set.Set (Tree a)) | Leaf a+ deriving stock (Show, Eq, Ord)+++spanTrees :: IO a -> IO (a, Set.Set (Tree Text))+spanTrees act = do+ (processor, readSpans) <- recordingProcessor+ res <- bracket (setup processor) shutdownTracerProvider $ \_ -> do+ act+ spans <- readSpans+ trees <- spansToTrees spans+ pure (res, Set.fromList trees)+ where+ setup processor = do+ tp <- createTracerProvider [processor] emptyTracerProviderOptions+ setGlobalTracerProvider tp+ pure tp+++toChildMap :: [ImmutableSpan] -> ([Text], Map Text [Text]) -> IO ([Text], Map Text [Text])+toChildMap [] acc = pure acc+toChildMap (ImmutableSpan {spanParent, spanName} : spans) (accRoots, accChildren) = case spanParent of+ Just parent -> do+ ImmutableSpan {spanName = parentName} <- unsafeReadSpan parent+ let existingChildren = fromMaybe mempty $ Map.lookup parentName accChildren+ toChildMap spans (accRoots, Map.insert parentName (existingChildren ++ [spanName]) accChildren)+ Nothing -> toChildMap spans (spanName : accRoots, accChildren)+++toTree :: (Ord a) => Map a [a] -> a -> Tree a+toTree childMap node = case Map.lookup node childMap of+ Nothing -> Leaf node+ Just children ->+ if null children+ then Leaf node+ else Branch node $ Set.fromList $ fmap (toTree childMap) children+++spansToTrees :: [ImmutableSpan] -> IO [Tree Text]+spansToTrees spans = do+ (roots, childMap) <- toChildMap spans mempty+ let trees = fmap (toTree childMap) roots+ pure trees+++recordingProcessor :: IO (SpanProcessor, IO [ImmutableSpan])+recordingProcessor = do+ spans <- newIORef []++ let processor =+ SpanProcessor+ { spanProcessorOnStart = mempty+ , spanProcessorOnEnd = \spanRef -> do+ immutableSpan <- readIORef spanRef+ atomicModifyIORef spans $ \soFar -> (soFar ++ [immutableSpan], ())+ , spanProcessorShutdown = async $ pure ShutdownSuccess+ , spanProcessorForceFlush = mempty+ }++ setGlobalTracerProvider+ =<< createTracerProvider [processor] emptyTracerProviderOptions++ pure (processor, readIORef spans)+++runTests :: TestTree -> IO ()+runTests t = case tryIngredients [consoleTestReporter] (setOption (Quiet True) mempty) t of+ Just act -> void act+ Nothing -> error "no ingredient"