diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -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
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright Ian Duncan (c) 2021
+Copyright Ian Duncan (c) 2021-2026
 
 All rights reserved.
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/hs-opentelemetry-exporter-in-memory.cabal b/hs-opentelemetry-exporter-in-memory.cabal
--- a/hs-opentelemetry-exporter-in-memory.cabal
+++ b/hs-opentelemetry-exporter-in-memory.cabal
@@ -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
diff --git a/src/OpenTelemetry/Exporter/InMemory.hs b/src/OpenTelemetry/Exporter/InMemory.hs
--- a/src/OpenTelemetry/Exporter/InMemory.hs
+++ b/src/OpenTelemetry/Exporter/InMemory.hs
@@ -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
 
diff --git a/src/OpenTelemetry/Exporter/InMemory/Assertions.hs b/src/OpenTelemetry/Exporter/InMemory/Assertions.hs
new file mode 100644
--- /dev/null
+++ b/src/OpenTelemetry/Exporter/InMemory/Assertions.hs
@@ -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)
diff --git a/src/OpenTelemetry/Exporter/InMemory/LogRecord.hs b/src/OpenTelemetry/Exporter/InMemory/LogRecord.hs
--- a/src/OpenTelemetry/Exporter/InMemory/LogRecord.hs
+++ b/src/OpenTelemetry/Exporter/InMemory/LogRecord.hs
@@ -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
diff --git a/src/OpenTelemetry/Exporter/InMemory/Metric.hs b/src/OpenTelemetry/Exporter/InMemory/Metric.hs
new file mode 100644
--- /dev/null
+++ b/src/OpenTelemetry/Exporter/InMemory/Metric.hs
@@ -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)
diff --git a/src/OpenTelemetry/Exporter/InMemory/Span.hs b/src/OpenTelemetry/Exporter/InMemory/Span.hs
--- a/src/OpenTelemetry/Exporter/InMemory/Span.hs
+++ b/src/OpenTelemetry/Exporter/InMemory/Span.hs
@@ -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)
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -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"
