packages feed

sofetch-otel-0.1.0.0: src/Fetch/OpenTelemetry.hs

{-# 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