diff --git a/sofetch-otel.cabal b/sofetch-otel.cabal
new file mode 100644
--- /dev/null
+++ b/sofetch-otel.cabal
@@ -0,0 +1,56 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.38.1.
+--
+-- see: https://github.com/sol/hpack
+
+name:           sofetch-otel
+version:        0.1.0.0
+description:    OpenTelemetry instrumentation for sofetch
+category:       Data
+homepage:       https://github.com/iand675/sofetch#readme
+bug-reports:    https://github.com/iand675/sofetch/issues
+author:         Ian Duncan
+maintainer:     ian@iankduncan.com
+copyright:      2026 Ian Duncan
+license:        BSD-3-Clause
+build-type:     Simple
+
+source-repository head
+  type: git
+  location: https://github.com/iand675/sofetch
+
+library
+  exposed-modules:
+      Fetch.OpenTelemetry
+  other-modules:
+      Paths_sofetch_otel
+  autogen-modules:
+      Paths_sofetch_otel
+  hs-source-dirs:
+      src
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      base >=4.7 && <5
+    , hs-opentelemetry-api
+    , sofetch
+  default-language: Haskell2010
+
+test-suite sofetch-otel-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_sofetch_otel
+  autogen-modules:
+      Paths_sofetch_otel
+  hs-source-dirs:
+      test
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , hs-opentelemetry-api
+    , hspec
+    , sofetch
+    , sofetch-otel
+    , unordered-containers
+  default-language: Haskell2010
diff --git a/src/Fetch/OpenTelemetry.hs b/src/Fetch/OpenTelemetry.hs
new file mode 100644
--- /dev/null
+++ b/src/Fetch/OpenTelemetry.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | OpenTelemetry instrumentation for sofetch.
+--
+-- Creates a root span @sofetch.fetch@ covering the entire computation,
+-- with child spans @sofetch.round@ wrapping each batch round.
+--
+-- When no 'TracerProvider' is configured (the default), all tracing
+-- operations are no-ops with negligible overhead.
+--
+-- @
+-- import OpenTelemetry.Trace.Core (makeTracer, tracerOptions)
+--
+-- tracer <- makeTracer tracerProvider "sofetch" tracerOptions
+-- let cfg = fetchConfig id liftIO
+-- result <- runFetchWithOTel cfg tracer myFetchAction
+-- @
+module Fetch.OpenTelemetry
+  ( runFetchWithOTel
+  ) where
+
+import Fetch
+  ( Fetch, FetchConfig(..), FetchEnv(..)
+  , CacheRef, newCacheRef
+  , Batches, batchSourceCount, batchSize
+  , RoundStats(..)
+  , runLoopWith
+  )
+
+import Data.IORef
+import Data.Int (Int64)
+import OpenTelemetry.Trace.Core
+  ( Tracer
+  , SpanArguments(..), SpanKind(..), defaultSpanArguments
+  , addAttribute, inSpan'
+  )
+
+-- | Run a 'Fetch' computation with OpenTelemetry instrumentation.
+--
+-- Creates:
+--
+-- * A root span @sofetch.fetch@ covering the entire computation.
+-- * A child span @sofetch.round@ for each batch round, wrapping
+--   the actual batch execution (not just the scheduling).
+--
+-- Root span attributes:
+--
+-- * @sofetch.total_rounds@: number of batch rounds executed
+-- * @sofetch.total_keys@: total keys fetched across all rounds
+-- * @sofetch.max_sources_per_round@: max distinct data sources in any round
+--
+-- Round span attributes:
+--
+-- * @sofetch.round.number@: 1-based round index
+-- * @sofetch.round.sources@: data sources dispatched this round
+-- * @sofetch.round.keys@: keys dispatched this round
+-- * @sofetch.round.cache_hits@: keys served from cache this round
+--
+-- To share a cache across runs, set @configCache = Just cRef@ on the
+-- 'FetchConfig'.
+runFetchWithOTel :: forall m a.
+                     Monad m
+                  => FetchConfig m
+                  -> Tracer
+                  -> Fetch m a
+                  -> m a
+runFetchWithOTel cfg tracer action = do
+  let lower = configLower cfg
+      lift  = configLift cfg
+  cRef <- case configCache cfg of
+    Just ref -> pure ref
+    Nothing  -> lift newCacheRef
+  lift $ inSpan' tracer "sofetch.fetch" defaultSpanArguments { kind = Internal } $ \rootSpan -> do
+    statsRef <- newIORef (0 :: Int, 0 :: Int, 0 :: Int)
+
+    let e = FetchEnv
+          { fetchCache = cRef
+          , fetchLower = lower
+          , fetchLift  = lift
+          }
+
+        -- Wrap each round in an OTel span, then accumulate stats.
+        withRound :: Int -> Batches m -> m RoundStats -> m ()
+        withRound !n batches exec = do
+          let nSources = batchSourceCount batches
+              nKeys    = batchSize batches
+          rs <- lift $ inSpan' tracer "sofetch.round" defaultSpanArguments { kind = Internal } $ \roundSpan -> do
+            addAttribute roundSpan "sofetch.round.number"  (fromIntegral n :: Int64)
+            addAttribute roundSpan "sofetch.round.sources" (fromIntegral nSources :: Int64)
+            addAttribute roundSpan "sofetch.round.keys"    (fromIntegral nKeys :: Int64)
+            rs <- lower exec
+            addAttribute roundSpan "sofetch.round.cache_hits" (fromIntegral (roundCacheHits rs) :: Int64)
+            pure rs
+          lift $ modifyIORef' statsRef $ \(r, k', s) ->
+            (r + 1, k' + roundKeys rs, max s (roundSources rs))
+
+    result <- lower $ runLoopWith e withRound action
+
+    (rounds, keys, sources) <- readIORef statsRef
+    addAttribute rootSpan "sofetch.total_rounds"          (fromIntegral rounds  :: Int64)
+    addAttribute rootSpan "sofetch.total_keys"            (fromIntegral keys    :: Int64)
+    addAttribute rootSpan "sofetch.max_sources_per_round" (fromIntegral sources :: Int64)
+
+    pure result
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,207 @@
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+
+module Main (main) where
+
+import Fetch
+import Fetch.OpenTelemetry
+
+import Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as HM
+import Data.IORef
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (mapMaybe)
+import GHC.Generics (Generic)
+import OpenTelemetry.Trace.Core
+  ( Tracer, makeTracer, getGlobalTracerProvider
+  , InstrumentationLibrary(..), tracerOptions
+  )
+import OpenTelemetry.Attributes (emptyAttributes)
+import Test.Hspec
+
+-- ══════════════════════════════════════════════
+-- Test key types
+-- ══════════════════════════════════════════════
+
+newtype UserId = UserId Int
+  deriving stock (Eq, Ord, Show, Generic)
+  deriving anyclass (Hashable)
+
+instance FetchKey UserId where
+  type Result UserId = String
+
+newtype PostId = PostId Int
+  deriving stock (Eq, Ord, Show, Generic)
+  deriving anyclass (Hashable)
+
+instance FetchKey PostId where
+  type Result PostId = String
+
+newtype FailKey = FailKey Int
+  deriving stock (Eq, Ord, Show, Generic)
+  deriving anyclass (Hashable)
+
+instance FetchKey FailKey where
+  type Result FailKey = String
+
+-- ══════════════════════════════════════════════
+-- Test monad & data sources
+-- ══════════════════════════════════════════════
+
+data TestEnv = TestEnv
+  { envUsers   :: HashMap UserId String
+  , envUserLog :: IORef [[UserId]]
+  , envPosts   :: HashMap PostId String
+  }
+
+mkTestEnv :: IO TestEnv
+mkTestEnv = TestEnv
+  <$> pure (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])
+  <*> newIORef []
+  <*> pure (HM.fromList [(PostId 10, "Hello World")])
+
+newtype TestM a = TestM { unTestM :: TestEnv -> IO a }
+
+instance Functor TestM where
+  fmap f (TestM g) = TestM $ \env -> fmap f (g env)
+
+instance Applicative TestM where
+  pure a = TestM $ \_ -> pure a
+  TestM ff <*> TestM fx = TestM $ \env -> ff env <*> fx env
+
+instance Monad TestM where
+  TestM ma >>= f = TestM $ \env -> do
+    a <- ma env
+    unTestM (f a) env
+
+askTestEnv :: TestM TestEnv
+askTestEnv = TestM pure
+
+testLiftIO :: IO a -> TestM a
+testLiftIO io = TestM $ \_ -> io
+
+runTestM :: TestEnv -> TestM a -> IO a
+runTestM env (TestM f) = f env
+
+instance DataSource TestM UserId where
+  batchFetch keysNE = do
+    let keys = NE.toList keysNE
+    env <- askTestEnv
+    testLiftIO $ modifyIORef' (envUserLog env) (keys :)
+    pure $ HM.fromList
+      (mapMaybe (\k -> fmap (\v -> (k, v)) (HM.lookup k (envUsers env))) keys)
+
+instance DataSource TestM PostId where
+  batchFetch keysNE = do
+    let keys = NE.toList keysNE
+    env <- askTestEnv
+    pure $ HM.fromList
+      (mapMaybe (\k -> fmap (\v -> (k, v)) (HM.lookup k (envPosts env))) keys)
+
+instance DataSource TestM FailKey where
+  batchFetch _ = error "FailKey data source exploded"
+
+-- ══════════════════════════════════════════════
+-- Helpers
+-- ══════════════════════════════════════════════
+
+mkNoopTracer :: IO Tracer
+mkNoopTracer = do
+  tp <- getGlobalTracerProvider
+  pure $ makeTracer tp
+    (InstrumentationLibrary "sofetch-otel-test" "" "" emptyAttributes)
+    tracerOptions
+
+-- ══════════════════════════════════════════════
+-- Tests
+-- ══════════════════════════════════════════════
+
+main :: IO ()
+main = hspec $ describe "Fetch.OpenTelemetry" $ do
+
+  it "single fetch returns correct value" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    result <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $ fetch (UserId 1)
+    result `shouldBe` "Alice"
+
+  it "applicative batching works" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    (a, b) <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $
+        (,) <$> fetch (UserId 1) <*> fetch (UserId 2)
+    a `shouldBe` "Alice"
+    b `shouldBe` "Bob"
+    batches <- readIORef (envUserLog env)
+    length batches `shouldBe` 1
+
+  it "monadic >>= creates separate rounds" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    _ <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $ do
+        _ <- fetch (UserId 1)
+        fetch (UserId 2)
+    batches <- readIORef (envUserLog env)
+    length batches `shouldBe` 2
+
+  it "multi-source batching works" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    (user, post) <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $
+        (,) <$> fetch (UserId 1) <*> fetch (PostId 10)
+    user `shouldBe` "Alice"
+    post `shouldBe` "Hello World"
+
+  it "tryFetch returns Left for missing key" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    result <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $ tryFetch (UserId 999)
+    case result of
+      Left _  -> pure ()
+      Right _ -> expectationFailure "Expected Left for missing key"
+
+  it "data source exceptions are caught by tryFetch" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    result <- runTestM env $
+      runFetchWithOTel (fetchConfig (runTestM env) testLiftIO) tracer $ tryFetch (FailKey 1)
+    case result of
+      Left _  -> pure ()
+      Right _ -> expectationFailure "Expected Left for failed source"
+
+  it "shared cache across runs via configCache" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    cRef <- newCacheRef
+    _ <- runTestM env $
+      runFetchWithOTel ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef }) tracer $ fetch (UserId 1)
+    _ <- runTestM env $
+      runFetchWithOTel ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef }) tracer $ fetch (UserId 1)
+    batches <- readIORef (envUserLog env)
+    length batches `shouldBe` 1
+
+  it "primeCache seeds cache through OTel runner" $ do
+    env <- mkTestEnv
+    tracer <- mkNoopTracer
+    cRef <- newCacheRef
+    _ <- runTestM env $
+      runFetchWithOTel ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef }) tracer $ do
+        primeCache (UserId 1) "OTel-Seeded"
+    result <- runTestM env $
+      runFetchWithOTel ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef }) tracer $ fetch (UserId 1)
+    result `shouldBe` "OTel-Seeded"
+    batches <- readIORef (envUserLog env)
+    length batches `shouldBe` 0
