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 +8/−0
- LICENSE +1/−1
- README.md +2/−0
- hs-opentelemetry-exporter-in-memory.cabal +25/−3
- src/OpenTelemetry/Exporter/InMemory.hs +12/−2
- src/OpenTelemetry/Exporter/InMemory/Assertions.hs +240/−0
- src/OpenTelemetry/Exporter/InMemory/LogRecord.hs +34/−1
- src/OpenTelemetry/Exporter/InMemory/Metric.hs +39/−0
- src/OpenTelemetry/Exporter/InMemory/Span.hs +36/−12
- test/Spec.hs +79/−0
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++[](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"