packages feed

hs-opentelemetry-exporter-in-memory 0.0.1.5 → 1.0.0.0

raw patch · 10 files changed

+476/−19 lines, 10 filesdep +hs-opentelemetry-exporter-in-memorydep +hspecdep +textdep ~hs-opentelemetry-api

Dependencies added: hs-opentelemetry-exporter-in-memory, hspec, text, vector

Dependency ranges changed: hs-opentelemetry-api

Files

ChangeLog.md view
@@ -1,5 +1,13 @@ # Changelog for hs-opentelemetry-exporter-in-memory +## Unreleased++## 1.0.0.0 - 2026-05-29++- `OpenTelemetry.Exporter.InMemory` barrel now re-exports all three signals (Span, Metric, LogRecord); deprecated annotation removed+- New `OpenTelemetry.Exporter.InMemory.LogRecord` module (in-memory log exporter for testing)+- New `OpenTelemetry.Exporter.InMemory.Metric` module (in-memory metric exporter for tests)+ ## 0.0.1.5  - Relax `hs-opentelemetry-api` bounds to support 0.3.x
LICENSE view
@@ -1,4 +1,4 @@-Copyright Ian Duncan (c) 2021+Copyright Ian Duncan (c) 2021-2026  All rights reserved. 
README.md view
@@ -1,1 +1,3 @@ # hs-opentelemetry-exporter-in-memory++[![hs-opentelemetry-exporter-in-memory](https://img.shields.io/hackage/v/hs-opentelemetry-exporter-in-memory?style=flat-square&logo=haskell&label=hs-opentelemetry-exporter-in-memory&labelColor=5D4F85)](https://hackage.haskell.org/package/hs-opentelemetry-exporter-in-memory)
hs-opentelemetry-exporter-in-memory.cabal view
@@ -1,12 +1,14 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.37.0.+-- This file has been generated from package.yaml by hpack version 0.38.3. -- -- see: https://github.com/sol/hpack  name:           hs-opentelemetry-exporter-in-memory-version:        0.0.1.5+version:        1.0.0.0+synopsis:       In-memory exporter for OpenTelemetry testing description:    Please see the README on GitHub at <https://github.com/iand675/hs-opentelemetry/tree/main/exporters/in-memory#readme>+category:       OpenTelemetry, Telemetry, Testing homepage:       https://github.com/iand675/hs-opentelemetry#readme bug-reports:    https://github.com/iand675/hs-opentelemetry/issues author:         Ian Duncan@@ -26,7 +28,9 @@ library   exposed-modules:       OpenTelemetry.Exporter.InMemory+      OpenTelemetry.Exporter.InMemory.Assertions       OpenTelemetry.Exporter.InMemory.LogRecord+      OpenTelemetry.Exporter.InMemory.Metric       OpenTelemetry.Exporter.InMemory.Span   other-modules:       Paths_hs_opentelemetry_exporter_in_memory@@ -36,6 +40,24 @@   build-depends:       async     , base >=4.7 && <5-    , hs-opentelemetry-api ==0.3.*+    , hs-opentelemetry-api ==1.0.*+    , text     , unagi-chan+    , vector+  default-language: Haskell2010++test-suite hs-opentelemetry-exporter-in-memory-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Paths_hs_opentelemetry_exporter_in_memory+  hs-source-dirs:+      test+  ghc-options: -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      base >=4.7 && <5+    , hs-opentelemetry-api ==1.0.*+    , hs-opentelemetry-exporter-in-memory ==1.0.*+    , hspec+    , text   default-language: Haskell2010
src/OpenTelemetry/Exporter/InMemory.hs view
@@ -1,7 +1,17 @@-module OpenTelemetry.Exporter.InMemory-  {-# DEPRECATED "use OpenTelemetry.Exporter.InMemory.Span instead" #-} (+{- |+Module      : OpenTelemetry.Exporter.InMemory+Description : Re-exports for in-memory exporters, primarily used for testing.+Stability   : experimental+-}+module OpenTelemetry.Exporter.InMemory (   module OpenTelemetry.Exporter.InMemory.Span,+  module OpenTelemetry.Exporter.InMemory.Metric,+  module OpenTelemetry.Exporter.InMemory.LogRecord,+  module OpenTelemetry.Exporter.InMemory.Assertions, ) where +import OpenTelemetry.Exporter.InMemory.Assertions+import OpenTelemetry.Exporter.InMemory.LogRecord+import OpenTelemetry.Exporter.InMemory.Metric import OpenTelemetry.Exporter.InMemory.Span 
+ src/OpenTelemetry/Exporter/InMemory/Assertions.hs view
@@ -0,0 +1,240 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      :  OpenTelemetry.Exporter.InMemory.Assertions+Copyright   :  (c) Ian Duncan, 2024-2026+License     :  BSD-3+Description :  Testing assertion helpers built on top of in-memory exporters.+Stability   :  experimental++Import this module in your test suite alongside 'OpenTelemetry.Exporter.InMemory'+to get ergonomic span/metric/log assertions without writing manual IORef reads and+list searches.++@+(proc, spansRef) <- inMemoryListExporter+-- ... run instrumented code ...+span <- assertSpanNamed spansRef "my-operation"+assertSpanAttribute span "http.method" (AttributeValue (TextAttribute "GET"))+@+-}+module OpenTelemetry.Exporter.InMemory.Assertions (+  -- * Exception type+  OTelAssertionError (..),++  -- * Span assertions+  getSpans,+  assertSpanNamed,+  assertSpanCount,+  assertNoSpans,+  assertSpanAttribute,+  assertSpanHasParent,+  assertSpanStatus,++  -- * Metric assertions+  getMetricExports,+  assertMetricNamed,+  assertMetricCount,+  assertNoMetrics,++  -- * Log assertions+  getLogRecords,+  assertLogCount,+  assertNoLogs,+) where++import Control.Exception (Exception, throwIO)+import Control.Monad (unless)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef (IORef, readIORef)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import OpenTelemetry.Attributes (Attribute, lookupAttribute)+import OpenTelemetry.Exporter.Metric (+  MetricExport (..),+  ResourceMetricsExport (..),+  ScopeMetricsExport (..),+ )+import OpenTelemetry.Log.Core (ReadableLogRecord)+import OpenTelemetry.Trace.Core (+  ImmutableSpan (..),+  SpanHot (..),+  SpanStatus (..),+ )+++-- | Exception thrown when an OpenTelemetry assertion fails.+newtype OTelAssertionError = OTelAssertionError String+  deriving (Eq)+++instance Show OTelAssertionError where+  show (OTelAssertionError msg) = "OTelAssertionError: " <> msg+++instance Exception OTelAssertionError+++-- Spans++-- | Read all spans currently in the in-memory exporter.+getSpans :: (MonadIO m) => IORef [ImmutableSpan] -> m [ImmutableSpan]+getSpans = liftIO . readIORef+++{- | Find the first span with the given name. Throws if not found.+When multiple spans share a name, returns the most recently ended one+(the in-memory list exporter prepends).+-}+assertSpanNamed :: (MonadIO m) => IORef [ImmutableSpan] -> Text -> m ImmutableSpan+assertSpanNamed ref name = liftIO $ do+  spans <- readIORef ref+  result <- findByName name spans+  case result of+    Nothing -> do+      names <- mapM (\s -> hotName <$> readIORef (spanHot s)) spans+      throwIO $ OTelAssertionError $ "Expected span named " <> show name <> " but found: [" <> T.unpack (T.intercalate ", " names) <> "]"+    Just s -> pure s+++-- | Assert the total number of exported spans.+assertSpanCount :: (MonadIO m) => IORef [ImmutableSpan] -> Int -> m ()+assertSpanCount ref expected = liftIO $ do+  spans <- readIORef ref+  let actual = length spans+  unless (actual == expected) $+    throwIO $+      OTelAssertionError $+        "Expected " <> show expected <> " spans but found " <> show actual+++-- | Assert no spans have been exported.+assertNoSpans :: (MonadIO m) => IORef [ImmutableSpan] -> m ()+assertNoSpans ref = assertSpanCount ref 0+++-- | Assert a span has a specific attribute value.+assertSpanAttribute :: (MonadIO m) => ImmutableSpan -> Text -> Attribute -> m ()+assertSpanAttribute span_ key expected = liftIO $ do+  hot <- readIORef (spanHot span_)+  let actual = lookupAttribute (hotAttributes hot) key+      name = hotName hot+  case actual of+    Nothing ->+      throwIO $ OTelAssertionError $ "Span " <> show name <> " missing attribute " <> show key+    Just v ->+      unless (v == expected) $+        throwIO $+          OTelAssertionError $+            "Span " <> show name <> " attribute " <> show key <> ": expected " <> show expected <> " but got " <> show v+++-- | Assert a span has a parent span.+assertSpanHasParent :: (MonadIO m) => ImmutableSpan -> m ()+assertSpanHasParent span_ = liftIO $ do+  case spanParent span_ of+    Nothing -> do+      name <- hotName <$> readIORef (spanHot span_)+      throwIO $ OTelAssertionError $ "Span " <> show name <> " has no parent"+    Just _ -> pure ()+++-- | Assert a span's status.+assertSpanStatus :: (MonadIO m) => ImmutableSpan -> SpanStatus -> m ()+assertSpanStatus span_ expected = liftIO $ do+  hot <- readIORef (spanHot span_)+  let actual = hotStatus hot+      name = hotName hot+  unless (actual == expected) $+    throwIO $+      OTelAssertionError $+        "Span " <> show name <> " status: expected " <> show expected <> " but got " <> show actual+++-- Metrics++-- | Read all metric export batches.+getMetricExports :: (MonadIO m) => IORef [ResourceMetricsExport] -> m [ResourceMetricsExport]+getMetricExports = liftIO . readIORef+++-- | Find the first metric with the given name across all exports.+assertMetricNamed :: (MonadIO m) => IORef [ResourceMetricsExport] -> Text -> m MetricExport+assertMetricNamed ref name = liftIO $ do+  batches <- readIORef ref+  case findMetricByName name batches of+    Nothing ->+      throwIO $ OTelAssertionError $ "Expected metric named " <> show name <> " but not found in exports"+    Just m -> pure m+++-- | Assert the total number of distinct metric names across all exports.+assertMetricCount :: (MonadIO m) => IORef [ResourceMetricsExport] -> Int -> m ()+assertMetricCount ref expected = liftIO $ do+  batches <- readIORef ref+  let actual = length (allMetrics batches)+  unless (actual == expected) $+    throwIO $+      OTelAssertionError $+        "Expected " <> show expected <> " metrics but found " <> show actual+++-- | Assert no metrics have been exported.+assertNoMetrics :: (MonadIO m) => IORef [ResourceMetricsExport] -> m ()+assertNoMetrics ref = assertMetricCount ref 0+++-- Logs++-- | Read all exported log records.+getLogRecords :: (MonadIO m) => IORef [ReadableLogRecord] -> m [ReadableLogRecord]+getLogRecords = liftIO . readIORef+++-- | Assert the total number of exported log records.+assertLogCount :: (MonadIO m) => IORef [ReadableLogRecord] -> Int -> m ()+assertLogCount ref expected = liftIO $ do+  logs <- readIORef ref+  let actual = length logs+  unless (actual == expected) $+    throwIO $+      OTelAssertionError $+        "Expected " <> show expected <> " log records but found " <> show actual+++-- | Assert no log records have been exported.+assertNoLogs :: (MonadIO m) => IORef [ReadableLogRecord] -> m ()+assertNoLogs ref = assertLogCount ref 0+++-- Internal helpers++findByName :: Text -> [ImmutableSpan] -> IO (Maybe ImmutableSpan)+findByName _ [] = pure Nothing+findByName name (s : rest) = do+  n <- hotName <$> readIORef (spanHot s)+  if n == name then pure (Just s) else findByName name rest+++metricExportName :: MetricExport -> Text+metricExportName (MetricExportSum n _ _ _ _ _ _ _) = n+metricExportName (MetricExportHistogram n _ _ _ _ _) = n+metricExportName (MetricExportExponentialHistogram n _ _ _ _ _) = n+metricExportName (MetricExportGauge n _ _ _ _ _) = n+++findMetricByName :: Text -> [ResourceMetricsExport] -> Maybe MetricExport+findMetricByName name batches = go (allMetrics batches)+  where+    go [] = Nothing+    go (m : rest)+      | metricExportName m == name = Just m+      | otherwise = go rest+++allMetrics :: [ResourceMetricsExport] -> [MetricExport]+allMetrics = concatMap extractMetrics+  where+    extractMetrics (ResourceMetricsExport _ scopes) =+      concatMap (\(ScopeMetricsExport _ ms) -> V.toList ms) (V.toList scopes)
src/OpenTelemetry/Exporter/InMemory/LogRecord.hs view
@@ -1,2 +1,35 @@-module OpenTelemetry.Exporter.InMemory.LogRecord () where+{- |+Module      : OpenTelemetry.Exporter.InMemory.LogRecord+Description : In-memory log record exporter for testing. Stores exported log records in an IORef.+Stability   : experimental+-}+module OpenTelemetry.Exporter.InMemory.LogRecord (+  inMemoryLogRecordExporter,+  getExportedLogRecords,+) where +import Control.Monad.IO.Class+import Data.IORef+import qualified Data.Vector as V+import OpenTelemetry.Internal.Common.Types (ExportResult (..), FlushResult (..))+import OpenTelemetry.Internal.Log.Types+++inMemoryLogRecordExporter :: (MonadIO m) => m (LogRecordExporter, IORef [ReadableLogRecord])+inMemoryLogRecordExporter = liftIO $ do+  ref <- newIORef []+  exporter <-+    mkLogRecordExporter+      LogRecordExporterArguments+        { logRecordExporterArgumentsExport = \lrs -> do+            let newRecords = V.toList lrs+            atomicModifyIORef ref (\existing -> (newRecords ++ existing, ()))+            pure Success+        , logRecordExporterArgumentsForceFlush = pure FlushSuccess+        , logRecordExporterArgumentsShutdown = pure ()+        }+  pure (exporter, ref)+++getExportedLogRecords :: (MonadIO m) => IORef [ReadableLogRecord] -> m [ReadableLogRecord]+getExportedLogRecords = liftIO . readIORef
+ src/OpenTelemetry/Exporter/InMemory/Metric.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module      :  OpenTelemetry.Exporter.InMemory.Metric+Copyright   :  (c) Ian Duncan, 2024-2026+License     :  BSD-3+Description :  In-memory metric exporter for tests.+Stability   :  experimental++Exports are collected into an 'IORef' list; use 'readIORef' to inspect after+calling 'metricExporterExport' (directly or via the SDK's collect+export cycle).+-}+module OpenTelemetry.Exporter.InMemory.Metric (+  inMemoryMetricExporter,+) where++import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import qualified Data.Vector as V+import OpenTelemetry.Exporter.Metric (+  MetricExporter (..),+  ResourceMetricsExport,+ )+import OpenTelemetry.Internal.Common.Types (ExportResult (..), FlushResult (..), ShutdownResult (..))+++-- | Create a 'MetricExporter' backed by an 'IORef' that appends each batch.+inMemoryMetricExporter :: (MonadIO m) => m (MetricExporter, IORef [ResourceMetricsExport])+inMemoryMetricExporter = liftIO $ do+  ref <- newIORef []+  let ex =+        MetricExporter+          { metricExporterExport = \batches -> do+              atomicModifyIORef' ref (\acc -> (acc ++ V.toList batches, ()))+              pure Success+          , metricExporterShutdown = pure ShutdownSuccess+          , metricExporterForceFlush = pure FlushSuccess+          }+  pure (ex, ref)
src/OpenTelemetry/Exporter/InMemory/Span.hs view
@@ -1,10 +1,37 @@+{- |+Module      : OpenTelemetry.Exporter.InMemory.Span+Copyright   : (c) Ian Duncan, 2021-2026+License     : BSD-3+Description : In-memory span exporter for testing+Stability   : experimental++= Overview++Stores exported spans in an 'IORef' for inspection in tests. This is the+recommended exporter for unit testing your instrumentation.++= Quick example++@+import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)++(processor, ref) <- inMemoryListExporter+tp <- createTracerProvider [processor] emptyTracerProviderOptions+let tracer = makeTracer tp "test" tracerOptions++-- ... run your instrumented code ...++forceFlushTracerProvider tp Nothing+spans <- readIORef ref+-- Now inspect 'spans' to verify your instrumentation+@+-} module OpenTelemetry.Exporter.InMemory.Span (   inMemoryChannelExporter,   inMemoryListExporter,   module Control.Concurrent.Chan.Unagi, ) where -import Control.Concurrent.Async import Control.Concurrent.Chan.Unagi import Control.Monad.IO.Class import Data.IORef@@ -21,11 +48,10 @@   let processor =         SpanProcessor           { spanProcessorOnStart = \_ _ -> pure ()-          , spanProcessorOnEnd = \ref -> do-              writeChan inChan =<< readIORef ref-          , spanProcessorShutdown = do-              async $ pure ShutdownSuccess-          , spanProcessorForceFlush = pure ()+          , spanProcessorOnEnd = \imm ->+              writeChan inChan imm+          , spanProcessorShutdown = pure ShutdownSuccess+          , spanProcessorForceFlush = pure FlushSuccess           }   pure (processor, outChan) @@ -39,11 +65,9 @@   let processor =         SpanProcessor           { spanProcessorOnStart = \_ _ -> pure ()-          , spanProcessorOnEnd = \ref -> do-              s <- readIORef ref-              atomicModifyIORef listRef (\l -> (s : l, ()))-          , spanProcessorShutdown = do-              async $ pure ShutdownSuccess-          , spanProcessorForceFlush = pure ()+          , spanProcessorOnEnd = \imm ->+              atomicModifyIORef listRef (\l -> (imm : l, ()))+          , spanProcessorShutdown = pure ShutdownSuccess+          , spanProcessorForceFlush = pure FlushSuccess           }   pure (processor, listRef)
+ test/Spec.hs view
@@ -0,0 +1,79 @@+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Data.IORef+import qualified OpenTelemetry.Context as Context+import OpenTelemetry.Exporter.InMemory.Span (inMemoryChannelExporter, inMemoryListExporter, readChan)+import OpenTelemetry.Trace.Core+import Test.Hspec+++main :: IO ()+main = hspec spec+++spec :: Spec+spec =+  -- In-memory span exporter / processor (implementation-specific test helper)+  -- https://opentelemetry.io/docs/specs/otel/trace/sdk/#span-processor+  do+    describe "inMemoryListExporter" $ do+      it "starts with empty list" $ do+        (_, ref) <- inMemoryListExporter+        spans <- readIORef ref+        length spans `shouldBe` 0++      -- Ended spans exported to processor (SimpleSpanProcessor behavior)+      -- https://opentelemetry.io/docs/specs/otel/trace/sdk/#simplespanprocessor+      it "captures ended spans" $ do+        (processor, ref) <- inMemoryListExporter+        tp <- createTracerProvider [processor] emptyTracerProviderOptions+        let t = makeTracer tp "test" tracerOptions+        s <- createSpan t Context.empty "test-span" defaultSpanArguments+        endSpan s Nothing+        _ <- shutdownTracerProvider tp Nothing+        spans <- readIORef ref+        length spans `shouldBe` 1+        case spans of+          (s : _) -> do+            hot <- readIORef (spanHot s)+            hotName hot `shouldBe` "test-span"+          [] -> expectationFailure "no spans"++      it "captures multiple spans in order of completion" $ do+        (processor, ref) <- inMemoryListExporter+        tp <- createTracerProvider [processor] emptyTracerProviderOptions+        let t = makeTracer tp "test" tracerOptions+        s1 <- createSpan t Context.empty "first" defaultSpanArguments+        s2 <- createSpan t Context.empty "second" defaultSpanArguments+        endSpan s1 Nothing+        endSpan s2 Nothing+        _ <- shutdownTracerProvider tp Nothing+        spans <- readIORef ref+        length spans `shouldBe` 2++      it "does not capture spans that have not ended" $ do+        (processor, ref) <- inMemoryListExporter+        tp <- createTracerProvider [processor] emptyTracerProviderOptions+        let t = makeTracer tp "test" tracerOptions+        _ <- createSpan t Context.empty "unfinished" defaultSpanArguments+        _ <- shutdownTracerProvider tp Nothing+        spans <- readIORef ref+        length spans `shouldBe` 0++    describe "inMemoryChannelExporter" $ do+      it "creates a processor and channel" $ do+        (_, _chan) <- inMemoryChannelExporter+        pure () :: IO ()++      it "captures ended spans via channel" $ do+        (processor, chan) <- inMemoryChannelExporter+        tp <- createTracerProvider [processor] emptyTracerProviderOptions+        let t = makeTracer tp "test" tracerOptions+        s <- createSpan t Context.empty "chan-span" defaultSpanArguments+        endSpan s Nothing+        _ <- shutdownTracerProvider tp Nothing+        element <- readChan chan+        hot <- readIORef (spanHot element)+        hotName hot `shouldBe` "chan-span"