packages feed

glue-prometheus-0.8.0.0: test/Glue/PrometheusSpec.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Glue.PrometheusSpec (spec) where

import           Data.IORef
import           Data.List.NonEmpty    (NonEmpty (..))
import qualified Data.Map.Strict       as M
import           Data.Text             (Text)
import           Glue.CircuitBreaker
import           Glue.Clock.Virtual
import           Glue.ConcurrencyLimit
import           Glue.Hedging
import           Glue.Prometheus
import           Glue.Retry
import           Glue.Shutdown
import           Prometheus            (Metric (..), Sample (..),
                                        SampleGroup (..), getCounter, getGauge,
                                        getHistogram, getVectorWith)
import           Test.Hspec
import           UnliftIO.Async        (async, wait)
import           UnliftIO.Exception    (Exception, SomeException, throwIO, try)
import           UnliftIO.STM          (atomically, newEmptyTMVarIO, putTMVar,
                                        readTMVar)

data PrometheusTestException = PrometheusTestException
  deriving (Eq, Show)

instance Exception PrometheusTestException

counters :: Ord l => (m -> IO Double) -> IO [(l, m)] -> IO (M.Map l Double)
counters reader entries = entries >>= fmap M.fromList . traverse (\(l, m) -> (,) l <$> reader m)

observations :: [(Text, M.Map Double Int)] -> M.Map Text Int
observations = M.fromList . fmap (fmap sum)

spec :: Spec
spec = do
  describe "instrumentService" $ do
    it "Records calls, failures and durations per service" $ do
      metrics <- registerServiceMetrics
      let ok      = instrumentService metrics "ok" (\(x :: Int) -> pure x)
          failing = instrumentService metrics "failing" (\(_ :: Int) -> throwIO PrometheusTestException :: IO Int)
      _ <- traverse ok [1, 2, 3]
      failing 1 `shouldThrow` (== PrometheusTestException)
      getVectorWith (serviceCalls metrics) getCounter >>= (`shouldBe` M.fromList [("failing", 1), ("ok", 3)]) . M.fromList
      getVectorWith (serviceFailures metrics) getCounter >>= (`shouldBe` M.fromList [("failing", 1)]) . M.fromList
      observations <$> getVectorWith (serviceDuration metrics) getHistogram
        `shouldReturn` M.fromList [("failing", 1), ("ok", 3)]

    it "Counts Left results as failures for Either services" $ do
      metrics <- registerServiceMetrics
      let service = instrumentServiceEither metrics "either" (\(x :: Int) -> pure (if x > 0 then Right x else Left ("bad" :: String)))
      _ <- traverse service [1, 0, -1]
      M.fromList <$> getVectorWith (serviceFailures metrics) getCounter `shouldReturn` M.fromList [("either", 2)]

  describe "instrumentBreakerOptions" $
    it "Tracks the breaker phase and counts transitions, keeping the existing observer" $ do
      metrics <- registerBreakerMetrics
      vc      <- newVirtualClock
      seen    <- newIORef (0 :: Int)
      options <- instrumentBreakerOptions metrics "backend"
        (defaultCircuitBreakerOptions :: CircuitBreakerOptions IO SomeException)
          { breakerFailureThreshold = 1
          , breakerClock            = virtualClock vc
          , onBreakerTransition     = const (atomicModifyIORef' seen (\n -> (n + 1, ())))
          }
      counters getGauge (getVectorWith (breakerState metrics) pure) `shouldReturn` M.fromList [("backend", 0)]
      (_, breaker) <- circuitBreaker options (\(_ :: Int) -> throwIO PrometheusTestException :: IO Int)
      _ <- try (breaker 1) :: IO (Either PrometheusTestException Int)
      counters getGauge (getVectorWith (breakerState metrics) pure) `shouldReturn` M.fromList [("backend", 2)]
      counters getCounter (getVectorWith (breakerTransitions metrics) pure)
        `shouldReturn` M.fromList [(("backend", "closed", "open"), 1)]
      readIORef seen `shouldReturn` 1

  describe "instrumentRetryOptions" $
    it "Counts retries and their waits" $ do
      metrics <- registerRetryMetrics
      calls   <- newIORef (0 :: Int)
      let options = instrumentRetryOptions metrics "flaky" (defaultRetryOptions :: RetryOptions IO Int SomeException) { maximumRetries = 2 }
          service (x :: Int) = atomicModifyIORef' calls (\n -> (n + 1, n + 1)) >>= \n -> if n < 3 then throwIO PrometheusTestException else pure x
      retryingService options service 5 `shouldReturn` 5
      counters getCounter (getVectorWith (retryCount metrics) pure) `shouldReturn` M.fromList [("flaky", 2)]
      observations <$> getVectorWith (retryDelay metrics) getHistogram `shouldReturn` M.fromList [("flaky", 2)]

  describe "instrumentAdmissionStore" $
    it "Tracks slots in flight and refusals per limiter" $ do
      metrics <- registerConcurrencyMetrics
      store   <- instrumentAdmissionStore metrics "providers" <$> newInMemoryAdmissionStore (const 1)
      gate    <- newEmptyTMVarIO
      started <- newEmptyTMVarIO
      let held = withSlot store ("a" :: Text) (atomically (putTMVar started ()) >> atomically (readTMVar gate))
      waiting <- async held
      atomically $ readTMVar started
      counters getGauge (getVectorWith (concurrencyInFlight metrics) pure) `shouldReturn` M.fromList [("providers", 1)]
      withSlot store "a" (pure ()) `shouldReturn` Left Saturated
      counters getCounter (getVectorWith (concurrencyRefusals metrics) pure) `shouldReturn` M.fromList [("providers", 1)]
      atomically $ putTMVar gate ()
      wait waiting `shouldReturn` Right ()
      counters getGauge (getVectorWith (concurrencyInFlight metrics) pure) `shouldReturn` M.fromList [("providers", 0)]

  describe "instrumentHedgeOptions" $
    it "Counts backup launches, attempt outcomes and the winner" $ do
      metrics <- registerHedgeMetrics
      let options = instrumentHedgeOptions metrics "search" (defaultHedgeOptions :: HedgeOptions IO SomeException Int)
      hedgeAcross options ((\_ -> throwIO PrometheusTestException) :| [pure]) (7 :: Int) `shouldReturn` 7
      counters getCounter (getVectorWith (hedgeLaunches metrics) pure) `shouldReturn` M.fromList [(("search", "1"), 1)]
      counters getCounter (getVectorWith (hedgeAttempts metrics) pure)
        `shouldReturn` M.fromList [(("search", "0", "failed"), 1), (("search", "1", "answered"), 1)]
      counters getCounter (getVectorWith (hedgeWins metrics) pure) `shouldReturn` M.fromList [(("search", "1"), 1)]

  describe "shutdownInFlightMetric" $
    it "Samples each handle's in-flight count at collection" $ do
      gate    <- newEmptyTMVarIO
      started <- newEmptyTMVarIO
      (service, handle) <- gracefulShutdown (defaultShutdownOptions :: ShutdownOptions IO Int) $ \(x :: Int) -> do
        atomically $ putTMVar started ()
        atomically $ readTMVar gate
        pure x
      ((), collect) <- construct (shutdownInFlightMetric [("api", handle)])
      let values = fmap (\groups -> [(labels, value) | SampleGroup _ _ samples <- groups, Sample _ labels value <- samples]) collect
      values `shouldReturn` [([("service", "api")], "0.0")]
      call <- async (service 1)
      atomically $ readTMVar started
      values `shouldReturn` [([("service", "api")], "1.0")]
      atomically $ putTMVar gate ()
      wait call `shouldReturn` 1